From 3059946d4506a47f56b4cad4672d6713ecfbf0f9 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 18 Apr 2026 13:23:17 +0800 Subject: [PATCH 1/6] =?UTF-8?q?refactor(provider):Provider=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=88=86=E5=B1=82=E9=87=8D=E6=9E=84=E4=B8=8E=20Discov?= =?UTF-8?q?ery=20=E9=80=9A=E7=94=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/loader_test.go | 162 ++++++++-- internal/config/provider.go | 218 +++++++++---- internal/config/provider_loader.go | 235 +++++++++++--- internal/config/provider_test.go | 86 ++++- internal/config/provider_windows_test.go | 66 ++++ internal/config/state/model_test.go | 28 +- .../config/state/service_provider_create.go | 87 ++++-- .../state/service_provider_create_test.go | 94 ++++++ internal/config/state/service_test.go | 14 +- internal/provider/anthropic/driver.go | 113 +++++++ internal/provider/anthropic/driver_test.go | 24 ++ internal/provider/auth.go | 36 +++ internal/provider/auth_test.go | 40 +++ internal/provider/builtin/builtin.go | 10 +- internal/provider/builtin/builtin_test.go | 14 + internal/provider/catalog/additional_test.go | 77 +++-- internal/provider/catalog/service.go | 14 +- internal/provider/catalog/service_test.go | 58 ++++ internal/provider/catalog/store.go | 2 +- internal/provider/catalog/store_test.go | 20 +- internal/provider/constants.go | 20 ++ internal/provider/contracts.go | 24 +- internal/provider/discovery/endpoint.go | 16 + internal/provider/discovery/endpoint_test.go | 43 +++ internal/provider/discovery/http/client.go | 152 +++++++++ .../provider/discovery/http/client_test.go | 58 ++++ internal/provider/discovery/models.go | 293 ++++++++++++++++++ internal/provider/discovery/models_test.go | 174 +++++++++++ internal/provider/gemini/driver.go | 85 +++++ internal/provider/gemini/driver_test.go | 41 +++ internal/provider/identity.go | 227 +++++++++++++- internal/provider/identity_test.go | 121 +++++++- .../openaicompat/chatcompletions/provider.go | 13 +- .../openaicompat/chatcompletions/response.go | 3 +- .../chatcompletions/sse_reader.go | 64 ---- internal/provider/openaicompat/discovery.go | 89 ++---- internal/provider/openaicompat/driver.go | 17 +- .../openaicompat/driver_internal_test.go | 68 +++- .../openaicompat/openaicompat_test.go | 216 +++++++++++++ internal/provider/openaicompat/provider.go | 66 +++- .../provider/openaicompat/shared/shared.go | 26 +- .../provider/openaicompat/sse_reader_test.go | 267 ---------------- internal/provider/protocols.go | 245 +++++++++++++++ internal/provider/protocols_test.go | 73 +++++ internal/provider/registry_test.go | 6 +- internal/provider/streaming/sse/reader.go | 78 +++++ .../sse/reader_test.go} | 24 +- internal/provider/types/model.go | 197 +++++++++++- internal/provider/types/model_test.go | 74 +++++ internal/tui/core/app/app.go | 28 +- internal/tui/core/app/update.go | 112 ++++--- internal/tui/core/app/update_test.go | 32 +- internal/tui/core/app/view.go | 20 ++ 53 files changed, 3607 insertions(+), 763 deletions(-) create mode 100644 internal/config/provider_windows_test.go create mode 100644 internal/provider/anthropic/driver.go create mode 100644 internal/provider/anthropic/driver_test.go create mode 100644 internal/provider/auth.go create mode 100644 internal/provider/auth_test.go create mode 100644 internal/provider/discovery/endpoint.go create mode 100644 internal/provider/discovery/endpoint_test.go create mode 100644 internal/provider/discovery/http/client.go create mode 100644 internal/provider/discovery/http/client_test.go create mode 100644 internal/provider/discovery/models.go create mode 100644 internal/provider/discovery/models_test.go create mode 100644 internal/provider/gemini/driver.go create mode 100644 internal/provider/gemini/driver_test.go delete mode 100644 internal/provider/openaicompat/chatcompletions/sse_reader.go delete mode 100644 internal/provider/openaicompat/sse_reader_test.go create mode 100644 internal/provider/protocols.go create mode 100644 internal/provider/protocols_test.go create mode 100644 internal/provider/streaming/sse/reader.go rename internal/provider/{openaicompat/chatcompletions/sse_reader_test.go => streaming/sse/reader_test.go} (68%) diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 842f6ef8..131b7182 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -262,6 +262,8 @@ models: openai_compatible: base_url: https://llm.example.com/v1 api_style: chat_completions + discovery_endpoint_path: /gateway/models + discovery_response_profile: generic ` customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { @@ -295,6 +297,16 @@ openai_compatible: if customProvider.BaseURL != "https://llm.example.com/v1" { t.Fatalf("expected base url https://llm.example.com/v1, got %q", customProvider.BaseURL) } + if customProvider.DiscoveryEndpointPath != "/gateway/models" { + t.Fatalf("expected discovery endpoint /gateway/models, got %q", customProvider.DiscoveryEndpointPath) + } + if customProvider.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { + t.Fatalf( + "expected discovery response profile %q, got %q", + provider.DiscoveryResponseProfileGeneric, + customProvider.DiscoveryResponseProfile, + ) + } if customProvider.Model != "" { t.Fatalf("expected custom provider default model to be empty, got %q", customProvider.Model) } @@ -362,6 +374,32 @@ func TestLoaderRejectsMalformedCustomProviderYAML(t *testing.T) { } } +func TestLoaderRejectsTopLevelDiscoverySettingsForKnownDriver(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 +discovery_endpoint_path: /models +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) + } + + if _, err := loader.Load(context.Background()); err == nil || !strings.Contains(err.Error(), "openai_compatible") { + t.Fatalf("expected known driver top-level discovery placement error, got %v", err) + } +} + func TestLoaderRejectsCustomProviderDefaultModel(t *testing.T) { t.Parallel() @@ -861,77 +899,127 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { baseDir := t.TempDir() tests := []struct { - name string - driver string - baseURL string - apiStyle string - deploymentMode string - apiVersion string - assert func(t *testing.T, cfg ProviderConfig) + name string + driver string + baseURL string + apiStyle string + deploymentMode string + apiVersion string + discoveryEndpointPath string + discoveryResponseProfile string + assert func(t *testing.T, cfg ProviderConfig) }{ { - name: "openaicompat settings", - driver: provider.DriverOpenAICompat, - baseURL: "https://llm.example.com/v1", - apiStyle: provider.OpenAICompatibleAPIStyleResponses, - deploymentMode: "ignored", - apiVersion: "ignored", + name: "openaicompat settings", + driver: provider.DriverOpenAICompat, + baseURL: "https://llm.example.com/v1", + apiStyle: provider.OpenAICompatibleAPIStyleResponses, + deploymentMode: "ignored", + apiVersion: "ignored", + discoveryEndpointPath: "/gateway/models", + discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() if cfg.APIStyle != provider.OpenAICompatibleAPIStyleResponses { t.Fatalf("expected APIStyle=%q, got %q", provider.OpenAICompatibleAPIStyleResponses, cfg.APIStyle) } + if cfg.DiscoveryEndpointPath != "/gateway/models" { + t.Fatalf("expected DiscoveryEndpointPath=/gateway/models, got %q", cfg.DiscoveryEndpointPath) + } + if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { + t.Fatalf( + "expected DiscoveryResponseProfile=%q, got %q", + provider.DiscoveryResponseProfileGeneric, + cfg.DiscoveryResponseProfile, + ) + } if cfg.DeploymentMode != "" || cfg.APIVersion != "" { t.Fatalf("expected non-openai specific settings to be empty, got %+v", cfg) } }, }, { - name: "gemini settings", - driver: provider.DriverGemini, - baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - apiStyle: "ignored", - deploymentMode: "vertex", - apiVersion: "ignored", + name: "gemini settings", + driver: provider.DriverGemini, + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + apiStyle: "ignored", + deploymentMode: "vertex", + apiVersion: "ignored", + discoveryEndpointPath: "/models", + discoveryResponseProfile: provider.DiscoveryResponseProfileGemini, assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() if cfg.DeploymentMode != "vertex" { t.Fatalf("expected DeploymentMode=vertex, got %q", cfg.DeploymentMode) } - if cfg.APIStyle != "" || cfg.APIVersion != "" { - t.Fatalf("expected non-gemini specific settings to be empty, got %+v", cfg) + if cfg.DiscoveryEndpointPath != "/models" { + t.Fatalf("expected DiscoveryEndpointPath=/models, got %q", cfg.DiscoveryEndpointPath) + } + if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGemini { + t.Fatalf( + "expected DiscoveryResponseProfile=%q, got %q", + provider.DiscoveryResponseProfileGemini, + cfg.DiscoveryResponseProfile, + ) + } + if cfg.APIStyle != provider.OpenAICompatibleAPIStyleChatCompletions || cfg.APIVersion != "" { + t.Fatalf("expected gemini legacy api_style and empty api_version, got %+v", cfg) } }, }, { - name: "anthropic settings", - driver: provider.DriverAnthropic, - baseURL: "https://api.anthropic.com/v1", - apiStyle: "ignored", - deploymentMode: "ignored", - apiVersion: "2023-06-01", + name: "anthropic settings", + driver: provider.DriverAnthropic, + baseURL: "https://api.anthropic.com/v1", + apiStyle: "ignored", + deploymentMode: "ignored", + apiVersion: "2023-06-01", + discoveryEndpointPath: "/models", + discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() if cfg.APIVersion != "2023-06-01" { t.Fatalf("expected APIVersion=2023-06-01, got %q", cfg.APIVersion) } + if cfg.DiscoveryEndpointPath != "/models" { + t.Fatalf("expected DiscoveryEndpointPath=/models, got %q", cfg.DiscoveryEndpointPath) + } + if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { + t.Fatalf( + "expected DiscoveryResponseProfile=%q, got %q", + provider.DiscoveryResponseProfileGeneric, + cfg.DiscoveryResponseProfile, + ) + } if cfg.APIStyle != "" || cfg.DeploymentMode != "" { t.Fatalf("expected non-anthropic specific settings to be empty, got %+v", cfg) } }, }, { - name: "unknown driver keeps top-level base url", - driver: "custom-driver", - baseURL: "https://custom.example.com/v1", - apiStyle: "responses", - deploymentMode: "vertex", - apiVersion: "2023-06-01", + name: "unknown driver keeps top-level base url", + driver: "custom-driver", + baseURL: "https://custom.example.com/v1", + apiStyle: "responses", + deploymentMode: "vertex", + apiVersion: "2023-06-01", + discoveryEndpointPath: "/catalog/models", + discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() if cfg.BaseURL != "https://custom.example.com/v1" { t.Fatalf("expected BaseURL=https://custom.example.com/v1, got %q", cfg.BaseURL) } + if cfg.DiscoveryEndpointPath != "/catalog/models" { + t.Fatalf("expected DiscoveryEndpointPath=/catalog/models, got %q", cfg.DiscoveryEndpointPath) + } + if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { + t.Fatalf( + "expected DiscoveryResponseProfile=%q, got %q", + provider.DiscoveryResponseProfileGeneric, + cfg.DiscoveryResponseProfile, + ) + } if cfg.APIStyle != "" || cfg.DeploymentMode != "" || cfg.APIVersion != "" { t.Fatalf("expected unknown driver protocol settings to be empty, got %+v", cfg) } @@ -955,6 +1043,8 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { tt.apiStyle, tt.deploymentMode, tt.apiVersion, + tt.discoveryEndpointPath, + tt.discoveryResponseProfile, ); err != nil { t.Fatalf("SaveCustomProvider() error = %v", err) } @@ -1001,6 +1091,8 @@ func TestSaveCustomProviderRejectsUnsafeProviderName(t *testing.T) { provider.OpenAICompatibleAPIStyleChatCompletions, "", "", + "", + "", ) if err == nil { t.Fatalf("expected SaveCustomProvider to reject %q", name) @@ -1150,6 +1242,8 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { provider.OpenAICompatibleAPIStyleChatCompletions, "", "", + "", + "", ) if err == nil { t.Fatal("expected create provider dir error") @@ -1172,6 +1266,8 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { provider.OpenAICompatibleAPIStyleChatCompletions, "", "", + "", + "", ) if err == nil { t.Fatal("expected write provider error") diff --git a/internal/config/provider.go b/internal/config/provider.go index 1487e1dd..690dd8c7 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -18,16 +18,24 @@ const ( ) type ProviderConfig struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` - APIStyle string `yaml:"-"` - DeploymentMode string `yaml:"-"` - APIVersion string `yaml:"-"` - Models []providertypes.ModelDescriptor `yaml:"-"` - Source ProviderSource `yaml:"-"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` + ChatProtocol string `yaml:"-"` + ChatEndpointPath string `yaml:"-"` + DiscoveryProtocol string `yaml:"-"` + AuthStrategy string `yaml:"-"` + ResponseProfile string `yaml:"-"` + APIStyle string `yaml:"-"` + DeploymentMode string `yaml:"-"` + APIVersion string `yaml:"-"` + DiscoveryEndpointPath string `yaml:"-"` + DiscoveryResponseProfile string `yaml:"-"` + ModelFieldAliases string `yaml:"-"` + Models []providertypes.ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` } type ResolvedProviderConfig struct { @@ -68,6 +76,23 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.APIKeyEnv) == "" { return fmt.Errorf("provider %q api_key_env is empty", p.Name) } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + p.Driver, + p.ChatProtocol, + p.ChatEndpointPath, + p.DiscoveryProtocol, + p.DiscoveryEndpointPath, + p.AuthStrategy, + p.ResponseProfile, + p.APIStyle, + p.DiscoveryResponseProfile, + ) + if err != nil { + return fmt.Errorf("provider %q: %w", p.Name, err) + } + if _, err := provider.NormalizeProviderDiscoveryResponseProfile(normalizedProtocols.ResponseProfile); err != nil { + return fmt.Errorf("provider %q: %w", p.Name, err) + } if _, err := p.Identity(); err != nil { return fmt.Errorf("provider %q: %w", p.Name, err) } @@ -85,11 +110,25 @@ func (p ProviderConfig) ResolveAPIKey() (string, error) { } value := strings.TrimSpace(os.Getenv(envName)) - if value == "" { - return "", fmt.Errorf("config: environment variable %s is empty", envName) + if value != "" { + return value, nil + } + + // 进程环境未命中时回退读取用户级环境变量(Windows 为注册表持久化), + // 并回填到当前进程环境,避免后续链路重复出现“变量为空”的假阴性。 + userValue, exists, err := LookupUserEnvVar(envName) + if err != nil { + return "", fmt.Errorf("config: lookup user environment variable %s: %w", envName, err) + } + if exists { + trimmedUserValue := strings.TrimSpace(userValue) + if trimmedUserValue != "" { + _ = os.Setenv(envName, trimmedUserValue) + return trimmedUserValue, nil + } } - return value, nil + return "", fmt.Errorf("config: environment variable %s is empty", envName) } func (p ProviderConfig) Resolve() (ResolvedProviderConfig, error) { @@ -153,30 +192,67 @@ func normalizeProviderDriver(driver string) string { // providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { - apiStyle := cfg.APIStyle - if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(apiStyle) == "" { - apiStyle = provider.OpenAICompatibleAPIStyleChatCompletions + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + cfg.Driver, + cfg.ChatProtocol, + cfg.ChatEndpointPath, + cfg.DiscoveryProtocol, + cfg.DiscoveryEndpointPath, + cfg.AuthStrategy, + cfg.ResponseProfile, + cfg.APIStyle, + cfg.DiscoveryResponseProfile, + ) + if err != nil { + return provider.ProviderIdentity{}, err } return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ - Driver: cfg.Driver, - BaseURL: cfg.BaseURL, - APIStyle: apiStyle, - DeploymentMode: cfg.DeploymentMode, - APIVersion: cfg.APIVersion, + Driver: cfg.Driver, + BaseURL: cfg.BaseURL, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + APIStyle: normalizedProtocols.LegacyAPIStyle, + DeploymentMode: cfg.DeploymentMode, + APIVersion: cfg.APIVersion, + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, }) } // ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { + normalizedProtocols, _ := provider.NormalizeProviderProtocolSettings( + p.Driver, + p.ChatProtocol, + p.ChatEndpointPath, + p.DiscoveryProtocol, + p.DiscoveryEndpointPath, + p.AuthStrategy, + p.ResponseProfile, + p.APIStyle, + p.DiscoveryResponseProfile, + ) + return provider.RuntimeConfig{ - Name: p.Name, - Driver: p.Driver, - BaseURL: p.BaseURL, - DefaultModel: p.Model, - APIKey: p.APIKey, - APIStyle: p.APIStyle, - DeploymentMode: p.DeploymentMode, - APIVersion: p.APIVersion, + Name: p.Name, + Driver: p.Driver, + BaseURL: p.BaseURL, + DefaultModel: p.Model, + APIKey: p.APIKey, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + APIStyle: normalizedProtocols.LegacyAPIStyle, + DeploymentMode: p.DeploymentMode, + APIVersion: p.APIVersion, + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, + ModelFieldAliases: p.ModelFieldAliases, } } @@ -205,52 +281,80 @@ const ( // OpenAIProvider returns the builtin OpenAI provider definition. func OpenAIProvider() ProviderConfig { return ProviderConfig{ - Name: OpenAIName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, + Name: OpenAIName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileOpenAI, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, + Source: ProviderSourceBuiltin, } } // GeminiProvider returns the builtin Gemini provider definition. func GeminiProvider() ProviderConfig { return ProviderConfig{ - Name: GeminiName, - Driver: provider.DriverOpenAICompat, - BaseURL: GeminiDefaultBaseURL, - Model: GeminiDefaultModel, - APIKeyEnv: GeminiDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, + Name: GeminiName, + Driver: provider.DriverGemini, + BaseURL: GeminiDefaultBaseURL, + Model: GeminiDefaultModel, + APIKeyEnv: GeminiDefaultAPIKeyEnv, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileGemini, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileGemini, + Source: ProviderSourceBuiltin, } } // OpenLLProvider returns the builtin OpenLL provider definition. func OpenLLProvider() ProviderConfig { return ProviderConfig{ - Name: OpenLLName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenLLDefaultBaseURL, - Model: OpenLLDefaultModel, - APIKeyEnv: OpenLLDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, + Name: OpenLLName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenLLDefaultBaseURL, + Model: OpenLLDefaultModel, + APIKeyEnv: OpenLLDefaultAPIKeyEnv, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileOpenAI, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, + Source: ProviderSourceBuiltin, } } // QiniuProvider returns the builtin Qiniu provider definition. func QiniuProvider() ProviderConfig { return ProviderConfig{ - Name: QiniuName, - Driver: provider.DriverOpenAICompat, - BaseURL: QiniuDefaultBaseURL, - Model: QiniuDefaultModel, - APIKeyEnv: QiniuDefaultAPIKeyEnv, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - Source: ProviderSourceBuiltin, + Name: QiniuName, + Driver: provider.DriverOpenAICompat, + BaseURL: QiniuDefaultBaseURL, + Model: QiniuDefaultModel, + APIKeyEnv: QiniuDefaultAPIKeyEnv, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileOpenAI, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, + Source: ProviderSourceBuiltin, } } diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index c4911e8b..3c15bcbe 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "encoding/json" "errors" "fmt" "os" @@ -20,14 +21,22 @@ const ( ) type customProviderFile struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - APIKeyEnv string `yaml:"api_key_env"` - BaseURL string `yaml:"base_url,omitempty"` - Models []customProviderModelFile `yaml:"models,omitempty"` - OpenAICompatible customOpenAICompatibleFile `yaml:"openai_compatible,omitempty"` - Gemini customGeminiProviderFile `yaml:"gemini,omitempty"` - Anthropic customAnthropicProviderFile `yaml:"anthropic,omitempty"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + APIKeyEnv string `yaml:"api_key_env"` + BaseURL string `yaml:"base_url,omitempty"` + ChatProtocol string `yaml:"chat_protocol,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` + AuthStrategy string `yaml:"auth_strategy,omitempty"` + ResponseProfile string `yaml:"response_profile,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` + ModelFieldAliases map[string][]string `yaml:"model_field_aliases,omitempty"` + Models []customProviderModelFile `yaml:"models,omitempty"` + OpenAICompatible customOpenAICompatibleFile `yaml:"openai_compatible,omitempty"` + Gemini customGeminiProviderFile `yaml:"gemini,omitempty"` + Anthropic customAnthropicProviderFile `yaml:"anthropic,omitempty"` } type customProviderModelFile struct { @@ -38,25 +47,54 @@ type customProviderModelFile struct { } type customOpenAICompatibleFile struct { - BaseURL string `yaml:"base_url"` - APIStyle string `yaml:"api_style,omitempty"` + BaseURL string `yaml:"base_url"` + ChatProtocol string `yaml:"chat_protocol,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` + AuthStrategy string `yaml:"auth_strategy,omitempty"` + ResponseProfile string `yaml:"response_profile,omitempty"` + APIStyle string `yaml:"api_style,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` } type customGeminiProviderFile struct { - BaseURL string `yaml:"base_url,omitempty"` - DeploymentMode string `yaml:"deployment_mode,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + ChatProtocol string `yaml:"chat_protocol,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` + AuthStrategy string `yaml:"auth_strategy,omitempty"` + ResponseProfile string `yaml:"response_profile,omitempty"` + DeploymentMode string `yaml:"deployment_mode,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` } type customAnthropicProviderFile struct { - BaseURL string `yaml:"base_url,omitempty"` - APIVersion string `yaml:"api_version,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + ChatProtocol string `yaml:"chat_protocol,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` + AuthStrategy string `yaml:"auth_strategy,omitempty"` + ResponseProfile string `yaml:"response_profile,omitempty"` + APIVersion string `yaml:"api_version,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` } type customProviderSettings struct { - BaseURL string - APIStyle string - DeploymentMode string - APIVersion string + BaseURL string + ChatProtocol string + ChatEndpointPath string + DiscoveryProtocol string + AuthStrategy string + ResponseProfile string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string + ModelFieldAliases string } // loadCustomProviders 扫描 baseDir/providers 下的一层子目录,并将其中的 provider.yaml 解析为运行时配置。 @@ -119,6 +157,9 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { if err := decoder.Decode(&file); err != nil { return ProviderConfig{}, fmt.Errorf("config: parse %s: %w", providerPath, err) } + if err := validateCustomProviderDiscoveryFieldPlacement(file); err != nil { + return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) + } settings := resolveCustomProviderSettings(file) models, err := customProviderModels(file.Models) @@ -127,20 +168,47 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { } cfg := ProviderConfig{ - Name: strings.TrimSpace(file.Name), - Driver: strings.TrimSpace(file.Driver), - BaseURL: settings.BaseURL, - APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), - APIStyle: settings.APIStyle, - DeploymentMode: settings.DeploymentMode, - APIVersion: settings.APIVersion, - Models: models, - Source: ProviderSourceCustom, + Name: strings.TrimSpace(file.Name), + Driver: strings.TrimSpace(file.Driver), + BaseURL: settings.BaseURL, + APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), + ChatProtocol: settings.ChatProtocol, + ChatEndpointPath: settings.ChatEndpointPath, + DiscoveryProtocol: settings.DiscoveryProtocol, + AuthStrategy: settings.AuthStrategy, + ResponseProfile: settings.ResponseProfile, + APIStyle: settings.APIStyle, + DeploymentMode: settings.DeploymentMode, + APIVersion: settings.APIVersion, + DiscoveryEndpointPath: settings.DiscoveryEndpointPath, + DiscoveryResponseProfile: settings.DiscoveryResponseProfile, + ModelFieldAliases: settings.ModelFieldAliases, + Models: models, + Source: ProviderSourceCustom, } - if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(cfg.APIStyle) == "" { - cfg.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + cfg.Driver, + cfg.ChatProtocol, + cfg.ChatEndpointPath, + cfg.DiscoveryProtocol, + cfg.DiscoveryEndpointPath, + cfg.AuthStrategy, + cfg.ResponseProfile, + cfg.APIStyle, + cfg.DiscoveryResponseProfile, + ) + if err != nil { + return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) } + cfg.ChatProtocol = normalizedProtocols.ChatProtocol + cfg.ChatEndpointPath = normalizedProtocols.ChatEndpointPath + cfg.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol + cfg.AuthStrategy = normalizedProtocols.AuthStrategy + cfg.ResponseProfile = normalizedProtocols.ResponseProfile + cfg.APIStyle = normalizedProtocols.LegacyAPIStyle + 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) @@ -199,20 +267,80 @@ func resolveCustomProviderSettings(file customProviderFile) customProviderSettin switch normalizeProviderDriver(file.Driver) { case provider.DriverOpenAICompat: settings.BaseURL = strings.TrimSpace(file.OpenAICompatible.BaseURL) + settings.ChatProtocol = strings.TrimSpace(file.OpenAICompatible.ChatProtocol) + settings.ChatEndpointPath = strings.TrimSpace(file.OpenAICompatible.ChatEndpointPath) + settings.DiscoveryProtocol = strings.TrimSpace(file.OpenAICompatible.DiscoveryProtocol) + settings.AuthStrategy = strings.TrimSpace(file.OpenAICompatible.AuthStrategy) + settings.ResponseProfile = strings.TrimSpace(file.OpenAICompatible.ResponseProfile) settings.APIStyle = strings.TrimSpace(file.OpenAICompatible.APIStyle) + settings.DiscoveryEndpointPath = strings.TrimSpace(file.OpenAICompatible.DiscoveryEndpointPath) + settings.DiscoveryResponseProfile = strings.TrimSpace(file.OpenAICompatible.DiscoveryResponseProfile) case provider.DriverGemini: settings.BaseURL = strings.TrimSpace(file.Gemini.BaseURL) + settings.ChatProtocol = strings.TrimSpace(file.Gemini.ChatProtocol) + settings.ChatEndpointPath = strings.TrimSpace(file.Gemini.ChatEndpointPath) + settings.DiscoveryProtocol = strings.TrimSpace(file.Gemini.DiscoveryProtocol) + settings.AuthStrategy = strings.TrimSpace(file.Gemini.AuthStrategy) + settings.ResponseProfile = strings.TrimSpace(file.Gemini.ResponseProfile) settings.DeploymentMode = strings.TrimSpace(file.Gemini.DeploymentMode) + settings.DiscoveryEndpointPath = strings.TrimSpace(file.Gemini.DiscoveryEndpointPath) + settings.DiscoveryResponseProfile = strings.TrimSpace(file.Gemini.DiscoveryResponseProfile) case provider.DriverAnthropic: settings.BaseURL = strings.TrimSpace(file.Anthropic.BaseURL) + settings.ChatProtocol = strings.TrimSpace(file.Anthropic.ChatProtocol) + settings.ChatEndpointPath = strings.TrimSpace(file.Anthropic.ChatEndpointPath) + settings.DiscoveryProtocol = strings.TrimSpace(file.Anthropic.DiscoveryProtocol) + settings.AuthStrategy = strings.TrimSpace(file.Anthropic.AuthStrategy) + settings.ResponseProfile = strings.TrimSpace(file.Anthropic.ResponseProfile) settings.APIVersion = strings.TrimSpace(file.Anthropic.APIVersion) + settings.DiscoveryEndpointPath = strings.TrimSpace(file.Anthropic.DiscoveryEndpointPath) + settings.DiscoveryResponseProfile = strings.TrimSpace(file.Anthropic.DiscoveryResponseProfile) default: settings.BaseURL = strings.TrimSpace(file.BaseURL) + settings.ChatProtocol = strings.TrimSpace(file.ChatProtocol) + settings.ChatEndpointPath = strings.TrimSpace(file.ChatEndpointPath) + settings.DiscoveryProtocol = strings.TrimSpace(file.DiscoveryProtocol) + settings.AuthStrategy = strings.TrimSpace(file.AuthStrategy) + settings.ResponseProfile = strings.TrimSpace(file.ResponseProfile) + settings.DiscoveryEndpointPath = strings.TrimSpace(file.DiscoveryEndpointPath) + settings.DiscoveryResponseProfile = strings.TrimSpace(file.DiscoveryResponseProfile) } + settings.ModelFieldAliases = encodeModelFieldAliases(file.ModelFieldAliases) return settings } +// validateCustomProviderDiscoveryFieldPlacement 校验 discovery 字段写在与 driver 对应的协议块中,避免静默忽略。 +func validateCustomProviderDiscoveryFieldPlacement(file customProviderFile) error { + topLevelChatProtocol := strings.TrimSpace(file.ChatProtocol) + topLevelChatEndpointPath := strings.TrimSpace(file.ChatEndpointPath) + topLevelDiscoveryProtocol := strings.TrimSpace(file.DiscoveryProtocol) + topLevelAuthStrategy := strings.TrimSpace(file.AuthStrategy) + topLevelResponseProfile := strings.TrimSpace(file.ResponseProfile) + topLevelDiscoveryEndpointPath := strings.TrimSpace(file.DiscoveryEndpointPath) + topLevelDiscoveryResponseProfile := strings.TrimSpace(file.DiscoveryResponseProfile) + if topLevelChatProtocol == "" && + topLevelChatEndpointPath == "" && + topLevelDiscoveryProtocol == "" && + topLevelAuthStrategy == "" && + topLevelResponseProfile == "" && + topLevelDiscoveryEndpointPath == "" && + topLevelDiscoveryResponseProfile == "" { + return nil + } + + switch normalizeProviderDriver(file.Driver) { + case provider.DriverOpenAICompat: + return errors.New("openaicompat discovery settings must be configured under openai_compatible") + case provider.DriverGemini: + return errors.New("gemini discovery settings must be configured under gemini") + case provider.DriverAnthropic: + return errors.New("anthropic discovery settings must be configured under anthropic") + default: + return nil + } +} + // SaveCustomProvider 保存自定义 provider 到文件系统。 func SaveCustomProvider( baseDir string, @@ -223,6 +351,8 @@ func SaveCustomProvider( apiStyle string, deploymentMode string, apiVersion string, + discoveryEndpointPath string, + discoveryResponseProfile string, ) error { if err := validateCustomProviderName(name); err != nil { return err @@ -243,21 +373,44 @@ func SaveCustomProvider( switch normalizedDriver { case provider.DriverOpenAICompat: cfg.OpenAICompatible = customOpenAICompatibleFile{ - BaseURL: baseURL, - APIStyle: strings.TrimSpace(apiStyle), + BaseURL: baseURL, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + APIStyle: strings.TrimSpace(apiStyle), + DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), + DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), } case provider.DriverGemini: cfg.Gemini = customGeminiProviderFile{ - BaseURL: baseURL, - DeploymentMode: strings.TrimSpace(deploymentMode), + BaseURL: baseURL, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + ChatEndpointPath: "/chat/completions", + DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + DeploymentMode: strings.TrimSpace(deploymentMode), + DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), + DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), } case provider.DriverAnthropic: cfg.Anthropic = customAnthropicProviderFile{ - BaseURL: baseURL, - APIVersion: strings.TrimSpace(apiVersion), + BaseURL: baseURL, + ChatProtocol: provider.ChatProtocolAnthropicMessages, + ChatEndpointPath: "/messages", + DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, + AuthStrategy: provider.AuthStrategyAnthropic, + ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + APIVersion: strings.TrimSpace(apiVersion), + DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), + DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), } default: cfg.BaseURL = baseURL + cfg.DiscoveryEndpointPath = strings.TrimSpace(discoveryEndpointPath) + cfg.DiscoveryResponseProfile = strings.TrimSpace(discoveryResponseProfile) } data, err := yaml.Marshal(cfg) @@ -273,6 +426,18 @@ func SaveCustomProvider( return nil } +// encodeModelFieldAliases 将 model field aliases 序列化为稳定字符串,供运行时跨层传递。 +func encodeModelFieldAliases(aliases map[string][]string) string { + if len(aliases) == 0 { + return "" + } + encoded, err := json.Marshal(aliases) + if err != nil { + return "" + } + return string(encoded) +} + // DeleteCustomProvider 删除自定义 provider。 func DeleteCustomProvider(baseDir string, name string) error { if err := validateCustomProviderName(name); err != nil { diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index f364b7ce..7a34be75 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -66,12 +66,14 @@ func TestProviderConfigIdentity(t *testing.T) { t.Parallel() cfg := ProviderConfig{ - Name: "test-openai", - Driver: "openaicompat", - BaseURL: "https://api.openai.com/v1", - Model: "gpt-4o", - APIKeyEnv: "TEST_KEY", - APIStyle: "chat_completions", + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o", + APIKeyEnv: "TEST_KEY", + APIStyle: "chat_completions", + DiscoveryEndpointPath: "models", + DiscoveryResponseProfile: "openai", } identity, err := cfg.Identity() @@ -87,6 +89,12 @@ func TestProviderConfigIdentity(t *testing.T) { if identity.APIStyle != "chat_completions" { t.Fatalf("expected api_style chat_completions, got %q", identity.APIStyle) } + if identity.DiscoveryEndpointPath != "/models" { + t.Fatalf("expected discovery endpoint /models, got %q", identity.DiscoveryEndpointPath) + } + if identity.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { + t.Fatalf("expected discovery response profile openai, got %q", identity.DiscoveryResponseProfile) + } } func TestProviderConfigResolveAPIKeyEmptyEnvName(t *testing.T) { @@ -116,6 +124,16 @@ func TestProviderIdentityFromConfigDefaultsAPIStyleForOpenAICompat(t *testing.T) if identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { t.Fatalf("expected default api_style %q, got %q", providerpkg.OpenAICompatibleAPIStyleChatCompletions, identity.APIStyle) } + if identity.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { + t.Fatalf("expected default discovery endpoint %q, got %q", providerpkg.DiscoveryEndpointPathModels, identity.DiscoveryEndpointPath) + } + if identity.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { + t.Fatalf( + "expected default discovery response profile %q, got %q", + providerpkg.DiscoveryResponseProfileOpenAI, + identity.DiscoveryResponseProfile, + ) + } } func TestQiniuProviderConfig(t *testing.T) { @@ -140,6 +158,12 @@ func TestQiniuProviderConfig(t *testing.T) { if provider.Source != ProviderSourceBuiltin { t.Fatalf("expected builtin source, got %q", provider.Source) } + if provider.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { + t.Fatalf("expected discovery endpoint %q, got %q", providerpkg.DiscoveryEndpointPathModels, provider.DiscoveryEndpointPath) + } + if provider.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { + t.Fatalf("expected discovery profile openai, got %q", provider.DiscoveryResponseProfile) + } } func TestNormalizeConfigKey(t *testing.T) { @@ -180,6 +204,29 @@ func TestProviderConfigResolveWrapsAPIKeyError(t *testing.T) { } } +func TestProviderConfigValidateRejectsInvalidDiscoverySettings(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: providerpkg.DriverOpenAICompat, + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4.1", + APIKeyEnv: "TEST_KEY", + DiscoveryEndpointPath: "https://api.openai.com/models", + Source: ProviderSourceBuiltin, + } + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "discovery endpoint path") { + t.Fatalf("expected invalid discovery endpoint path error, got %v", err) + } + + cfg.DiscoveryEndpointPath = "/models" + cfg.DiscoveryResponseProfile = "not-supported" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "discovery response profile") { + t.Fatalf("expected invalid discovery response profile error, got %v", err) + } +} + func TestCloneProviderConfigModelDescriptorsIndependence(t *testing.T) { t.Parallel() @@ -367,8 +414,8 @@ func TestGeminiProviderConfig(t *testing.T) { if provider.Name != GeminiName { t.Fatalf("expected name %q, got %q", GeminiName, provider.Name) } - if provider.Driver != "openaicompat" { - t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) + if provider.Driver != "gemini" { + t.Fatalf("expected driver %q, got %q", "gemini", provider.Driver) } if provider.BaseURL != GeminiDefaultBaseURL { t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL) @@ -451,14 +498,21 @@ func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { got := resolved.ToRuntimeConfig() want := providerpkg.RuntimeConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - DefaultModel: "server-default", - APIKey: "secret-key", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + DefaultModel: "server-default", + APIKey: "secret-key", + ChatProtocol: providerpkg.ChatProtocolOpenAIResponses, + ChatEndpointPath: "/responses", + DiscoveryProtocol: providerpkg.DiscoveryProtocolOpenAIModels, + AuthStrategy: providerpkg.AuthStrategyBearer, + ResponseProfile: providerpkg.DiscoveryResponseProfileOpenAI, + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + DiscoveryEndpointPath: providerpkg.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: providerpkg.DiscoveryResponseProfileOpenAI, } if got != want { diff --git a/internal/config/provider_windows_test.go b/internal/config/provider_windows_test.go new file mode 100644 index 00000000..5d4a8343 --- /dev/null +++ b/internal/config/provider_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package config + +import ( + "os" + "strings" + "testing" +) + +func TestProviderConfigResolveAPIKeyFallsBackToUserEnv(t *testing.T) { + const envName = "NEOCODE_TEST_USER_ENV_FALLBACK_KEY" + const envValue = "user-env-secret" + + originalProcessValue, hadOriginalProcessValue := os.LookupEnv(envName) + originalUserValue, hadOriginalUserValue, lookupErr := LookupUserEnvVar(envName) + if lookupErr != nil { + t.Fatalf("LookupUserEnvVar() error = %v", lookupErr) + } + + t.Cleanup(func() { + if hadOriginalProcessValue { + _ = os.Setenv(envName, originalProcessValue) + } else { + _ = os.Unsetenv(envName) + } + + if hadOriginalUserValue { + _ = PersistUserEnvVar(envName, originalUserValue) + } else { + _ = DeleteUserEnvVar(envName) + } + }) + + _ = os.Unsetenv(envName) + if err := PersistUserEnvVar(envName, envValue); err != nil { + if containsPermissionDenied(err) { + t.Skipf("skip windows user env persistence test without registry permission: %v", err) + } + t.Fatalf("PersistUserEnvVar() error = %v", err) + } + + cfg := ProviderConfig{ + Name: "windows-fallback-provider", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: envName, + } + got, err := cfg.ResolveAPIKey() + if err != nil { + t.Fatalf("ResolveAPIKey() error = %v", err) + } + if got != envValue { + t.Fatalf("ResolveAPIKey() = %q, want %q", got, envValue) + } + if processGot := os.Getenv(envName); processGot != envValue { + t.Fatalf("expected process env synchronized to %q, got %q", envValue, processGot) + } +} + +func containsPermissionDenied(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "access is denied") +} diff --git a/internal/config/state/model_test.go b/internal/config/state/model_test.go index 77bb88e8..b2a641f8 100644 --- a/internal/config/state/model_test.go +++ b/internal/config/state/model_test.go @@ -39,6 +39,12 @@ func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *test if input.Identity.APIStyle != "responses" { t.Fatalf("expected normalized api_style, got %+v", input.Identity) } + if input.Identity.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { + t.Fatalf("expected default discovery endpoint, got %+v", input.Identity) + } + if input.Identity.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { + t.Fatalf("expected default discovery response profile, got %+v", input.Identity) + } if len(input.DefaultModels) != 1 || input.DefaultModels[0].ID != "server-default" { t.Fatalf("expected builtin default model, got %+v", input.DefaultModels) } @@ -58,8 +64,8 @@ func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *test if runtimeConfig.DefaultModel != "server-default" || runtimeConfig.APIKey != "secret-key" { t.Fatalf("expected runtime config to resolve model and api key, got %+v", runtimeConfig) } - if runtimeConfig.APIStyle != " Responses " { - t.Fatalf("expected runtime config to preserve configured api_style, got %+v", runtimeConfig) + if runtimeConfig.APIStyle != providerpkg.OpenAICompatibleAPIStyleResponses { + t.Fatalf("expected runtime config api_style to be normalized, got %+v", runtimeConfig) } } @@ -85,13 +91,27 @@ func TestCatalogInputFromProviderDefaultsOpenAICompatibleIdentityAPIStyle(t *tes input.Identity, ) } + if input.Identity.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { + t.Fatalf( + "expected default discovery endpoint %q, got %+v", + providerpkg.DiscoveryEndpointPathModels, + input.Identity, + ) + } + if input.Identity.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { + t.Fatalf( + "expected default discovery response profile %q, got %+v", + providerpkg.DiscoveryResponseProfileOpenAI, + input.Identity, + ) + } runtimeConfig, err := input.ResolveDiscoveryConfig() if err != nil { t.Fatalf("ResolveDiscoveryConfig() error = %v", err) } - if runtimeConfig.APIStyle != "" { - t.Fatalf("expected runtime config to preserve original empty api_style, got %+v", runtimeConfig) + if runtimeConfig.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf("expected runtime config to inherit default api_style, got %+v", runtimeConfig) } } diff --git a/internal/config/state/service_provider_create.go b/internal/config/state/service_provider_create.go index aa6a07f4..def75e97 100644 --- a/internal/config/state/service_provider_create.go +++ b/internal/config/state/service_provider_create.go @@ -28,25 +28,29 @@ const providerCreateCrossProcessLockHeartbeatInterval = 2 * time.Second // CreateCustomProviderInput 定义新增自定义 Provider 所需的输入参数。 type CreateCustomProviderInput struct { - Name string - Driver string - BaseURL string - APIKeyEnv string - APIKey string - APIStyle string - DeploymentMode string - APIVersion string + Name string + Driver string + BaseURL string + APIKeyEnv string + APIKey string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string } type createCustomProviderNormalizedInput struct { - Name string - Driver string - BaseURL string - APIKeyEnv string - APIKey string - APIStyle string - DeploymentMode string - APIVersion string + Name string + Driver string + BaseURL string + APIKeyEnv string + APIKey string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string } type providerConfigSnapshot struct { @@ -94,6 +98,7 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr return Selection{}, fmt.Errorf("selection: snapshot provider config: %w", err) } + providerSaveAttempted := false providerSaved := false userEnvPersisted := false processEnvApplied := false @@ -106,7 +111,7 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr previousProcessEnvValue, hadPreviousUserEnv, previousUserEnvValue, - providerSaved, + providerSaveAttempted, userEnvPersisted, processEnvApplied, providerSnapshot, @@ -122,7 +127,7 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr return rolledErr } - providerSaved = true + providerSaveAttempted = true if err := saveCustomProviderForCreate( s.manager.BaseDir(), normalized.Name, @@ -132,9 +137,12 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr normalized.APIStyle, normalized.DeploymentMode, normalized.APIVersion, + normalized.DiscoveryEndpointPath, + normalized.DiscoveryResponseProfile, ); err != nil { return Selection{}, rollback(fmt.Errorf("selection: save provider config: %w", err)) } + providerSaved = true if err := persistUserEnvVarForCreate(normalized.APIKeyEnv, normalized.APIKey); err != nil { return Selection{}, rollback(fmt.Errorf("selection: persist user env: %w", err)) @@ -161,14 +169,16 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr // normalizeCreateCustomProviderInput 统一裁剪新增 Provider 输入并执行基础字段校验。 func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (createCustomProviderNormalizedInput, error) { 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), - APIStyle: strings.TrimSpace(input.APIStyle), - DeploymentMode: strings.TrimSpace(input.DeploymentMode), - APIVersion: strings.TrimSpace(input.APIVersion), + 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), + APIStyle: strings.TrimSpace(input.APIStyle), + DeploymentMode: strings.TrimSpace(input.DeploymentMode), + APIVersion: strings.TrimSpace(input.APIVersion), + DiscoveryEndpointPath: strings.TrimSpace(input.DiscoveryEndpointPath), + DiscoveryResponseProfile: strings.TrimSpace(input.DiscoveryResponseProfile), } if err := config.ValidateCustomProviderName(normalized.Name); err != nil { @@ -186,6 +196,27 @@ func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (create if config.IsProtectedEnvVarName(normalized.APIKeyEnv) { return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: env key %q is protected", normalized.APIKeyEnv) } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + normalized.Driver, + "", + "", + "", + normalized.DiscoveryEndpointPath, + "", + "", + normalized.APIStyle, + normalized.DiscoveryResponseProfile, + ) + if err != nil { + return createCustomProviderNormalizedInput{}, err + } + if normalized.Driver == provider.DriverOpenAICompat { + normalized.APIStyle = normalizedProtocols.LegacyAPIStyle + } else { + normalized.APIStyle = "" + } + normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile return normalized, nil } @@ -238,7 +269,7 @@ func rollbackCreateCustomProvider( previousProcessEnvValue string, hadPreviousUserEnv bool, previousUserEnvValue string, - providerSaved bool, + providerSaveAttempted bool, userEnvPersisted bool, processEnvApplied bool, providerSnapshot providerConfigSnapshot, @@ -266,7 +297,7 @@ func rollbackCreateCustomProvider( } } - if providerSaved { + if providerSaveAttempted { if err := restoreProviderConfigSnapshot(baseDir, providerName, providerSnapshot); err != nil { rollbackErrs = append(rollbackErrs, fmt.Errorf("restore provider config: %w", err)) } diff --git a/internal/config/state/service_provider_create_test.go b/internal/config/state/service_provider_create_test.go index 3a3beace..7528e773 100644 --- a/internal/config/state/service_provider_create_test.go +++ b/internal/config/state/service_provider_create_test.go @@ -307,6 +307,8 @@ func TestCreateCustomProviderRollbackOnSaveProviderFailure(t *testing.T) { apiStyle string, deploymentMode string, apiVersion string, + discoveryEndpointPath string, + discoveryResponseProfile string, ) error { providerDir := filepath.Join(baseDir, "providers", name) if err := os.MkdirAll(providerDir, 0o755); err != nil { @@ -463,6 +465,98 @@ func TestLockProviderCreateCrossProcessWaitsForFreshLockUntilContextDone(t *test } } +func TestCreateCustomProviderRejectsInvalidDiscoverySettings(t *testing.T) { + restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + defer restorePersist() + defer restoreDelete() + defer restoreLookup() + defer restoreSave() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{{ID: "m1", Name: "m1"}}, + }) + + _, err := service.CreateCustomProvider(context.Background(), CreateCustomProviderInput{ + Name: "invalid-discovery-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "INVALID_DISCOVERY_PROVIDER_API_KEY", + APIKey: "key", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: "https://llm.example.com/models", + }) + if err == nil || !strings.Contains(err.Error(), "discovery endpoint path") { + t.Fatalf("expected invalid discovery endpoint path error, got %v", err) + } + + _, err = service.CreateCustomProvider(context.Background(), CreateCustomProviderInput{ + Name: "invalid-discovery-profile-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "INVALID_DISCOVERY_PROFILE_PROVIDER_API_KEY", + APIKey: "key", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: "/models", + DiscoveryResponseProfile: "unsupported", + }) + if err == nil || !strings.Contains(err.Error(), "discovery response profile") { + t.Fatalf("expected invalid discovery response profile error, got %v", err) + } +} + +func TestLockProviderCreateCrossProcessCleansStaleLockBeforeAcquire(t *testing.T) { + baseDir := t.TempDir() + lockPath := filepath.Join(baseDir, providerCreateCrossProcessLockName) + if err := os.Mkdir(lockPath, 0o700); err != nil { + t.Fatalf("Mkdir() error = %v", err) + } + expiredAt := time.Now().Add(-providerCreateCrossProcessLockStaleThreshold - time.Second) + if err := os.Chtimes(lockPath, expiredAt, expiredAt); err != nil { + t.Fatalf("Chtimes() error = %v", err) + } + + release, err := lockProviderCreateCrossProcess(context.Background(), baseDir) + if err != nil { + t.Fatalf("lockProviderCreateCrossProcess() error = %v", err) + } + + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("expected lock dir recreated and held, stat err = %v", err) + } + release() + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("expected lock dir released, stat err = %v", err) + } +} + +func TestTryReclaimStaleProviderCreateLockKeepsActiveLeaseWhenDirIsOld(t *testing.T) { + baseDir := t.TempDir() + lockPath := filepath.Join(baseDir, providerCreateCrossProcessLockName) + if err := os.Mkdir(lockPath, 0o700); err != nil { + t.Fatalf("Mkdir() error = %v", err) + } + + staleDirTime := time.Now().Add(-providerCreateCrossProcessLockStaleThreshold - time.Second) + if err := os.Chtimes(lockPath, staleDirTime, staleDirTime); err != nil { + t.Fatalf("Chtimes() dir error = %v", err) + } + if err := touchProviderCreateLockLease(lockPath, time.Now()); err != nil { + t.Fatalf("touchProviderCreateLockLease() error = %v", err) + } + + reclaimed, err := tryReclaimStaleProviderCreateLock(lockPath, time.Now()) + if err != nil { + t.Fatalf("tryReclaimStaleProviderCreateLock() error = %v", err) + } + if reclaimed { + t.Fatal("expected active lease lock not to be reclaimed") + } + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("expected lock dir to remain, stat err = %v", err) + } +} + func captureEnvForCreateProvider(t *testing.T, key string) func() { t.Helper() diff --git a/internal/config/state/service_test.go b/internal/config/state/service_test.go index bb678ad3..0ef876cf 100644 --- a/internal/config/state/service_test.go +++ b/internal/config/state/service_test.go @@ -77,7 +77,7 @@ func TestSelectionServiceListProviderOptionsUsesCatalogModels(t *testing.T) { } } -func TestSelectionServiceBuiltinUnsupportedAPIStyleFailsAcrossSnapshotPaths(t *testing.T) { +func TestSelectionServiceBuiltinUnsupportedAPIStyleNoLongerFailsAcrossSnapshotPaths(t *testing.T) { t.Parallel() providerCfg := openAIProviderForTest() @@ -95,14 +95,14 @@ func TestSelectionServiceBuiltinUnsupportedAPIStyleFailsAcrossSnapshotPaths(t *t } service := NewService(manager, newDriverSupporterStub(), catalog.NewService("", registry, nil)) - if _, err := service.ListProviderOptions(context.Background()); !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected ListProviderOptions() to surface discovery config error, got %v", err) + if _, err := service.ListProviderOptions(context.Background()); err != nil { + t.Fatalf("expected ListProviderOptions() to remain available, got %v", err) } - if _, err := service.SelectProvider(context.Background(), OpenAIName); !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected SelectProvider() to surface discovery config error, got %v", err) + if _, err := service.SelectProvider(context.Background(), OpenAIName); err != nil { + t.Fatalf("expected SelectProvider() to remain available, got %v", err) } - if _, err := service.EnsureSelection(context.Background()); !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected EnsureSelection() to surface discovery config error, got %v", err) + if _, err := service.EnsureSelection(context.Background()); err != nil { + t.Fatalf("expected EnsureSelection() to remain available, got %v", err) } reloaded, err := manager.Reload(context.Background()) diff --git a/internal/provider/anthropic/driver.go b/internal/provider/anthropic/driver.go new file mode 100644 index 00000000..7e55ab96 --- /dev/null +++ b/internal/provider/anthropic/driver.go @@ -0,0 +1,113 @@ +package anthropic + +import ( + "context" + "net/http" + "time" + + "neo-code/internal/provider" + httpdiscovery "neo-code/internal/provider/discovery/http" + "neo-code/internal/provider/openaicompat" + providertypes "neo-code/internal/provider/types" +) + +// DriverName 是 Anthropic 协议驱动的唯一标识。 +const DriverName = provider.DriverAnthropic + +// Driver 返回 Anthropic 协议驱动定义。 +func Driver() provider.DriverDefinition { + compatDriver := openaicompat.Driver() + + return provider.DriverDefinition{ + Name: DriverName, + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { + normalized, err := normalizeRuntimeConfig(cfg) + if err != nil { + return nil, err + } + if normalized.ChatProtocol == provider.ChatProtocolAnthropicMessages { + return nil, provider.NewDiscoveryConfigError("anthropic driver: chat_protocol anthropic_messages is not supported yet") + } + normalized.Driver = provider.DriverOpenAICompat + return compatDriver.Build(ctx, normalized) + }, + Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + normalized, err := normalizeRuntimeConfig(cfg) + if err != nil { + return nil, err + } + + rawModels, err := httpdiscovery.DiscoverRawModels(ctx, &http.Client{Timeout: 90 * time.Second}, httpdiscovery.RequestConfig{ + BaseURL: normalized.BaseURL, + EndpointPath: normalized.DiscoveryEndpointPath, + DiscoveryProtocol: normalized.DiscoveryProtocol, + ResponseProfile: normalized.ResponseProfile, + AuthStrategy: normalized.AuthStrategy, + APIKey: normalized.APIKey, + APIVersion: normalized.APIVersion, + }) + if err != nil { + return nil, err + } + + descriptors := make([]providertypes.ModelDescriptor, 0, len(rawModels)) + for _, raw := range rawModels { + descriptor, ok := providertypes.DescriptorFromRawModel(raw) + if !ok { + continue + } + descriptors = append(descriptors, descriptor) + } + return providertypes.MergeModelDescriptors(descriptors), nil + }, + ValidateCatalogIdentity: validateCatalogIdentity, + } +} + +// normalizeRuntimeConfig 统一收敛 Anthropic runtime 配置并执行组合校验。 +func normalizeRuntimeConfig(cfg provider.RuntimeConfig) (provider.RuntimeConfig, error) { + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + cfg.Driver, + cfg.ChatProtocol, + cfg.ChatEndpointPath, + cfg.DiscoveryProtocol, + cfg.DiscoveryEndpointPath, + cfg.AuthStrategy, + cfg.ResponseProfile, + cfg.APIStyle, + cfg.DiscoveryResponseProfile, + ) + if err != nil { + return provider.RuntimeConfig{}, provider.NewDiscoveryConfigError(err.Error()) + } + + normalized := cfg + normalized.ChatProtocol = normalizedProtocols.ChatProtocol + normalized.ChatEndpointPath = normalizedProtocols.ChatEndpointPath + normalized.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol + normalized.AuthStrategy = normalizedProtocols.AuthStrategy + normalized.ResponseProfile = normalizedProtocols.ResponseProfile + normalized.APIStyle = normalizedProtocols.LegacyAPIStyle + normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + return normalized, nil +} + +// validateCatalogIdentity 在 catalog 路径上执行 Anthropic 协议静态校验。 +func validateCatalogIdentity(identity provider.ProviderIdentity) error { + _, err := provider.NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return provider.NewDiscoveryConfigError(err.Error()) + } + return nil +} diff --git a/internal/provider/anthropic/driver_test.go b/internal/provider/anthropic/driver_test.go new file mode 100644 index 00000000..f86f524d --- /dev/null +++ b/internal/provider/anthropic/driver_test.go @@ -0,0 +1,24 @@ +package anthropic + +import ( + "context" + "testing" + + "neo-code/internal/provider" +) + +func TestDriverBuildRejectsUnsupportedAnthropicMessages(t *testing.T) { + t.Parallel() + + driver := Driver() + _, err := driver.Build(context.Background(), provider.RuntimeConfig{ + Driver: DriverName, + BaseURL: "https://api.anthropic.com/v1", + APIKey: "test-key", + ChatProtocol: provider.ChatProtocolAnthropicMessages, + AuthStrategy: provider.AuthStrategyAnthropic, + }) + if err == nil { + t.Fatal("expected unsupported anthropic messages error") + } +} diff --git a/internal/provider/auth.go b/internal/provider/auth.go new file mode 100644 index 00000000..0b78033b --- /dev/null +++ b/internal/provider/auth.go @@ -0,0 +1,36 @@ +package provider + +import ( + "net/http" + "strings" +) + +const defaultAnthropicAPIVersion = "2023-06-01" + +// ApplyAuthHeaders 根据认证策略写入请求头,统一收敛 provider 层鉴权行为并避免上层重复分支。 +func ApplyAuthHeaders(header http.Header, authStrategy string, apiKey string, apiVersion string) { + if header == nil { + return + } + + trimmedAPIKey := strings.TrimSpace(apiKey) + if trimmedAPIKey == "" { + return + } + + switch NormalizeProviderAuthStrategy(authStrategy) { + case "", AuthStrategyBearer: + header.Set("Authorization", "Bearer "+trimmedAPIKey) + case AuthStrategyXAPIKey: + header.Set("X-API-Key", trimmedAPIKey) + case AuthStrategyAnthropic: + header.Set("x-api-key", trimmedAPIKey) + version := strings.TrimSpace(apiVersion) + if version == "" { + version = defaultAnthropicAPIVersion + } + header.Set("anthropic-version", version) + default: + header.Set("Authorization", "Bearer "+trimmedAPIKey) + } +} diff --git a/internal/provider/auth_test.go b/internal/provider/auth_test.go new file mode 100644 index 00000000..354420d9 --- /dev/null +++ b/internal/provider/auth_test.go @@ -0,0 +1,40 @@ +package provider + +import ( + "net/http" + "testing" +) + +func TestApplyAuthHeaders(t *testing.T) { + t.Parallel() + + t.Run("bearer", func(t *testing.T) { + t.Parallel() + header := http.Header{} + ApplyAuthHeaders(header, AuthStrategyBearer, " test-key ", "") + if got := header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("expected bearer header, got %q", got) + } + }) + + t.Run("x_api_key", func(t *testing.T) { + t.Parallel() + header := http.Header{} + ApplyAuthHeaders(header, AuthStrategyXAPIKey, "test-key", "") + if got := header.Get("X-API-Key"); got != "test-key" { + t.Fatalf("expected x-api-key header, got %q", got) + } + }) + + t.Run("anthropic", func(t *testing.T) { + t.Parallel() + header := http.Header{} + ApplyAuthHeaders(header, AuthStrategyAnthropic, "test-key", "") + if got := header.Get("x-api-key"); got != "test-key" { + t.Fatalf("expected anthropic x-api-key header, got %q", got) + } + if got := header.Get("anthropic-version"); got != defaultAnthropicAPIVersion { + t.Fatalf("expected default anthropic version %q, got %q", defaultAnthropicAPIVersion, got) + } + }) +} diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index a53d9830..89ab753a 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -4,6 +4,8 @@ import ( "errors" "neo-code/internal/provider" + "neo-code/internal/provider/anthropic" + "neo-code/internal/provider/gemini" "neo-code/internal/provider/openaicompat" ) @@ -19,5 +21,11 @@ func register(registry *provider.Registry) error { if registry == nil { return errors.New("builtin provider registry is nil") } - return registry.Register(openaicompat.Driver()) + if err := registry.Register(openaicompat.Driver()); err != nil { + return err + } + if err := registry.Register(gemini.Driver()); err != nil { + return err + } + return registry.Register(anthropic.Driver()) } diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index 1b164b0b..ad741f9d 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -4,6 +4,8 @@ import ( "testing" "neo-code/internal/provider" + "neo-code/internal/provider/anthropic" + "neo-code/internal/provider/gemini" "neo-code/internal/provider/openaicompat" ) @@ -20,6 +22,12 @@ func TestNewRegistry(t *testing.T) { if !registry.Supports(openaicompat.DriverName) { t.Fatalf("expected registry to include %q driver", openaicompat.DriverName) } + if !registry.Supports(gemini.DriverName) { + t.Fatalf("expected registry to include %q driver", gemini.DriverName) + } + if !registry.Supports(anthropic.DriverName) { + t.Fatalf("expected registry to include %q driver", anthropic.DriverName) + } } func TestRegister(t *testing.T) { @@ -46,5 +54,11 @@ func TestRegister(t *testing.T) { if !registry.Supports(openaicompat.DriverName) { t.Fatalf("expected registry to include %q driver", openaicompat.DriverName) } + if !registry.Supports(gemini.DriverName) { + t.Fatalf("expected registry to include %q driver", gemini.DriverName) + } + if !registry.Supports(anthropic.DriverName) { + t.Fatalf("expected registry to include %q driver", anthropic.DriverName) + } }) } diff --git a/internal/provider/catalog/additional_test.go b/internal/provider/catalog/additional_test.go index e2f50ade..75de6a49 100644 --- a/internal/provider/catalog/additional_test.go +++ b/internal/provider/catalog/additional_test.go @@ -96,7 +96,11 @@ func TestJSONStoreAdditionalFilesystemErrors(t *testing.T) { if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } - if err := os.MkdirAll(store.catalogPath(identity), 0o755); err != nil { + normalizedIdentity, err := provider.NormalizeProviderIdentity(identity) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } + if err := os.MkdirAll(store.catalogPath(normalizedIdentity), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } _, err = store.Load(context.Background(), identity) @@ -136,7 +140,7 @@ func TestCatalogSnapshotOnMissingCatalog(t *testing.T) { } } -func TestListProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing.T) { +func TestListProviderModelsAllowsUnsupportedOpenAICompatibleAPIStyleWhenCatalogIsWarm(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") registry := provider.NewRegistry() @@ -144,19 +148,32 @@ func TestListProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing t.Fatalf("register openaicompat driver: %v", err) } - service := NewService("", registry, newMemoryStore()) + store := newMemoryStore() + service := NewService("", registry, store) cfg := customGatewayProvider() cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) + if err := store.Save(context.Background(), ModelCatalog{ + SchemaVersion: schemaVersion, + Identity: input.Identity, + FetchedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + Models: []providertypes.ModelDescriptor{{ID: "cached-model", Name: "Cached Model"}}, + }); err != nil { + t.Fatalf("Save() cached catalog error = %v", err) + } models, err := service.ListProviderModels(context.Background(), input) - if err == nil || models != nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("expected unsupported api_style error, got models=%+v err=%v", models, err) + if err != nil { + t.Fatalf("expected warm catalog to bypass chat-only api_style validation, got %v", err) + } + if !containsModelDescriptorID(models, "cached-model") { + t.Fatalf("expected cached model in result, got %+v", models) } } -func TestListBuiltinProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing.T) { +func TestListBuiltinProviderModelsAllowsUnsupportedOpenAICompatibleAPIStyleWhenCatalogIsWarm(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") registry := provider.NewRegistry() @@ -164,19 +181,32 @@ func TestListBuiltinProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t * t.Fatalf("register openaicompat driver: %v", err) } - service := NewService("", registry, newMemoryStore()) + store := newMemoryStore() + service := NewService("", registry, store) cfg := config.OpenAIProvider() cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) + if err := store.Save(context.Background(), ModelCatalog{ + SchemaVersion: schemaVersion, + Identity: input.Identity, + FetchedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + Models: []providertypes.ModelDescriptor{{ID: "cached-model", Name: "Cached Model"}}, + }); err != nil { + t.Fatalf("Save() cached catalog error = %v", err) + } models, err := service.ListProviderModels(context.Background(), input) - if err == nil || models != nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("expected builtin unsupported api_style error, got models=%+v err=%v", models, err) + if err != nil { + t.Fatalf("expected warm catalog to bypass chat-only api_style validation, got %v", err) + } + if !containsModelDescriptorID(models, "cached-model") { + t.Fatalf("expected cached model in result, got %+v", models) } } -func TestListBuiltinProviderModelsSnapshotRejectsUnsupportedOpenAICompatibleAPIStyleBeforeFallback(t *testing.T) { +func TestListBuiltinProviderModelsSnapshotAllowsUnsupportedOpenAICompatibleAPIStyle(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") registry := provider.NewRegistry() @@ -189,17 +219,26 @@ func TestListBuiltinProviderModelsSnapshotRejectsUnsupportedOpenAICompatibleAPIS cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) + if err := service.store.Save(context.Background(), ModelCatalog{ + SchemaVersion: schemaVersion, + Identity: input.Identity, + FetchedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + Models: []providertypes.ModelDescriptor{{ID: "cached-model", Name: "Cached Model"}}, + }); err != nil { + t.Fatalf("Save() cached catalog error = %v", err) + } models, err := service.ListProviderModelsSnapshot(context.Background(), input) - if err == nil || models != nil || !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected snapshot path to reject invalid api_style, got models=%+v err=%v", models, err) + if err != nil { + t.Fatalf("expected snapshot path to allow chat-only api_style drift, got %v", err) } - if !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("unexpected snapshot error: %v", err) + if !containsModelDescriptorID(models, "cached-model") { + t.Fatalf("expected cached model in snapshot result, got %+v", models) } } -func TestListBuiltinProviderModelsSnapshotAndCachedRejectUnsupportedAPIStyleWithCachedCatalog(t *testing.T) { +func TestListBuiltinProviderModelsSnapshotAndCachedAllowUnsupportedAPIStyleWithCachedCatalog(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") registry := provider.NewRegistry() @@ -234,11 +273,11 @@ func TestListBuiltinProviderModelsSnapshotAndCachedRejectUnsupportedAPIStyleWith for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { models, err := tt.call(context.Background(), input) - if err == nil || models != nil || !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected cached %s path to reject invalid api_style, got models=%+v err=%v", tt.name, models, err) + if err != nil { + t.Fatalf("expected cached %s path to allow chat-only api_style drift, got %v", tt.name, err) } - if !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("unexpected cached %s error: %v", tt.name, err) + if !containsModelDescriptorID(models, "cached-model") { + t.Fatalf("expected cached %s result to include cached model, got %+v", tt.name, models) } }) } diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index 8a5e23fa..42622b3e 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "fmt" "strings" "sync" "time" @@ -106,6 +107,10 @@ func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogI models := snapshot.models catalogOK := snapshot.ok + if catalogOK && len(models) == 0 { + // 空 catalog 等价于未命中,避免历史空缓存长期阻断后续 discovery。 + catalogOK = false + } performedSyncRefresh := false if !catalogOK && options.allowSyncRefresh { discovered, err := s.discoverAndPersist(ctx, input) @@ -166,18 +171,23 @@ func (s *Service) discoverAndPersist(ctx context.Context, input provider.Catalog } discovered = providertypes.MergeModelDescriptors(discovered) + if len(discovered) == 0 { + return discovered, nil + } if s.store == nil { return discovered, nil } now := s.now() - _ = s.store.Save(ctx, ModelCatalog{ + if err := s.store.Save(ctx, ModelCatalog{ SchemaVersion: schemaVersion, Identity: input.Identity, FetchedAt: now, ExpiresAt: now.Add(s.catalogTTL), Models: discovered, - }) + }); err != nil { + return nil, fmt.Errorf("provider catalog: persist discovered models: %w", err) + } return discovered, nil } diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 3488dd3a..85f566e6 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -388,6 +388,40 @@ func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) } } +func TestListProviderModelsRefreshesWhenCatalogSnapshotIsEmpty(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + store := newMemoryStore() + input := customGatewayProviderSource() + if err := store.Save(context.Background(), ModelCatalog{ + SchemaVersion: schemaVersion, + Identity: input.Identity, + FetchedAt: time.Now().Add(-time.Minute), + ExpiresAt: time.Now().Add(time.Hour), + Models: nil, + }); err != nil { + t.Fatalf("seed empty catalog: %v", err) + } + + var discoverCalls int32 + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + return []providertypes.ModelDescriptor{{ID: "qwen-plus", Name: "Qwen Plus"}}, nil + }) + + service := NewService("", registry, store) + models, err := service.ListProviderModels(context.Background(), input) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "qwen-plus" { + t.Fatalf("expected sync refresh result from discovery, got %+v", models) + } + if atomic.LoadInt32(&discoverCalls) == 0 { + t.Fatal("expected discovery to be called when snapshot models are empty") + } +} + func TestDiscoverAndPersistFailurePaths(t *testing.T) { t.Run("unsupported driver", func(t *testing.T) { service := NewService("", provider.NewRegistry(), newMemoryStore()) @@ -443,6 +477,30 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { t.Fatalf("expected discovered model to be returned, got %+v", discovered) } }) + + t.Run("empty discovery result is not persisted", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + store := newMemoryStore() + service := NewService("", newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return nil, nil + }), store) + + discovered, err := service.discoverAndPersist(context.Background(), customGatewayProviderSource()) + if err != nil { + t.Fatalf("discoverAndPersist() error = %v", err) + } + if len(discovered) != 0 { + t.Fatalf("expected empty discovery result, got %+v", discovered) + } + + identity, identityErr := customGatewayProvider().Identity() + if identityErr != nil { + t.Fatalf("Identity() error = %v", identityErr) + } + if _, loadErr := store.Load(context.Background(), identity); !errors.Is(loadErr, ErrCatalogNotFound) { + t.Fatalf("expected empty discovery not to be cached, got %v", loadErr) + } + }) } func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { diff --git a/internal/provider/catalog/store.go b/internal/provider/catalog/store.go index 1a7c00a8..1855dfc9 100644 --- a/internal/provider/catalog/store.go +++ b/internal/provider/catalog/store.go @@ -17,7 +17,7 @@ import ( providertypes "neo-code/internal/provider/types" ) -const schemaVersion = 1 +const schemaVersion = 2 var ErrCatalogNotFound = errors.New("provider: model catalog not found") diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index e4a49d7c..8e2a4cbc 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -21,10 +21,14 @@ func TestJSONStoreRoundTrip(t *testing.T) { if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } + normalizedIdentity, err := provider.NormalizeProviderIdentity(identity) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } expected := ModelCatalog{ SchemaVersion: schemaVersion, - Identity: identity, + Identity: normalizedIdentity, FetchedAt: time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC), Models: []providertypes.ModelDescriptor{ @@ -148,6 +152,10 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } + normalizedIdentity, err := provider.NormalizeProviderIdentity(identity) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } first := ModelCatalog{ SchemaVersion: schemaVersion, @@ -191,7 +199,7 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { t.Fatalf("expected no temp files to remain, got %+v", matches) } - data, err := os.ReadFile(store.catalogPath(identity)) + data, err := os.ReadFile(store.catalogPath(normalizedIdentity)) if err != nil { t.Fatalf("ReadFile() error = %v", err) } @@ -254,11 +262,15 @@ func TestJSONStoreLoadRejectsInvalidJSON(t *testing.T) { if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } + normalizedIdentity, err := provider.NormalizeProviderIdentity(identity) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } - if err := os.MkdirAll(filepath.Dir(store.catalogPath(identity)), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(store.catalogPath(normalizedIdentity)), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } - if err := os.WriteFile(store.catalogPath(identity), []byte("{not-json"), 0o644); err != nil { + if err := os.WriteFile(store.catalogPath(normalizedIdentity), []byte("{not-json"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } diff --git a/internal/provider/constants.go b/internal/provider/constants.go index 404c105d..8962276e 100644 --- a/internal/provider/constants.go +++ b/internal/provider/constants.go @@ -6,6 +6,26 @@ const ( DriverGemini = "gemini" DriverAnthropic = "anthropic" + ChatProtocolOpenAIChatCompletions = "openai_chat_completions" + ChatProtocolOpenAIResponses = "openai_responses" + ChatProtocolGeminiNative = "gemini_native" + ChatProtocolAnthropicMessages = "anthropic_messages" + + DiscoveryProtocolOpenAIModels = "openai_models" + DiscoveryProtocolGeminiModels = "gemini_models" + DiscoveryProtocolAnthropicModels = "anthropic_models" + DiscoveryProtocolCustomHTTPJSON = "custom_http_json" + + AuthStrategyBearer = "bearer" + AuthStrategyXAPIKey = "x_api_key" + AuthStrategyAnthropic = "anthropic" + OpenAICompatibleAPIStyleChatCompletions = "chat_completions" OpenAICompatibleAPIStyleResponses = "responses" + + DiscoveryEndpointPathModels = "/models" + + DiscoveryResponseProfileOpenAI = "openai" + DiscoveryResponseProfileGemini = "gemini" + DiscoveryResponseProfileGeneric = "generic" ) diff --git a/internal/provider/contracts.go b/internal/provider/contracts.go index a3d20a55..6592ffca 100644 --- a/internal/provider/contracts.go +++ b/internal/provider/contracts.go @@ -8,14 +8,22 @@ import ( // RuntimeConfig 表示 provider 构建与模型发现使用的最小运行时输入。 type RuntimeConfig struct { - Name string - Driver string - BaseURL string - DefaultModel string - APIKey string - APIStyle string - DeploymentMode string - APIVersion string + Name string + Driver string + BaseURL string + DefaultModel string + APIKey string + ChatProtocol string + ChatEndpointPath string + DiscoveryProtocol string + AuthStrategy string + ResponseProfile string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string + ModelFieldAliases string } // Provider 定义模型生成能力,通过 channel 推送流式事件给上层消费。 diff --git a/internal/provider/discovery/endpoint.go b/internal/provider/discovery/endpoint.go new file mode 100644 index 00000000..0de697ad --- /dev/null +++ b/internal/provider/discovery/endpoint.go @@ -0,0 +1,16 @@ +package discovery + +import "strings" + +// ResolveEndpoint 将归一化后的相对端点路径拼接到 baseURL,避免上层重复处理路径前后缀。 +func ResolveEndpoint(baseURL string, endpointPath string) string { + trimmedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmedEndpointPath := strings.TrimSpace(endpointPath) + if trimmedEndpointPath == "" { + return trimmedBaseURL + } + if !strings.HasPrefix(trimmedEndpointPath, "/") { + trimmedEndpointPath = "/" + trimmedEndpointPath + } + return trimmedBaseURL + trimmedEndpointPath +} diff --git a/internal/provider/discovery/endpoint_test.go b/internal/provider/discovery/endpoint_test.go new file mode 100644 index 00000000..62ec5376 --- /dev/null +++ b/internal/provider/discovery/endpoint_test.go @@ -0,0 +1,43 @@ +package discovery + +import "testing" + +func TestResolveEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + endpointPath string + want string + }{ + { + name: "joins base and relative path", + baseURL: "https://api.example.com/v1/", + endpointPath: "models", + want: "https://api.example.com/v1/models", + }, + { + name: "keeps leading slash path", + baseURL: "https://api.example.com/v1", + endpointPath: "/models", + want: "https://api.example.com/v1/models", + }, + { + name: "empty endpoint returns base", + baseURL: "https://api.example.com/v1/", + endpointPath: " ", + want: "https://api.example.com/v1", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ResolveEndpoint(tt.baseURL, tt.endpointPath); got != tt.want { + t.Fatalf("ResolveEndpoint(%q, %q) = %q, want %q", tt.baseURL, tt.endpointPath, got, tt.want) + } + }) + } +} diff --git a/internal/provider/discovery/http/client.go b/internal/provider/discovery/http/client.go new file mode 100644 index 00000000..b0d5ef1e --- /dev/null +++ b/internal/provider/discovery/http/client.go @@ -0,0 +1,152 @@ +package httpdiscovery + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + + "neo-code/internal/provider" + "neo-code/internal/provider/discovery" +) + +// RequestConfig 描述通用 HTTP discovery 请求的必要输入。 +type RequestConfig struct { + BaseURL string + EndpointPath string + DiscoveryProtocol string + ResponseProfile string + AuthStrategy string + APIKey string + APIVersion string +} + +// DiscoverRawModels 通过通用 HTTP discovery 协议拉取模型列表并输出标准化的原始模型对象切片。 +func DiscoverRawModels(ctx context.Context, client *http.Client, cfg RequestConfig) ([]map[string]any, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if client == nil { + return nil, errors.New("provider discovery: http client is nil") + } + + discoveryProtocol := provider.NormalizeProviderDiscoveryProtocol(cfg.DiscoveryProtocol) + if discoveryProtocol == "" { + discoveryProtocol = provider.DiscoveryProtocolCustomHTTPJSON + } + + endpointPath, err := provider.NormalizeProviderDiscoveryEndpointPath(cfg.EndpointPath) + if err != nil { + return nil, provider.NewDiscoveryConfigError(err.Error()) + } + if endpointPath == "" { + endpointPath = provider.DiscoveryEndpointPathModels + } + + responseProfile, err := resolveResponseProfile(discoveryProtocol, cfg.ResponseProfile) + if err != nil { + return nil, provider.NewDiscoveryConfigError(err.Error()) + } + + endpoint := discovery.ResolveEndpoint(cfg.BaseURL, endpointPath) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("provider discovery: build models request: %w", err) + } + req.Header.Set("Accept", "application/json") + provider.ApplyAuthHeaders(req.Header, cfg.AuthStrategy, cfg.APIKey, cfg.APIVersion) + + resp, err := client.Do(req) + if err != nil { + return nil, wrapTransportError(err) + } + defer func(body io.ReadCloser) { + _ = body.Close() + }(resp.Body) + + if resp.StatusCode >= http.StatusBadRequest { + return nil, parseHTTPError(resp) + } + + var payload any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("provider discovery: decode models response: %w", err) + } + + rawModels, err := discovery.ExtractRawModels(payload, responseProfile) + if err != nil { + return nil, fmt.Errorf("provider discovery: decode models response: %w", err) + } + return rawModels, nil +} + +// resolveResponseProfile 根据 discovery protocol 与显式配置决定响应提取策略。 +func resolveResponseProfile(discoveryProtocol string, profile string) (string, error) { + normalizedProfile, err := provider.NormalizeProviderDiscoveryResponseProfile(profile) + if err != nil { + return "", err + } + if normalizedProfile != "" { + return normalizedProfile, nil + } + + switch discoveryProtocol { + case provider.DiscoveryProtocolGeminiModels: + return provider.DiscoveryResponseProfileGemini, nil + case provider.DiscoveryProtocolOpenAIModels: + return provider.DiscoveryResponseProfileOpenAI, nil + default: + return provider.DiscoveryResponseProfileGeneric, nil + } +} + +// parseHTTPError 将 discovery HTTP 错误映射为可分类 ProviderError。 +func parseHTTPError(resp *http.Response) error { + _, _ = io.Copy(io.Discard, resp.Body) + message := httpErrorMessage(resp.StatusCode) + return provider.NewProviderErrorFromStatus(resp.StatusCode, message) +} + +// httpErrorMessage 为 discovery 请求生成可操作错误文案,便于用户区分端点、鉴权与上游异常。 +func httpErrorMessage(statusCode int) string { + switch statusCode { + case http.StatusNotFound: + return "provider discovery: models endpoint not found (404); check discovery endpoint path" + case http.StatusUnauthorized: + return "provider discovery: unauthorized (401); check api key, auth strategy, and environment variable" + case http.StatusForbidden: + return "provider discovery: forbidden (403); check provider permissions" + } + if statusCode >= http.StatusInternalServerError { + return fmt.Sprintf("provider discovery: models endpoint request failed (status=%d); upstream server error", statusCode) + } + return fmt.Sprintf("provider discovery: models endpoint request failed (status=%d)", statusCode) +} + +// wrapTransportError 统一归类 discovery 传输层失败,区分 timeout 与 network 错误。 +func wrapTransportError(err error) error { + message := strings.TrimSpace(err.Error()) + if message == "" { + message = "unknown transport error" + } + if isTimeoutTransportError(err) { + return provider.NewTimeoutProviderError("provider discovery: send models request timeout: " + message) + } + return provider.NewNetworkProviderError("provider discovery: send models request: " + message) +} + +// isTimeoutTransportError 判断网络错误是否由超时触发。 +func isTimeoutTransportError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/internal/provider/discovery/http/client_test.go b/internal/provider/discovery/http/client_test.go new file mode 100644 index 00000000..a8361588 --- /dev/null +++ b/internal/provider/discovery/http/client_test.go @@ -0,0 +1,58 @@ +package httpdiscovery + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "neo-code/internal/provider" +) + +func TestDiscoverRawModels(t *testing.T) { + t.Parallel() + + var authHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + if r.URL.Path != "/models" { + t.Fatalf("expected /models path, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "gpt-4.1"}, + }, + }) + })) + defer server.Close() + + models, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + AuthStrategy: provider.AuthStrategyBearer, + APIKey: "test-key", + }) + if err != nil { + t.Fatalf("DiscoverRawModels() error = %v", err) + } + if authHeader != "Bearer test-key" { + t.Fatalf("expected bearer auth header, got %q", authHeader) + } + if len(models) != 1 || models[0]["id"] != "gpt-4.1" { + t.Fatalf("unexpected models result: %+v", models) + } +} + +func TestDiscoverRawModelsRejectsInvalidEndpointPath(t *testing.T) { + t.Parallel() + + _, err := DiscoverRawModels(context.Background(), &http.Client{}, RequestConfig{ + BaseURL: "https://api.example.com/v1", + EndpointPath: "https://api.example.com/models", + }) + if err == nil || !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error, got %v", err) + } +} diff --git a/internal/provider/discovery/models.go b/internal/provider/discovery/models.go new file mode 100644 index 00000000..7777428d --- /dev/null +++ b/internal/provider/discovery/models.go @@ -0,0 +1,293 @@ +package discovery + +import ( + "fmt" + "strings" + + "neo-code/internal/provider" +) + +// ExtractRawModels 兼容不同供应商的模型列表响应结构,统一提取为模型对象切片。 +func ExtractRawModels(payload any, profile string) ([]map[string]any, error) { + if payload == nil { + return nil, nil + } + + if models, isArrayPayload, err := decodeRootArrayPayload(payload); isArrayPayload || err != nil { + return models, err + } + + objectPayload, ok := payload.(map[string]any) + if !ok { + return nil, fmt.Errorf("unsupported models payload type %T", payload) + } + + normalizedProfile := normalizeResponseProfile(profile) + keys := modelListKeys(normalizedProfile) + models, found, err := extractRawModelsFromObject(objectPayload, keys) + if err != nil { + return nil, err + } + if found { + return models, nil + } + + // 严格 profile 未命中时,回退到 generic 键集合,降低第三方 OpenAI-compatible 变体接入成本。 + if normalizedProfile != provider.DiscoveryResponseProfileGeneric { + fallbackKeys := modelListKeys(provider.DiscoveryResponseProfileGeneric) + models, found, err = extractRawModelsFromObject(objectPayload, fallbackKeys) + if err != nil { + return nil, err + } + if found { + return models, nil + } + } + + if looksLikeSingleModelObject(objectPayload) { + return []map[string]any{objectPayload}, nil + } + + // 对未知容器键做受控深度遍历兜底,仅在“看起来像模型集合”时返回结果,避免误识别无关数组。 + if models, found, err := extractRawModelsByTraversal(objectPayload, "", 0); err != nil { + return nil, err + } else if found { + return models, nil + } + + return nil, fmt.Errorf("models payload does not contain supported list keys %q", strings.Join(keys, ", ")) +} + +// extractRawModelsFromObject 在对象中按候选键提取模型列表,支持 data.models 一类嵌套容器。 +func extractRawModelsFromObject(objectPayload map[string]any, keys []string) ([]map[string]any, bool, error) { + for _, key := range keys { + value, exists := lookupObjectValue(objectPayload, key) + if !exists { + continue + } + + if nestedObject, ok := value.(map[string]any); ok { + if looksLikeSingleModelObject(nestedObject) { + return []map[string]any{nestedObject}, true, nil + } + nestedModels, nestedFound, nestedErr := extractRawModelsFromObject(nestedObject, keys) + if nestedErr != nil { + return nil, true, nestedErr + } + if nestedFound { + return nestedModels, true, nil + } + continue + } + + models, err := decodeModelEntries(value) + if err != nil { + return nil, true, fmt.Errorf("unsupported %q value type: %w", key, err) + } + return models, true, nil + } + return nil, false, nil +} + +// lookupObjectValue 优先按原键读取,再按大小写与分隔符无关的规则兜底读取。 +func lookupObjectValue(objectPayload map[string]any, key string) (any, bool) { + if value, ok := objectPayload[key]; ok { + return value, true + } + target := normalizeDiscoveryKey(key) + for currentKey, value := range objectPayload { + if normalizeDiscoveryKey(currentKey) == target { + return value, true + } + } + return nil, false +} + +// normalizeDiscoveryKey 统一字段名比较规则:忽略大小写、下划线、连字符和空白。 +func normalizeDiscoveryKey(key string) string { + trimmed := strings.TrimSpace(strings.ToLower(key)) + replacer := strings.NewReplacer("_", "", "-", "", " ", "") + return replacer.Replace(trimmed) +} + +// decodeRootArrayPayload 专门处理顶层数组 payload,避免把 map 根对象误判成单模型。 +func decodeRootArrayPayload(raw any) ([]map[string]any, bool, error) { + switch raw.(type) { + case nil: + return nil, true, nil + case []any: + models, err := decodeModelEntries(raw) + return models, true, err + default: + return nil, false, nil + } +} + +// decodeModelEntries 处理列表、单对象和空值三种模型承载形式。 +func decodeModelEntries(raw any) ([]map[string]any, error) { + switch value := raw.(type) { + case nil: + return nil, nil + case []any: + models := make([]map[string]any, 0, len(value)) + for _, item := range value { + switch typed := item.(type) { + case map[string]any: + models = append(models, typed) + case string: + modelID := strings.TrimSpace(typed) + if modelID == "" { + continue + } + models = append(models, map[string]any{ + "id": modelID, + "name": modelID, + }) + } + } + return models, nil + case map[string]any: + return []map[string]any{value}, nil + default: + return nil, fmt.Errorf("unsupported models data type %T", raw) + } +} + +// modelListKeys 根据 profile 返回优先尝试的模型列表字段顺序。 +func modelListKeys(profile string) []string { + switch normalizeResponseProfile(profile) { + case provider.DiscoveryResponseProfileGemini: + return []string{"models", "data"} + case provider.DiscoveryResponseProfileGeneric: + return []string{"data", "models", "items", "results", "model_list", "modelList", "list"} + default: + return []string{"data", "models"} + } +} + +// extractRawModelsByTraversal 在未知容器键场景下做深度遍历兜底,防止第三方字段命名偏差导致 discovery 全量失效。 +func extractRawModelsByTraversal(raw any, currentKey string, depth int) ([]map[string]any, bool, error) { + if depth > 8 { + return nil, false, nil + } + + switch typed := raw.(type) { + case map[string]any: + if looksLikeSingleModelObject(typed) { + return []map[string]any{typed}, true, nil + } + for key, value := range typed { + models, found, err := extractRawModelsByTraversal(value, key, depth+1) + if err != nil { + return nil, true, err + } + if found { + return models, true, nil + } + } + return nil, false, nil + case []any: + models, err := decodeModelEntries(typed) + if err != nil { + return nil, true, err + } + if len(models) > 0 && looksLikeModelCollection(models) { + // 对纯字符串数组做额外约束,避免把 errors/messages 误识别为模型列表。 + if !arrayContainsOnlyStrings(typed) || currentKey == "" || isLikelyModelListKey(currentKey) { + return models, true, nil + } + } + for _, item := range typed { + models, found, nestedErr := extractRawModelsByTraversal(item, currentKey, depth+1) + if nestedErr != nil { + return nil, true, nestedErr + } + if found { + return models, true, nil + } + } + return nil, false, nil + default: + return nil, false, nil + } +} + +// looksLikeModelCollection 判断提取出的列表是否具有模型集合特征,降低兜底路径误命中概率。 +func looksLikeModelCollection(models []map[string]any) bool { + if len(models) == 0 { + return false + } + + validCount := 0 + for _, model := range models { + if looksLikeSingleModelObject(model) { + validCount++ + } + } + return validCount > 0 && validCount*2 >= len(models) +} + +// arrayContainsOnlyStrings 判断数组是否全部由字符串元素构成。 +func arrayContainsOnlyStrings(items []any) bool { + if len(items) == 0 { + return false + } + for _, item := range items { + if _, ok := item.(string); !ok { + return false + } + } + return true +} + +// isLikelyModelListKey 判断当前键名是否疑似模型列表容器键。 +func isLikelyModelListKey(key string) bool { + normalized := normalizeDiscoveryKey(key) + if normalized == "" { + return false + } + if strings.Contains(normalized, "model") { + return true + } + switch normalized { + case "data", "items", "results", "list": + return true + default: + return false + } +} + +// normalizeResponseProfile 统一 profile 写法并为未知值回退到 openai 解析序。 +func normalizeResponseProfile(profile string) string { + switch strings.ToLower(strings.TrimSpace(profile)) { + case provider.DiscoveryResponseProfileGemini: + return provider.DiscoveryResponseProfileGemini + case provider.DiscoveryResponseProfileGeneric: + return provider.DiscoveryResponseProfileGeneric + default: + return provider.DiscoveryResponseProfileOpenAI + } +} + +// looksLikeSingleModelObject 判断 map 是否像一个模型对象,用于根对象兼容兜底。 +func looksLikeSingleModelObject(raw map[string]any) bool { + if raw == nil { + return false + } + if id, ok := lookupObjectValue(raw, "id"); ok && strings.TrimSpace(fmt.Sprint(id)) != "" { + return true + } + if model, ok := lookupObjectValue(raw, "model"); ok && strings.TrimSpace(fmt.Sprint(model)) != "" { + return true + } + if modelID, ok := lookupObjectValue(raw, "model_id"); ok && strings.TrimSpace(fmt.Sprint(modelID)) != "" { + return true + } + if modelID, ok := lookupObjectValue(raw, "modelId"); ok && strings.TrimSpace(fmt.Sprint(modelID)) != "" { + return true + } + if name, ok := lookupObjectValue(raw, "name"); ok && strings.TrimSpace(fmt.Sprint(name)) != "" { + return true + } + return false +} diff --git a/internal/provider/discovery/models_test.go b/internal/provider/discovery/models_test.go new file mode 100644 index 00000000..14859427 --- /dev/null +++ b/internal/provider/discovery/models_test.go @@ -0,0 +1,174 @@ +package discovery + +import ( + "testing" + + "neo-code/internal/provider" +) + +func TestExtractRawModels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload any + profile string + wantLen int + wantErr bool + }{ + { + name: "openai data array", + payload: map[string]any{ + "data": []any{ + map[string]any{"id": "gpt-4.1"}, + }, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 1, + }, + { + name: "openai nested data models array", + payload: map[string]any{ + "data": map[string]any{ + "models": []any{ + map[string]any{"id": "qwen-plus"}, + }, + }, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 1, + }, + { + name: "generic profile supports case-insensitive list keys", + payload: map[string]any{ + "Data": []any{ + map[string]any{"id": "qwen-max"}, + }, + }, + profile: provider.DiscoveryResponseProfileGeneric, + wantLen: 1, + }, + { + name: "gemini models array", + payload: map[string]any{ + "models": []any{ + map[string]any{"name": "models/gemini-2.5-flash"}, + }, + }, + profile: provider.DiscoveryResponseProfileGemini, + wantLen: 1, + }, + { + name: "generic results array", + payload: map[string]any{ + "results": []any{ + map[string]any{"id": "custom-model"}, + }, + }, + profile: provider.DiscoveryResponseProfileGeneric, + wantLen: 1, + }, + { + name: "root array payload", + payload: []any{ + map[string]any{"id": "m1"}, + map[string]any{"id": "m2"}, + }, + profile: provider.DiscoveryResponseProfileGeneric, + wantLen: 2, + }, + { + name: "single model object payload", + payload: map[string]any{ + "id": "m1", + "name": "Model 1", + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 1, + }, + { + name: "unsupported payload type", + payload: map[string]any{ + "data": "unexpected", + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantErr: true, + }, + { + name: "missing supported list key", + payload: map[string]any{ + "object": "list", + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantErr: true, + }, + { + name: "openai profile falls back to generic keys for items payload", + payload: map[string]any{ + "items": []any{ + map[string]any{"id": "m1"}, + }, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 1, + }, + { + name: "generic profile accepts items payload", + payload: map[string]any{ + "items": []any{ + map[string]any{"id": "m1"}, + }, + }, + profile: provider.DiscoveryResponseProfileGeneric, + wantLen: 1, + }, + { + name: "string list under models key", + payload: map[string]any{ + "models": []any{"model-a", "model-b"}, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 2, + }, + { + name: "deep nested unknown container keys", + payload: map[string]any{ + "result": map[string]any{ + "payload": map[string]any{ + "records": []any{ + map[string]any{"model_id": "qwen-max"}, + }, + }, + }, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantLen: 1, + }, + { + name: "deep fallback skips non-model string arrays", + payload: map[string]any{ + "errors": []any{"bad request", "permission denied"}, + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ExtractRawModels(tt.payload, tt.profile) + if (err != nil) != tt.wantErr { + t.Fatalf("ExtractRawModels() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got) != tt.wantLen { + t.Fatalf("ExtractRawModels() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} diff --git a/internal/provider/gemini/driver.go b/internal/provider/gemini/driver.go new file mode 100644 index 00000000..40f601d1 --- /dev/null +++ b/internal/provider/gemini/driver.go @@ -0,0 +1,85 @@ +package gemini + +import ( + "context" + + "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat" + providertypes "neo-code/internal/provider/types" +) + +// DriverName 是 Gemini 协议驱动的唯一标识。 +const DriverName = provider.DriverGemini + +// Driver 返回 Gemini 协议驱动定义。 +func Driver() provider.DriverDefinition { + compatDriver := openaicompat.Driver() + + return provider.DriverDefinition{ + Name: DriverName, + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { + normalized, err := normalizeRuntimeConfig(cfg) + if err != nil { + return nil, err + } + return compatDriver.Build(ctx, normalized) + }, + Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + normalized, err := normalizeRuntimeConfig(cfg) + if err != nil { + return nil, err + } + return compatDriver.Discover(ctx, normalized) + }, + ValidateCatalogIdentity: validateCatalogIdentity, + } +} + +// normalizeRuntimeConfig 统一收敛 Gemini runtime 配置并兼容映射到 openaicompat 执行路径。 +func normalizeRuntimeConfig(cfg provider.RuntimeConfig) (provider.RuntimeConfig, error) { + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + cfg.Driver, + cfg.ChatProtocol, + cfg.ChatEndpointPath, + cfg.DiscoveryProtocol, + cfg.DiscoveryEndpointPath, + cfg.AuthStrategy, + cfg.ResponseProfile, + cfg.APIStyle, + cfg.DiscoveryResponseProfile, + ) + if err != nil { + return provider.RuntimeConfig{}, provider.NewDiscoveryConfigError(err.Error()) + } + + normalized := cfg + normalized.Driver = provider.DriverOpenAICompat + normalized.ChatProtocol = normalizedProtocols.ChatProtocol + normalized.ChatEndpointPath = normalizedProtocols.ChatEndpointPath + normalized.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol + normalized.AuthStrategy = normalizedProtocols.AuthStrategy + normalized.ResponseProfile = normalizedProtocols.ResponseProfile + normalized.APIStyle = normalizedProtocols.LegacyAPIStyle + normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + return normalized, nil +} + +// validateCatalogIdentity 在 catalog 路径上执行 Gemini 协议静态校验,避免无效快照误导选择流程。 +func validateCatalogIdentity(identity provider.ProviderIdentity) error { + _, err := provider.NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return provider.NewDiscoveryConfigError(err.Error()) + } + return nil +} diff --git a/internal/provider/gemini/driver_test.go b/internal/provider/gemini/driver_test.go new file mode 100644 index 00000000..44553e05 --- /dev/null +++ b/internal/provider/gemini/driver_test.go @@ -0,0 +1,41 @@ +package gemini + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "neo-code/internal/provider" +) + +func TestDriverDiscover(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"name": "gemini-2.5-flash"}, + }, + }) + })) + defer server.Close() + + driver := Driver() + models, err := driver.Discover(context.Background(), provider.RuntimeConfig{ + Driver: DriverName, + BaseURL: server.URL, + APIKey: "test-key", + DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, + ResponseProfile: provider.DiscoveryResponseProfileGemini, + DiscoveryEndpointPath: "/models", + }) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(models) != 1 { + t.Fatalf("expected one model, got %+v", models) + } +} diff --git a/internal/provider/identity.go b/internal/provider/identity.go index 4ebc14bc..45bc0b8b 100644 --- a/internal/provider/identity.go +++ b/internal/provider/identity.go @@ -9,16 +9,38 @@ import ( // ProviderIdentity 标识 discovery、缓存与去重所使用的具体 provider 连接身份。 type ProviderIdentity struct { - Driver string `json:"driver"` - BaseURL string `json:"base_url"` - APIStyle string `json:"api_style,omitempty"` - DeploymentMode string `json:"deployment_mode,omitempty"` - APIVersion string `json:"api_version,omitempty"` + Driver string `json:"driver"` + BaseURL string `json:"base_url"` + ChatProtocol string `json:"chat_protocol,omitempty"` + ChatEndpointPath string `json:"chat_endpoint_path,omitempty"` + DiscoveryProtocol string `json:"discovery_protocol,omitempty"` + AuthStrategy string `json:"auth_strategy,omitempty"` + ResponseProfile string `json:"response_profile,omitempty"` + APIStyle string `json:"api_style,omitempty"` + DeploymentMode string `json:"deployment_mode,omitempty"` + APIVersion string `json:"api_version,omitempty"` + DiscoveryEndpointPath string `json:"discovery_endpoint_path,omitempty"` + DiscoveryResponseProfile string `json:"discovery_response_profile,omitempty"` } // Key 返回稳定的 provider 身份键,供缓存文件命名与去重逻辑复用。 func (i ProviderIdentity) Key() string { parts := []string{i.Driver, i.BaseURL} + if strings.TrimSpace(i.ChatProtocol) != "" { + parts = append(parts, i.ChatProtocol) + } + if strings.TrimSpace(i.ChatEndpointPath) != "" { + parts = append(parts, i.ChatEndpointPath) + } + if strings.TrimSpace(i.DiscoveryProtocol) != "" { + parts = append(parts, i.DiscoveryProtocol) + } + if strings.TrimSpace(i.AuthStrategy) != "" { + parts = append(parts, i.AuthStrategy) + } + if strings.TrimSpace(i.ResponseProfile) != "" { + parts = append(parts, i.ResponseProfile) + } if strings.TrimSpace(i.APIStyle) != "" { parts = append(parts, i.APIStyle) } @@ -28,6 +50,12 @@ func (i ProviderIdentity) Key() string { if strings.TrimSpace(i.APIVersion) != "" { parts = append(parts, i.APIVersion) } + if strings.TrimSpace(i.DiscoveryEndpointPath) != "" { + parts = append(parts, i.DiscoveryEndpointPath) + } + if strings.TrimSpace(i.DiscoveryResponseProfile) != "" { + parts = append(parts, i.DiscoveryResponseProfile) + } return strings.Join(parts, "|") } @@ -61,6 +89,93 @@ func NormalizeProviderAPIVersion(version string) string { return NormalizeKey(version) } +// NormalizeProviderDiscoveryEndpointPath 规范化模型发现端点路径,拒绝包含主机信息或查询参数的配置。 +func NormalizeProviderDiscoveryEndpointPath(endpointPath string) (string, error) { + value := strings.TrimSpace(endpointPath) + if value == "" { + return "", nil + } + if strings.Contains(value, `\`) { + return "", fmt.Errorf("provider discovery endpoint path %q is invalid", endpointPath) + } + + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("provider discovery endpoint path %q is invalid: %w", endpointPath, err) + } + if parsed.Scheme != "" || parsed.Host != "" || strings.HasPrefix(value, "//") { + return "", fmt.Errorf("provider discovery endpoint path %q must be a relative path", endpointPath) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("provider discovery endpoint path %q must not contain query or fragment", endpointPath) + } + + segments := strings.Split(value, "/") + for _, segment := range segments { + if strings.TrimSpace(segment) == ".." { + return "", fmt.Errorf("provider discovery endpoint path %q must not contain '..'", endpointPath) + } + } + + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + cleaned := path.Clean(value) + if !strings.HasPrefix(cleaned, "/") { + return "", fmt.Errorf("provider discovery endpoint path %q is invalid", endpointPath) + } + if cleaned != "/" { + cleaned = strings.TrimRight(cleaned, "/") + } + return cleaned, nil +} + +// NormalizeProviderDiscoveryResponseProfile 规范化模型发现响应解析策略,仅允许受支持的 profile。 +func NormalizeProviderDiscoveryResponseProfile(profile string) (string, error) { + normalized := NormalizeKey(profile) + if normalized == "" { + return "", nil + } + + switch normalized { + case DiscoveryResponseProfileOpenAI, DiscoveryResponseProfileGemini, DiscoveryResponseProfileGeneric: + return normalized, nil + default: + return "", fmt.Errorf("provider discovery response profile %q is unsupported", profile) + } +} + +// NormalizeProviderDiscoverySettings 根据 driver 归一化 discovery 设置,并在受支持场景补齐默认值。 +func NormalizeProviderDiscoverySettings( + driver string, + endpointPath string, + responseProfile string, +) (string, string, error) { + normalizedDriver := NormalizeProviderDriver(driver) + candidateEndpointPath := strings.TrimSpace(endpointPath) + candidateResponseProfile := strings.TrimSpace(responseProfile) + + // 仅为 openaicompat 提供默认发现配置,其他 driver 维持显式输入。 + if normalizedDriver == DriverOpenAICompat { + if candidateEndpointPath == "" { + candidateEndpointPath = DiscoveryEndpointPathModels + } + if candidateResponseProfile == "" { + candidateResponseProfile = DiscoveryResponseProfileOpenAI + } + } + + normalizedEndpointPath, err := NormalizeProviderDiscoveryEndpointPath(candidateEndpointPath) + if err != nil { + return "", "", err + } + normalizedResponseProfile, err := NormalizeProviderDiscoveryResponseProfile(candidateResponseProfile) + if err != nil { + return "", "", err + } + return normalizedEndpointPath, normalizedResponseProfile, nil +} + // NormalizeProviderBaseURL 将 provider 接入地址规整为可比较的稳定形式。 func NormalizeProviderBaseURL(raw string) (string, error) { value := strings.TrimSpace(raw) @@ -122,32 +237,114 @@ func NormalizeProviderIdentity(identity ProviderIdentity) (ProviderIdentity, err if err != nil { return ProviderIdentity{}, err } + normalizedProtocols, err := NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return ProviderIdentity{}, err + } return ProviderIdentity{ - Driver: normalizedDriver, - BaseURL: baseURL, - APIStyle: NormalizeProviderAPIStyle(identity.APIStyle), + Driver: normalizedDriver, + BaseURL: baseURL, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + APIStyle: normalizedProtocols.LegacyAPIStyle, + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, }, nil case DriverGemini: baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) if err != nil { return ProviderIdentity{}, err } + normalizedProtocols, err := NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return ProviderIdentity{}, err + } return ProviderIdentity{ - Driver: normalizedDriver, - BaseURL: baseURL, - DeploymentMode: NormalizeProviderDeploymentMode(identity.DeploymentMode), + Driver: normalizedDriver, + BaseURL: baseURL, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + APIStyle: normalizedProtocols.LegacyAPIStyle, + DeploymentMode: NormalizeProviderDeploymentMode(identity.DeploymentMode), + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, }, nil case DriverAnthropic: baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) if err != nil { return ProviderIdentity{}, err } + normalizedProtocols, err := NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return ProviderIdentity{}, err + } return ProviderIdentity{ - Driver: normalizedDriver, - BaseURL: baseURL, - APIVersion: NormalizeProviderAPIVersion(identity.APIVersion), + Driver: normalizedDriver, + BaseURL: baseURL, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + APIStyle: normalizedProtocols.LegacyAPIStyle, + APIVersion: NormalizeProviderAPIVersion(identity.APIVersion), + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, }, nil default: - return NewProviderIdentity(identity.Driver, identity.BaseURL) + baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) + if err != nil { + return ProviderIdentity{}, err + } + discoveryEndpointPath, discoveryResponseProfile, err := NormalizeProviderDiscoverySettings( + identity.Driver, + identity.DiscoveryEndpointPath, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return ProviderIdentity{}, err + } + return ProviderIdentity{ + Driver: normalizedDriver, + BaseURL: baseURL, + DiscoveryEndpointPath: discoveryEndpointPath, + DiscoveryResponseProfile: discoveryResponseProfile, + }, nil } } diff --git a/internal/provider/identity_test.go b/internal/provider/identity_test.go index 2fad0219..4af0f219 100644 --- a/internal/provider/identity_test.go +++ b/internal/provider/identity_test.go @@ -6,14 +6,16 @@ func TestProviderIdentityKeyIncludesDriverSpecificFields(t *testing.T) { t.Parallel() identity := ProviderIdentity{ - Driver: "openaicompat", - BaseURL: "https://api.example.com/v1", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + DiscoveryEndpointPath: "/v2/models", + DiscoveryResponseProfile: "generic", } - if got, want := identity.Key(), "openaicompat|https://api.example.com/v1|responses|ignored|ignored"; got != want { + if got, want := identity.Key(), "openaicompat|https://api.example.com/v1|responses|ignored|ignored|/v2/models|generic"; got != want { t.Fatalf("expected identity key %q, got %q", want, got) } } @@ -22,9 +24,11 @@ func TestNormalizeProviderIdentityUsesDriverSpecificNormalization(t *testing.T) t.Parallel() identity, err := NormalizeProviderIdentity(ProviderIdentity{ - Driver: " OpenAICompat ", - BaseURL: "https://API.EXAMPLE.COM/v1/", - APIStyle: " Responses ", + Driver: " OpenAICompat ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: " Responses ", + DiscoveryEndpointPath: " models ", + DiscoveryResponseProfile: " Generic ", }) if err != nil { t.Fatalf("NormalizeProviderIdentity() error = %v", err) @@ -39,15 +43,27 @@ func TestNormalizeProviderIdentityUsesDriverSpecificNormalization(t *testing.T) if identity.APIStyle != "responses" { t.Fatalf("expected normalized api_style %q, got %q", "responses", identity.APIStyle) } + if identity.DiscoveryEndpointPath != "/models" { + t.Fatalf("expected normalized discovery endpoint path %q, got %q", "/models", identity.DiscoveryEndpointPath) + } + if identity.DiscoveryResponseProfile != DiscoveryResponseProfileGeneric { + t.Fatalf( + "expected normalized discovery response profile %q, got %q", + DiscoveryResponseProfileGeneric, + identity.DiscoveryResponseProfile, + ) + } } func TestNormalizeProviderIdentityPreservesDriverSpecificFields(t *testing.T) { t.Parallel() identity, err := NormalizeProviderIdentity(ProviderIdentity{ - Driver: " Gemini ", - BaseURL: "https://API.EXAMPLE.COM/v1/", - DeploymentMode: " Vertex ", + Driver: " Gemini ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + DeploymentMode: " Vertex ", + DiscoveryEndpointPath: "/models", + DiscoveryResponseProfile: "gemini", }) if err != nil { t.Fatalf("NormalizeProviderIdentity() error = %v", err) @@ -62,6 +78,9 @@ func TestNormalizeProviderIdentityPreservesDriverSpecificFields(t *testing.T) { if identity.DeploymentMode != "vertex" { t.Fatalf("expected normalized deployment_mode %q, got %q", "vertex", identity.DeploymentMode) } + if identity.DiscoveryEndpointPath != "/models" || identity.DiscoveryResponseProfile != DiscoveryResponseProfileGemini { + t.Fatalf("expected normalized discovery settings, got %+v", identity) + } } func TestProviderIdentityStringMatchesKey(t *testing.T) { @@ -115,11 +134,13 @@ func TestNormalizeProviderIdentityAnthropicAndUnknownDriver(t *testing.T) { } fallbackIdentity, err := NormalizeProviderIdentity(ProviderIdentity{ - Driver: " custom ", - BaseURL: "https://API.EXAMPLE.COM/v1/", - APIStyle: "responses", - DeploymentMode: "vertex", - APIVersion: "2023-06-01", + Driver: " custom ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: "responses", + DeploymentMode: "vertex", + APIVersion: "2023-06-01", + DiscoveryEndpointPath: "gateway/models", + DiscoveryResponseProfile: "generic", }) if err != nil { t.Fatalf("NormalizeProviderIdentity() fallback error = %v", err) @@ -130,4 +151,70 @@ func TestNormalizeProviderIdentityAnthropicAndUnknownDriver(t *testing.T) { if fallbackIdentity.APIStyle != "" || fallbackIdentity.DeploymentMode != "" || fallbackIdentity.APIVersion != "" { t.Fatalf("expected fallback identity to drop protocol-specific fields, got %+v", fallbackIdentity) } + if fallbackIdentity.DiscoveryEndpointPath != "/gateway/models" || fallbackIdentity.DiscoveryResponseProfile != "generic" { + t.Fatalf("expected fallback identity to preserve normalized discovery settings, got %+v", fallbackIdentity) + } +} + +func TestNormalizeProviderDiscoveryEndpointPath(t *testing.T) { + t.Parallel() + + got, err := NormalizeProviderDiscoveryEndpointPath(" models ") + if err != nil { + t.Fatalf("NormalizeProviderDiscoveryEndpointPath() error = %v", err) + } + if got != "/models" { + t.Fatalf("expected /models, got %q", got) + } + + if _, err := NormalizeProviderDiscoveryEndpointPath("https://api.example.com/models"); err == nil { + t.Fatalf("expected absolute URL to be rejected") + } + if _, err := NormalizeProviderDiscoveryEndpointPath("/models?x=1"); err == nil { + t.Fatalf("expected query string to be rejected") + } +} + +func TestNormalizeProviderDiscoveryResponseProfile(t *testing.T) { + t.Parallel() + + got, err := NormalizeProviderDiscoveryResponseProfile(" Gemini ") + if err != nil { + t.Fatalf("NormalizeProviderDiscoveryResponseProfile() error = %v", err) + } + if got != DiscoveryResponseProfileGemini { + t.Fatalf("expected gemini, got %q", got) + } + + if _, err := NormalizeProviderDiscoveryResponseProfile("unsupported-profile"); err == nil { + t.Fatalf("expected unsupported profile to fail") + } +} + +func TestNormalizeProviderDiscoverySettings(t *testing.T) { + t.Parallel() + + endpointPath, responseProfile, err := NormalizeProviderDiscoverySettings( + DriverOpenAICompat, + "", + "", + ) + if err != nil { + t.Fatalf("NormalizeProviderDiscoverySettings() openaicompat error = %v", err) + } + if endpointPath != DiscoveryEndpointPathModels || responseProfile != DiscoveryResponseProfileOpenAI { + t.Fatalf("expected openaicompat defaults, got endpoint=%q profile=%q", endpointPath, responseProfile) + } + + endpointPath, responseProfile, err = NormalizeProviderDiscoverySettings( + "custom-driver", + "", + "", + ) + if err != nil { + t.Fatalf("NormalizeProviderDiscoverySettings() custom driver error = %v", err) + } + if endpointPath != "" || responseProfile != "" { + t.Fatalf("expected custom driver to keep empty discovery settings, got endpoint=%q profile=%q", endpointPath, responseProfile) + } } diff --git a/internal/provider/openaicompat/chatcompletions/provider.go b/internal/provider/openaicompat/chatcompletions/provider.go index d92cce6d..6dd83519 100644 --- a/internal/provider/openaicompat/chatcompletions/provider.go +++ b/internal/provider/openaicompat/chatcompletions/provider.go @@ -48,12 +48,12 @@ func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateReque return fmt.Errorf("%smarshal request: %w", shared.ErrorPrefix, err) } - endpoint := shared.Endpoint(p.cfg.BaseURL, "/chat/completions") + endpoint := shared.Endpoint(p.cfg.BaseURL, resolveChatEndpointPath(p.cfg)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("%sbuild request: %w", shared.ErrorPrefix, err) } - shared.SetBearerAuthorization(httpReq.Header, p.cfg.APIKey) + shared.ApplyAuthHeaders(httpReq.Header, p.cfg) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") @@ -73,3 +73,12 @@ func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateReque return p.ConsumeStream(ctx, resp.Body, events) } + +// resolveChatEndpointPath 统一解析聊天端点路径,优先使用 runtime 配置并在缺省时回退默认值。 +func resolveChatEndpointPath(cfg provider.RuntimeConfig) string { + normalizedPath, err := provider.NormalizeProviderChatEndpointPath(cfg.ChatEndpointPath) + if err != nil || normalizedPath == "" { + return "/chat/completions" + } + return normalizedPath +} diff --git a/internal/provider/openaicompat/chatcompletions/response.go b/internal/provider/openaicompat/chatcompletions/response.go index a394ebcb..8a862b15 100644 --- a/internal/provider/openaicompat/chatcompletions/response.go +++ b/internal/provider/openaicompat/chatcompletions/response.go @@ -10,6 +10,7 @@ import ( "neo-code/internal/provider" "neo-code/internal/provider/openaicompat/shared" + "neo-code/internal/provider/streaming/sse" providertypes "neo-code/internal/provider/types" ) @@ -19,7 +20,7 @@ func (p *Provider) ConsumeStream( body io.Reader, events chan<- providertypes.StreamEvent, ) error { - reader := NewBoundedSSEReader(body) + reader := sse.NewBoundedReader(body) var ( finishReason string diff --git a/internal/provider/openaicompat/chatcompletions/sse_reader.go b/internal/provider/openaicompat/chatcompletions/sse_reader.go deleted file mode 100644 index 07317d7b..00000000 --- a/internal/provider/openaicompat/chatcompletions/sse_reader.go +++ /dev/null @@ -1,64 +0,0 @@ -package chatcompletions - -import ( - "bufio" - "errors" - "io" - - "neo-code/internal/provider" -) - -// 单行与总量上限,防止恶意或异常数据导致内存无限增长。 -const ( - MaxSSELineSize = 256 * 1024 // L1: 单行 256KB - MaxStreamTotalSize = 10 << 20 // L3: 总量 10MB -) - -// BoundedSSEReader 对 bufio.Reader 包装两级有界检查, -// 适用于 Chat Completions SSE 的顺序消费场景。 -type BoundedSSEReader struct { - reader *bufio.Reader - totalRead int64 -} - -// NewBoundedSSEReader 创建有界 SSE 行读取器。 -func NewBoundedSSEReader(r io.Reader) *BoundedSSEReader { - return &BoundedSSEReader{ - reader: bufio.NewReaderSize(r, MaxSSELineSize+1), - } -} - -// ReadLine 读取一行(以 \n 分隔),同时执行 L1 与 L3 检查。 -func (r *BoundedSSEReader) ReadLine() (string, error) { - line, err := r.reader.ReadSlice('\n') - - if errors.Is(err, bufio.ErrBufferFull) { - return "", provider.ErrLineTooLong - } - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - - rawLen := len(line) - if rawLen > 0 && line[rawLen-1] == '\n' { - rawLen-- - } - if rawLen > MaxSSELineSize { - return "", provider.ErrLineTooLong - } - - r.totalRead += int64(len(line)) - if r.totalRead > MaxStreamTotalSize { - return "", provider.ErrStreamTooLarge - } - - return TrimLineEnding(string(line)), err -} - -// TrimLineEnding 移除行尾的 \r\n 或 \n。 -func TrimLineEnding(line string) string { - for len(line) > 0 && (line[len(line)-1] == '\n' || line[len(line)-1] == '\r') { - line = line[:len(line)-1] - } - return line -} diff --git a/internal/provider/openaicompat/discovery.go b/internal/provider/openaicompat/discovery.go index 43361a88..3ba9a826 100644 --- a/internal/provider/openaicompat/discovery.go +++ b/internal/provider/openaicompat/discovery.go @@ -2,76 +2,35 @@ package openaicompat import ( "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "neo-code/internal/provider/openaicompat/shared" + "neo-code/internal/provider" + httpdiscovery "neo-code/internal/provider/discovery/http" ) -// openAIModelsResponse 表示 /models 端点的响应结构。 -type openAIModelsResponse struct { - Data any `json:"data"` -} - -// decodeModelsData 兼容解析 data 字段,支持数组与单对象两种常见返回格式。 -func decodeModelsData(data any) ([]map[string]any, error) { - switch value := data.(type) { - case nil: - return nil, nil - case []any: - models := make([]map[string]any, 0, len(value)) - for _, item := range value { - model, ok := item.(map[string]any) - if !ok { - continue - } - models = append(models, model) - } - return models, nil - case map[string]any: - return []map[string]any{value}, nil - default: - return nil, fmt.Errorf("unsupported models data type %T", data) - } -} - -// fetchModels 从 OpenAI 兼容的 /models 端点获取原始模型列表。 +// fetchModels 调用通用 discovery HTTP 引擎,并输出标准化原始模型对象列表。 func (p *Provider) fetchModels(ctx context.Context) ([]map[string]any, error) { - endpoint := shared.Endpoint(p.cfg.BaseURL, "/models") - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + p.cfg.Driver, + p.cfg.ChatProtocol, + p.cfg.ChatEndpointPath, + p.cfg.DiscoveryProtocol, + p.cfg.DiscoveryEndpointPath, + p.cfg.AuthStrategy, + p.cfg.ResponseProfile, + p.cfg.APIStyle, + p.cfg.DiscoveryResponseProfile, + ) if err != nil { - return nil, fmt.Errorf("%sbuild models request: %w", shared.ErrorPrefix, err) + return nil, provider.NewDiscoveryConfigError(err.Error()) } - req.Header.Set("Accept", "application/json") - shared.SetBearerAuthorization(req.Header, p.cfg.APIKey) - resp, err := p.client.Do(req) - if err != nil { - return nil, fmt.Errorf("%ssend models request: %w", shared.ErrorPrefix, err) - } - defer func(body io.ReadCloser) { - _ = body.Close() - }(resp.Body) - - if resp.StatusCode >= http.StatusBadRequest { - data, _ := io.ReadAll(resp.Body) - body := strings.TrimSpace(string(data)) - if body == "" { - body = resp.Status - } - return nil, fmt.Errorf("%smodels endpoint %s", shared.ErrorPrefix, body) - } - - var payload openAIModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("%sdecode models response: %w", shared.ErrorPrefix, err) - } - models, err := decodeModelsData(payload.Data) - if err != nil { - return nil, fmt.Errorf("%sdecode models response: %w", shared.ErrorPrefix, err) - } - return models, nil + return httpdiscovery.DiscoverRawModels(ctx, p.client, httpdiscovery.RequestConfig{ + BaseURL: p.cfg.BaseURL, + EndpointPath: normalizedProtocols.DiscoveryEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + ResponseProfile: normalizedProtocols.ResponseProfile, + AuthStrategy: normalizedProtocols.AuthStrategy, + APIKey: p.cfg.APIKey, + APIVersion: p.cfg.APIVersion, + }) } diff --git a/internal/provider/openaicompat/driver.go b/internal/provider/openaicompat/driver.go index 6c5b9e93..220f0f83 100644 --- a/internal/provider/openaicompat/driver.go +++ b/internal/provider/openaicompat/driver.go @@ -24,8 +24,21 @@ func Driver() provider.DriverDefinition { // validateCatalogIdentity 复用 api_style 分流规则,在 catalog 快照与缓存路径上提前拒绝当前尚不支持的静态配置。 func validateCatalogIdentity(identity provider.ProviderIdentity) error { - _, err := supportedAPIStyle(identity.APIStyle) - return err + _, err := provider.NormalizeProviderProtocolSettings( + identity.Driver, + identity.ChatProtocol, + identity.ChatEndpointPath, + identity.DiscoveryProtocol, + identity.DiscoveryEndpointPath, + identity.AuthStrategy, + identity.ResponseProfile, + identity.APIStyle, + identity.DiscoveryResponseProfile, + ) + if err != nil { + return provider.NewDiscoveryConfigError(err.Error()) + } + return nil } // driverDefinition 根据驱动名构造共享的 OpenAI-compatible 协议驱动定义。 diff --git a/internal/provider/openaicompat/driver_internal_test.go b/internal/provider/openaicompat/driver_internal_test.go index efc373a7..673996fc 100644 --- a/internal/provider/openaicompat/driver_internal_test.go +++ b/internal/provider/openaicompat/driver_internal_test.go @@ -58,13 +58,13 @@ func TestDriverClosuresAndAPIStyle(t *testing.T) { if got := normalizedAPIStyle(" Responses "); got != "responses" { t.Fatalf("expected normalized responses style, got %q", got) } - if got, err := supportedAPIStyle(""); err != nil || got != provider.OpenAICompatibleAPIStyleChatCompletions { - t.Fatalf("expected supported default api style, got style=%q err=%v", got, err) + if got, err := supportedChatProtocol(provider.RuntimeConfig{}); err != nil || got != provider.ChatProtocolOpenAIChatCompletions { + t.Fatalf("expected default chat protocol, got protocol=%q err=%v", got, err) } - if _, err := supportedAPIStyle(" Responses "); err == nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { + if _, err := supportedChatProtocol(provider.RuntimeConfig{APIStyle: " Responses "}); err == nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { t.Fatalf("expected unsupported responses api_style, got %v", err) } - if _, err := supportedAPIStyle("custom_style"); err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { + if _, err := supportedChatProtocol(provider.RuntimeConfig{APIStyle: "custom_style"}); err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { t.Fatalf("expected unsupported custom api_style, got %v", err) } } @@ -85,6 +85,20 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { t.Fatalf("expected build models request error, got %v", err) } + p = &Provider{ + cfg: provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + APIKey: "test-key", + DiscoveryEndpointPath: "https://api.example.com/models", + }, + client: &http.Client{}, + } + if _, err := p.fetchModels(context.Background()); err == nil || !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error, got %v", err) + } + var auth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") @@ -127,10 +141,18 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { t.Fatalf("expected unsupported api_style error, got %v", err) } + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{}, + }) + })) + defer server.Close() + p, err = New(provider.RuntimeConfig{ Name: DriverName, Driver: DriverName, - BaseURL: "https://api.example.com/v1", + BaseURL: server.URL, DefaultModel: "gpt-4.1", APIKey: "test-key", APIStyle: "responses", @@ -138,14 +160,15 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { if err != nil { t.Fatalf("New() error = %v", err) } - if _, err := p.DiscoverModels(context.Background()); err == nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("expected discovery to reject responses api_style, got %v", err) + p.client = server.Client() + if _, err := p.DiscoverModels(context.Background()); err != nil { + t.Fatalf("expected discovery to ignore chat-only api_style, got %v", err) } p, err = New(provider.RuntimeConfig{ Name: DriverName, Driver: DriverName, - BaseURL: "https://api.example.com/v1", + BaseURL: server.URL, DefaultModel: "gpt-4.1", APIKey: "test-key", APIStyle: "custom_style", @@ -153,7 +176,32 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { if err != nil { t.Fatalf("New() error = %v", err) } - if _, err := p.DiscoverModels(context.Background()); err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { - t.Fatalf("expected discovery to reject custom api_style, got %v", err) + p.client = server.Client() + if _, err := p.DiscoverModels(context.Background()); err != nil { + t.Fatalf("expected discovery to ignore unknown chat-only api_style, got %v", err) + } +} + +func TestValidateCatalogIdentityRejectsInvalidDiscoverySettings(t *testing.T) { + t.Parallel() + + err := validateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryEndpointPath: "https://api.example.com/models", + }) + if err == nil || !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error for endpoint path, got %v", err) + } + + err = validateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DiscoveryResponseProfile: "unsupported", + }) + if err == nil || !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error for response profile, got %v", err) } } diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index d93abff8..532991aa 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -150,6 +150,157 @@ func TestDiscoverModels(t *testing.T) { } } +func TestDiscoverModelsUsesConfiguredDiscoveryEndpointPath(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gateway/models" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "gpt-4.1-mini"}, + }, + }) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, "") + cfg.DiscoveryEndpointPath = "/gateway/models" + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "gpt-4.1-mini" { + t.Fatalf("expected configured discovery endpoint path to return one model, got %+v", models) + } +} + +func TestDiscoverModelsParsesGeminiProfileModelList(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"name": "gemini-2.5-flash", "displayName": "Gemini 2.5 Flash"}, + }, + }) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, "") + cfg.DiscoveryResponseProfile = provider.DiscoveryResponseProfileGemini + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 1 || models[0].Name != "Gemini 2.5 Flash" { + t.Fatalf("expected gemini profile parsing, got %+v", models) + } +} + +func TestDiscoverModelsParsesNestedContainerAndAliasFields(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "models": []map[string]any{ + {"model_id": "qwen-plus", "displayname": "Qwen Plus"}, + }, + }, + }) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, "") + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "qwen-plus" || models[0].Name != "Qwen Plus" { + t.Fatalf("expected nested alias payload parsing, got %+v", models) + } +} + +func TestDiscoverModelsOpenAIProfileFallsBackToGenericListKeys(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "thirdparty-chat", "display_name": "ThirdParty Chat"}, + }, + }) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, "") + cfg.DiscoveryResponseProfile = provider.DiscoveryResponseProfileOpenAI + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "thirdparty-chat" || models[0].Name != "ThirdParty Chat" { + t.Fatalf("expected openai profile fallback to generic list keys, got %+v", models) + } +} + +func TestDiscoverModelsParsesStringModelIDs(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []any{"model-alpha", "model-beta"}, + }) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, "") + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 2 || models[0].ID != "model-alpha" || models[1].ID != "model-beta" { + t.Fatalf("expected string model ids to be normalized, got %+v", models) + } +} + // --- toOpenAIMessage 转换测试 --- func TestToOpenAIMessage_BasicMessage(t *testing.T) { @@ -777,6 +928,38 @@ func TestDiscoverModels_HTTPError(t *testing.T) { } } +func TestDiscoverModels_HTTP404ClassifiedAsNotFound(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("missing endpoint")) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL, "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + _, err = p.DiscoverModels(context.Background()) + if err == nil { + t.Fatal("expected error for HTTP 404 response") + } + + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("expected provider error, got %T %v", err, err) + } + if providerErr.Code != provider.ErrorCodeNotFound { + t.Fatalf("expected error code %q, got %+v", provider.ErrorCodeNotFound, providerErr) + } + if !strings.Contains(providerErr.Message, "endpoint not found") { + t.Fatalf("expected not found guidance message, got %+v", providerErr) + } +} + func TestDiscoverModels_NetworkError(t *testing.T) { t.Parallel() @@ -1025,6 +1208,39 @@ data: [DONE] } } +func TestGenerate_UsesConfiguredChatEndpointPath(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gateway/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]} +data: [DONE] + +`)) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL, config.OpenAIDefaultModel) + cfg.ChatEndpointPath = "/gateway/chat/completions" + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + events := make(chan providertypes.StreamEvent, 4) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Model: config.OpenAIDefaultModel, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, + }, events) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } +} + // --- parseError 边界测试 --- func TestParseError_ReadBodyFailure(t *testing.T) { diff --git a/internal/provider/openaicompat/provider.go b/internal/provider/openaicompat/provider.go index 32c546c0..4cff8fb9 100644 --- a/internal/provider/openaicompat/provider.go +++ b/internal/provider/openaicompat/provider.go @@ -2,8 +2,10 @@ package openaicompat import ( "context" + "encoding/json" "fmt" "net/http" + "strings" "time" "neo-code/internal/provider" @@ -57,18 +59,15 @@ func New(cfg provider.RuntimeConfig, opts ...buildOption) (*Provider, error) { // DiscoverModels 通过 /models 端点查询可用模型列表。 func (p *Provider) DiscoverModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { - if _, err := supportedAPIStyle(p.cfg.APIStyle); err != nil { - return nil, err - } - rawModels, err := p.fetchModels(ctx) if err != nil { return nil, err } descriptors := make([]providertypes.ModelDescriptor, 0, len(rawModels)) + fieldAliases := decodeModelFieldAliases(p.cfg.ModelFieldAliases) for _, raw := range rawModels { - descriptor, ok := providertypes.DescriptorFromRawModel(raw) + descriptor, ok := providertypes.DescriptorFromRawModelWithAliases(raw, fieldAliases) if !ok { continue } @@ -80,7 +79,7 @@ func (p *Provider) DiscoverModels(ctx context.Context) ([]providertypes.ModelDes // Generate 发起 SSE 流式生成请求。 // 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { - if _, err := supportedAPIStyle(p.cfg.APIStyle); err != nil { + if _, err := supportedChatProtocol(p.cfg); err != nil { return err } @@ -100,18 +99,61 @@ func normalizedAPIStyle(apiStyle string) string { return normalized } -func supportedAPIStyle(apiStyle string) (string, error) { - normalized := normalizedAPIStyle(apiStyle) +// normalizedChatProtocol 统一解析 chat protocol,优先新字段并兼容旧 api_style 配置。 +func normalizedChatProtocol(cfg provider.RuntimeConfig) string { + if normalized := provider.NormalizeProviderChatProtocol(cfg.ChatProtocol); normalized != "" { + return normalized + } + + normalizedLegacyAPIStyle := normalizedAPIStyle(cfg.APIStyle) + switch normalizedLegacyAPIStyle { + case provider.OpenAICompatibleAPIStyleResponses: + return provider.ChatProtocolOpenAIResponses + case provider.OpenAICompatibleAPIStyleChatCompletions, "": + return provider.ChatProtocolOpenAIChatCompletions + default: + return normalizedLegacyAPIStyle + } +} + +// supportedChatProtocol 校验 openaicompat 当前支持的聊天协议。 +func supportedChatProtocol(cfg provider.RuntimeConfig) (string, error) { + normalized := normalizedChatProtocol(cfg) + usingLegacyAPIStyle := strings.TrimSpace(cfg.APIStyle) != "" switch normalized { - case provider.OpenAICompatibleAPIStyleChatCompletions: + case provider.ChatProtocolOpenAIChatCompletions: return normalized, nil - case provider.OpenAICompatibleAPIStyleResponses: + case provider.ChatProtocolOpenAIResponses: + if usingLegacyAPIStyle { + return "", provider.NewDiscoveryConfigError( + fmt.Sprintf("openaicompat provider: api_style %q is not supported yet", provider.OpenAICompatibleAPIStyleResponses), + ) + } return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: api_style %q is not supported yet", normalized), + fmt.Sprintf("openaicompat provider: chat_protocol %q is not supported yet", normalized), ) default: + if usingLegacyAPIStyle { + return "", provider.NewDiscoveryConfigError( + fmt.Sprintf("openaicompat provider: unsupported api_style %q", normalizedAPIStyle(cfg.APIStyle)), + ) + } return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: unsupported api_style %q", normalized), + fmt.Sprintf("openaicompat provider: unsupported chat_protocol %q", normalized), ) } } + +// decodeModelFieldAliases 解析配置中的字段别名映射字符串,解析失败时静默回退到默认映射。 +func decodeModelFieldAliases(encoded string) map[string][]string { + trimmed := strings.TrimSpace(encoded) + if trimmed == "" { + return nil + } + + var aliases map[string][]string + if err := json.Unmarshal([]byte(trimmed), &aliases); err != nil { + return nil + } + return aliases +} diff --git a/internal/provider/openaicompat/shared/shared.go b/internal/provider/openaicompat/shared/shared.go index a9e11e1b..e294f76a 100644 --- a/internal/provider/openaicompat/shared/shared.go +++ b/internal/provider/openaicompat/shared/shared.go @@ -6,9 +6,10 @@ import ( "strings" "neo-code/internal/provider" + "neo-code/internal/provider/discovery" ) -// ErrorPrefix 统一收敛 OpenAI 兼容 provider 的错误前缀,避免历史命名残留继续扩散。 +// ErrorPrefix 统一收敛 OpenAI 兼容 provider 的错误前缀 const ErrorPrefix = "openaicompat provider: " func ValidateRuntimeConfig(cfg provider.RuntimeConfig) error { @@ -22,25 +23,14 @@ func ValidateRuntimeConfig(cfg provider.RuntimeConfig) error { } func Endpoint(baseURL string, path string) string { - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if path == "" { - return baseURL - } - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - return baseURL + path + return discovery.ResolveEndpoint(baseURL, path) } func SetBearerAuthorization(header http.Header, apiKey string) { - if header == nil { - return - } - - apiKey = strings.TrimSpace(apiKey) - if apiKey == "" { - return - } + provider.ApplyAuthHeaders(header, provider.AuthStrategyBearer, apiKey, "") +} - header.Set("Authorization", "Bearer "+apiKey) +// ApplyAuthHeaders 根据 runtime 配置中的 auth strategy 写入鉴权头。 +func ApplyAuthHeaders(header http.Header, cfg provider.RuntimeConfig) { + provider.ApplyAuthHeaders(header, cfg.AuthStrategy, cfg.APIKey, cfg.APIVersion) } diff --git a/internal/provider/openaicompat/sse_reader_test.go b/internal/provider/openaicompat/sse_reader_test.go deleted file mode 100644 index aba86535..00000000 --- a/internal/provider/openaicompat/sse_reader_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package openaicompat - -import ( - "errors" - "io" - "strings" - "testing" - - "neo-code/internal/provider" - "neo-code/internal/provider/openaicompat/chatcompletions" -) - -// --- boundedSSEReader 单元测试 --- - -func TestBoundedSSEReader_ReadLine_Normal(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - isEOF bool - }{ - { - name: "single line with newline", - input: "data: hello\n", - want: "data: hello", - isEOF: false, - }, - { - name: "line with CRLF", - input: "data: world\r\n", - want: "data: world", - isEOF: false, - }, - { - name: "empty line", - input: "\n", - want: "", - isEOF: false, - }, - { - name: "SSE comment line", - input: ": heartbeat\n", - want: ": heartbeat", - isEOF: false, - }, - { - name: "EOF without trailing newline (io.EOF)", - input: "data: partial", - want: "data: partial", - isEOF: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(tt.input)) - got, err := r.ReadLine() - if got != tt.want { - t.Fatalf("ReadLine() = %q, want %q", got, tt.want) - } - if tt.isEOF && !errors.Is(err, io.EOF) { - t.Fatalf("expected io.EOF, got %v", err) - } - if !tt.isEOF && err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -func TestBoundedSSEReader_ReadLine_MultipleLines(t *testing.T) { - t.Parallel() - - r := chatcompletions.NewBoundedSSEReader(strings.NewReader("line1\nline2\n\nline4\n")) - - line1, err := r.ReadLine() - if err != nil || line1 != "line1" { - t.Fatalf("first line: got %q, err = %v", line1, err) - } - - line2, err := r.ReadLine() - if err != nil || line2 != "line2" { - t.Fatalf("second line: got %q, err = %v", line2, err) - } - - // 空行 - empty, err := r.ReadLine() - if err != nil || empty != "" { - t.Fatalf("empty line: got %q, err = %v", empty, err) - } - - line4, err := r.ReadLine() - if err != nil || line4 != "line4" { - t.Fatalf("fourth line: got %q, err = %v", line4, err) - } - - // EOF - _, err = r.ReadLine() - if !errors.Is(err, io.EOF) { - t.Fatalf("expected EOF after all lines, got %v", err) - } -} - -func TestBoundedSSEReader_L1_LineTooLong(t *testing.T) { - t.Parallel() - - longLine := strings.Repeat("x", chatcompletions.MaxSSELineSize+1) + "\n" - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(longLine)) - - _, err := r.ReadLine() - if err == nil { - t.Fatal("expected ErrLineTooLong for oversized line") - } - if !errors.Is(err, provider.ErrLineTooLong) { - t.Fatalf("expected ErrLineTooLong, got %v", err) - } -} - -func TestBoundedSSEReader_L1_BoundaryExactLimit(t *testing.T) { - t.Parallel() - - // 恰好等于上限的行应该正常通过(不含 \n) - exactLine := strings.Repeat("a", chatcompletions.MaxSSELineSize) + "\n" - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(exactLine)) - - got, err := r.ReadLine() - if err != nil { - t.Fatalf("unexpected error at exact limit: %v", err) - } - if len(got) != chatcompletions.MaxSSELineSize { - t.Fatalf("expected line length %d, got %d", chatcompletions.MaxSSELineSize, len(got)) - } -} - -func TestBoundedSSEReader_L3_StreamTooLarge(t *testing.T) { - t.Parallel() - - // 构造输入:每行 1KB(远小于 maxSSELineSize),行数足够多使总量超过 maxStreamTotalSize - line := strings.Repeat("x", 1024) + "\n" // 1025 bytes per line - lineSize := int64(len(line)) - - var sb strings.Builder - linesToWrite := int(chatcompletions.MaxStreamTotalSize/lineSize) + 1 - for range linesToWrite { - sb.WriteString(line) - } - - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(sb.String())) - - // 前面的行应能正常读取 - expectedNormal := int(chatcompletions.MaxStreamTotalSize / lineSize) - for range expectedNormal { - _, err := r.ReadLine() - if err != nil { - t.Fatalf("unexpected error on normal line: %v", err) - } - } - - // 超限的行应返回 ErrStreamTooLarge(而非 ErrLineTooLong) - _, err := r.ReadLine() - if err == nil { - t.Fatal("expected ErrStreamTooLarge") - } - if !errors.Is(err, provider.ErrStreamTooLarge) { - t.Fatalf("expected ErrStreamTooLarge, got %v", err) - } -} - -func TestTrimLineEnding(t *testing.T) { - t.Parallel() - - tests := []struct { - input string - want string - }{ - {"hello\n", "hello"}, - {"hello\r\n", "hello"}, - {"hello\r\n\n", "hello"}, // 连续换行符全部去除 - {"hello", "hello"}, - {"\n", ""}, - {"\r\n", ""}, - {"\r", ""}, // 孤立 \r 也去除 - {"", ""}, - } - - for _, tt := range tests { - got := chatcompletions.TrimLineEnding(tt.input) - if got != tt.want { - t.Fatalf("trimLineEnding(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -// TestBoundedSSEReader_L1_NoNewlineEOFAtLimit 验证恰好等于缓冲区大小 -// 但以 EOF 结尾(无 \n)的行能被正常读取并返回 io.EOF。 -func TestBoundedSSEReader_L1_NoNewlineEOFAtLimit(t *testing.T) { - t.Parallel() - - // 不含 \n 的行,长度恰好等于 maxSSELineSize,以 EOF 结尾 - exactLine := strings.Repeat("b", chatcompletions.MaxSSELineSize) - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(exactLine)) - - got, err := r.ReadLine() - if err == nil || !errors.Is(err, io.EOF) { - t.Fatalf("expected io.EOF for line without trailing newline, got err=%v", err) - } - if len(got) != chatcompletions.MaxSSELineSize { - t.Fatalf("expected line length %d, got %d", chatcompletions.MaxSSELineSize, len(got)) - } -} - -// TestBoundedSSEReader_L1_NoNewlineEOFExceedsLimit 验证超过缓冲区大小 -// 且以 EOF 结尾(无 \n)的行返回 ErrLineTooLong。 -func TestBoundedSSEReader_L1_NoNewlineEOFExceedsLimit(t *testing.T) { - t.Parallel() - - // 不含 \n,长度超过 maxSSELineSize - longLine := strings.Repeat("c", chatcompletions.MaxSSELineSize+1) - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(longLine)) - - _, err := r.ReadLine() - if err == nil { - t.Fatal("expected ErrLineTooLong for oversized line without newline") - } - if !errors.Is(err, provider.ErrLineTooLong) { - t.Fatalf("expected ErrLineTooLong, got %v", err) - } -} - -// TestBoundedSSEReader_UnderlyingErrorPropagation 验证底层 reader 的非 EOF 错误 -// 会被正确传播,而不是被 L1/L3 吞掉。 -func TestBoundedSSEReader_UnderlyingErrorPropagation(t *testing.T) { - t.Parallel() - - r := chatcompletions.NewBoundedSSEReader(&errReader{err: io.ErrClosedPipe}) - _, err := r.ReadLine() - if err == nil { - t.Fatal("expected error from broken reader") - } - if !errors.Is(err, io.ErrClosedPipe) { - t.Fatalf("expected io.ErrClosedPipe, got %v", err) - } -} - -// TestBoundedSSEReader_L1_ThenNormalRead 验证 L1 触发后 reader 状态仍然一致, -// 后续正常行仍可继续读取(如果调用方选择恢复)。 -func TestBoundedSSEReader_L1_ThenNormalRead(t *testing.T) { - t.Parallel() - - // 第一行超长触发 L1,第二行正常 - input := strings.Repeat("x", chatcompletions.MaxSSELineSize+10) + "\nnormal line\n" - r := chatcompletions.NewBoundedSSEReader(strings.NewReader(input)) - - // 第一行应返回 ErrLineTooLong - _, err := r.ReadLine() - if !errors.Is(err, provider.ErrLineTooLong) { - t.Fatalf("first line: expected ErrLineTooLong, got %v", err) - } - - // 注意:L1 触发后 bufio.Reader 内部可能已消耗了部分后续数据(缓冲区残留), - // 因此此测试仅验证 L1 错误被正确返回,不要求后续行一定可读。 - // 这符合实际使用场景——L1 触发后调用方会终止流消费。 -} diff --git a/internal/provider/protocols.go b/internal/provider/protocols.go new file mode 100644 index 00000000..f3bf9125 --- /dev/null +++ b/internal/provider/protocols.go @@ -0,0 +1,245 @@ +package provider + +import ( + "fmt" + "strings" +) + +// NormalizedProtocolSettings 收敛 provider 协议相关字段,统一承载新旧配置映射后的可执行结果。 +type NormalizedProtocolSettings struct { + ChatProtocol string + ChatEndpointPath string + DiscoveryProtocol string + DiscoveryEndpointPath string + AuthStrategy string + ResponseProfile string + LegacyAPIStyle string +} + +// NormalizeProviderChatProtocol 规范化 chat protocol 枚举,未知值保持原样供上层做更严格校验。 +func NormalizeProviderChatProtocol(value string) string { + return NormalizeKey(value) +} + +// NormalizeProviderDiscoveryProtocol 规范化 discovery protocol 枚举,未知值保持原样供上层做更严格校验。 +func NormalizeProviderDiscoveryProtocol(value string) string { + return NormalizeKey(value) +} + +// NormalizeProviderAuthStrategy 规范化 auth strategy 枚举,未知值保持原样供上层做更严格校验。 +func NormalizeProviderAuthStrategy(value string) string { + return NormalizeKey(value) +} + +// NormalizeProviderChatEndpointPath 规范化聊天端点路径,沿用 discovery 路径的安全约束规则。 +func NormalizeProviderChatEndpointPath(endpointPath string) (string, error) { + return NormalizeProviderDiscoveryEndpointPath(endpointPath) +} + +// NormalizeProviderProtocolSettings 合并新旧协议字段并执行组合校验,输出统一可执行协议配置。 +func NormalizeProviderProtocolSettings( + driver string, + chatProtocol string, + chatEndpointPath string, + discoveryProtocol string, + discoveryEndpointPath string, + authStrategy string, + responseProfile string, + legacyAPIStyle string, + legacyDiscoveryResponseProfile string, +) (NormalizedProtocolSettings, error) { + normalizedDriver := NormalizeProviderDriver(driver) + settings := NormalizedProtocolSettings{ + ChatProtocol: NormalizeProviderChatProtocol(chatProtocol), + DiscoveryProtocol: NormalizeProviderDiscoveryProtocol(discoveryProtocol), + AuthStrategy: NormalizeProviderAuthStrategy(authStrategy), + ResponseProfile: NormalizeKey(responseProfile), + LegacyAPIStyle: NormalizeProviderAPIStyle(legacyAPIStyle), + } + + normalizedChatEndpointPath, err := NormalizeProviderChatEndpointPath(chatEndpointPath) + if err != nil { + return NormalizedProtocolSettings{}, err + } + normalizedDiscoveryEndpointPath, err := NormalizeProviderDiscoveryEndpointPath(discoveryEndpointPath) + if err != nil { + return NormalizedProtocolSettings{}, err + } + settings.ChatEndpointPath = normalizedChatEndpointPath + settings.DiscoveryEndpointPath = normalizedDiscoveryEndpointPath + + if settings.LegacyAPIStyle != "" { + mappedChatProtocol := "" + switch settings.LegacyAPIStyle { + case OpenAICompatibleAPIStyleChatCompletions: + mappedChatProtocol = ChatProtocolOpenAIChatCompletions + case OpenAICompatibleAPIStyleResponses: + mappedChatProtocol = ChatProtocolOpenAIResponses + } + if mappedChatProtocol != "" && + (settings.ChatProtocol == "" || + settings.ChatProtocol == ChatProtocolOpenAIChatCompletions || + settings.ChatProtocol == ChatProtocolOpenAIResponses) { + settings.ChatProtocol = mappedChatProtocol + } + } + + if settings.ResponseProfile == "" { + legacyProfile, err := NormalizeProviderDiscoveryResponseProfile(legacyDiscoveryResponseProfile) + if err != nil { + return NormalizedProtocolSettings{}, err + } + settings.ResponseProfile = legacyProfile + } + + applyDriverDefaultProtocolSettings(normalizedDriver, &settings) + if err := validateProtocolEnums(settings); err != nil { + return NormalizedProtocolSettings{}, err + } + if err := validateProtocolCombinations(settings); err != nil { + return NormalizedProtocolSettings{}, err + } + + if settings.ChatEndpointPath == "" { + settings.ChatEndpointPath = defaultChatEndpointPath(settings.ChatProtocol) + } + if settings.DiscoveryEndpointPath == "" { + settings.DiscoveryEndpointPath = defaultDiscoveryEndpointPath(settings.DiscoveryProtocol) + } + + if settings.LegacyAPIStyle == "" && (normalizedDriver == DriverOpenAICompat || normalizedDriver == DriverGemini) { + switch settings.ChatProtocol { + case ChatProtocolOpenAIChatCompletions: + settings.LegacyAPIStyle = OpenAICompatibleAPIStyleChatCompletions + case ChatProtocolOpenAIResponses: + settings.LegacyAPIStyle = OpenAICompatibleAPIStyleResponses + } + } + + return settings, nil +} + +// applyDriverDefaultProtocolSettings 为不同 driver 注入协议默认值,降低配置输入成本并保证行为稳定。 +func applyDriverDefaultProtocolSettings(normalizedDriver string, settings *NormalizedProtocolSettings) { + if settings == nil { + return + } + + switch normalizedDriver { + case DriverOpenAICompat: + if settings.ChatProtocol == "" { + settings.ChatProtocol = ChatProtocolOpenAIChatCompletions + } + if settings.DiscoveryProtocol == "" { + settings.DiscoveryProtocol = DiscoveryProtocolOpenAIModels + } + if settings.AuthStrategy == "" { + settings.AuthStrategy = AuthStrategyBearer + } + if settings.ResponseProfile == "" { + settings.ResponseProfile = DiscoveryResponseProfileOpenAI + } + case DriverGemini: + if settings.ChatProtocol == "" { + settings.ChatProtocol = ChatProtocolOpenAIChatCompletions + } + if settings.DiscoveryProtocol == "" { + settings.DiscoveryProtocol = DiscoveryProtocolGeminiModels + } + if settings.AuthStrategy == "" { + settings.AuthStrategy = AuthStrategyBearer + } + if settings.ResponseProfile == "" { + settings.ResponseProfile = DiscoveryResponseProfileGemini + } + case DriverAnthropic: + if settings.ChatProtocol == "" { + settings.ChatProtocol = ChatProtocolAnthropicMessages + } + if settings.DiscoveryProtocol == "" { + settings.DiscoveryProtocol = DiscoveryProtocolAnthropicModels + } + if settings.AuthStrategy == "" { + settings.AuthStrategy = AuthStrategyAnthropic + } + if settings.ResponseProfile == "" { + settings.ResponseProfile = DiscoveryResponseProfileGeneric + } + default: + if settings.ChatProtocol == "" { + settings.ChatProtocol = ChatProtocolOpenAIChatCompletions + } + if settings.DiscoveryProtocol == "" { + settings.DiscoveryProtocol = DiscoveryProtocolCustomHTTPJSON + } + if settings.AuthStrategy == "" { + settings.AuthStrategy = AuthStrategyBearer + } + if settings.ResponseProfile == "" { + settings.ResponseProfile = DiscoveryResponseProfileGeneric + } + } +} + +// validateProtocolEnums 校验协议枚举输入,避免拼写错误在运行期才暴露。 +func validateProtocolEnums(settings NormalizedProtocolSettings) error { + switch settings.ChatProtocol { + case ChatProtocolOpenAIChatCompletions, ChatProtocolOpenAIResponses, ChatProtocolGeminiNative, ChatProtocolAnthropicMessages: + default: + return fmt.Errorf("provider chat protocol %q is unsupported", settings.ChatProtocol) + } + + switch settings.DiscoveryProtocol { + case DiscoveryProtocolOpenAIModels, DiscoveryProtocolGeminiModels, DiscoveryProtocolAnthropicModels, DiscoveryProtocolCustomHTTPJSON: + default: + return fmt.Errorf("provider discovery protocol %q is unsupported", settings.DiscoveryProtocol) + } + + switch settings.AuthStrategy { + case AuthStrategyBearer, AuthStrategyXAPIKey, AuthStrategyAnthropic: + default: + return fmt.Errorf("provider auth strategy %q is unsupported", settings.AuthStrategy) + } + + profile, err := NormalizeProviderDiscoveryResponseProfile(settings.ResponseProfile) + if err != nil { + return err + } + if profile == "" { + return fmt.Errorf("provider response profile is empty") + } + return nil +} + +// validateProtocolCombinations 校验协议组合合法性,保证启动阶段 fail-fast。 +func validateProtocolCombinations(settings NormalizedProtocolSettings) error { + if settings.ChatProtocol == ChatProtocolAnthropicMessages && settings.AuthStrategy == AuthStrategyBearer { + return fmt.Errorf("chat protocol %q does not allow auth strategy %q", settings.ChatProtocol, settings.AuthStrategy) + } + if settings.ChatProtocol == ChatProtocolGeminiNative && settings.AuthStrategy == AuthStrategyAnthropic { + return fmt.Errorf("chat protocol %q does not allow auth strategy %q", settings.ChatProtocol, settings.AuthStrategy) + } + return nil +} + +// defaultChatEndpointPath 返回不同 chat protocol 的默认端点路径。 +func defaultChatEndpointPath(chatProtocol string) string { + switch strings.TrimSpace(chatProtocol) { + case ChatProtocolOpenAIResponses: + return "/responses" + case ChatProtocolAnthropicMessages: + return "/messages" + default: + return "/chat/completions" + } +} + +// defaultDiscoveryEndpointPath 返回不同 discovery protocol 的默认端点路径。 +func defaultDiscoveryEndpointPath(discoveryProtocol string) string { + switch strings.TrimSpace(discoveryProtocol) { + case DiscoveryProtocolOpenAIModels, DiscoveryProtocolGeminiModels, DiscoveryProtocolAnthropicModels, DiscoveryProtocolCustomHTTPJSON: + return DiscoveryEndpointPathModels + default: + return "" + } +} diff --git a/internal/provider/protocols_test.go b/internal/provider/protocols_test.go new file mode 100644 index 00000000..a6cf43b6 --- /dev/null +++ b/internal/provider/protocols_test.go @@ -0,0 +1,73 @@ +package provider + +import "testing" + +func TestNormalizeProviderProtocolSettingsLegacyMapping(t *testing.T) { + t.Parallel() + + settings, err := NormalizeProviderProtocolSettings( + DriverOpenAICompat, + "", + "", + "", + "", + "", + "", + OpenAICompatibleAPIStyleResponses, + DiscoveryResponseProfileGeneric, + ) + if err != nil { + t.Fatalf("NormalizeProviderProtocolSettings() error = %v", err) + } + + if settings.ChatProtocol != ChatProtocolOpenAIResponses { + t.Fatalf("expected chat protocol %q, got %q", ChatProtocolOpenAIResponses, settings.ChatProtocol) + } + if settings.ResponseProfile != DiscoveryResponseProfileGeneric { + t.Fatalf("expected response profile %q, got %q", DiscoveryResponseProfileGeneric, settings.ResponseProfile) + } + if settings.DiscoveryEndpointPath != DiscoveryEndpointPathModels { + t.Fatalf("expected discovery endpoint path %q, got %q", DiscoveryEndpointPathModels, settings.DiscoveryEndpointPath) + } +} + +func TestNormalizeProviderProtocolSettingsRejectsIllegalCombination(t *testing.T) { + t.Parallel() + + _, err := NormalizeProviderProtocolSettings( + DriverAnthropic, + ChatProtocolAnthropicMessages, + "", + DiscoveryProtocolAnthropicModels, + "", + AuthStrategyBearer, + DiscoveryResponseProfileGeneric, + "", + "", + ) + if err == nil { + t.Fatal("expected illegal chat/auth combination to fail") + } +} + +func TestNormalizeProviderProtocolSettingsUnknownDriverKeepsLegacyAPIStyleEmpty(t *testing.T) { + t.Parallel() + + settings, err := NormalizeProviderProtocolSettings( + "custom-driver", + "", + "", + "", + "", + "", + "", + "", + "", + ) + if err != nil { + t.Fatalf("NormalizeProviderProtocolSettings() error = %v", err) + } + if settings.LegacyAPIStyle != "" { + t.Fatalf("expected unknown driver to keep legacy api style empty, got %q", settings.LegacyAPIStyle) + } +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 0d0f05ec..4ed69c53 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -158,7 +158,7 @@ func TestRegistrySupports(t *testing.T) { func TestRegistryValidateCatalogIdentity(t *testing.T) { t.Parallel() - t.Run("openaicompat rejects unsupported api_style", func(t *testing.T) { + t.Run("openaicompat catalog identity allows chat-only api_style drift", func(t *testing.T) { t.Parallel() registry := newTestRegistry(t) @@ -167,8 +167,8 @@ func TestRegistryValidateCatalogIdentity(t *testing.T) { BaseURL: config.OpenAIDefaultBaseURL, APIStyle: "responses", }) - if !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected discovery config error, got %v", err) + if err != nil { + t.Fatalf("expected catalog identity validation to pass, got %v", err) } }) diff --git a/internal/provider/streaming/sse/reader.go b/internal/provider/streaming/sse/reader.go new file mode 100644 index 00000000..34718b9d --- /dev/null +++ b/internal/provider/streaming/sse/reader.go @@ -0,0 +1,78 @@ +package sse + +import ( + "bufio" + "errors" + "io" + + "neo-code/internal/provider" +) + +const ( + // DefaultMaxLineSize 定义单条 SSE 行的默认上限,防止异常长行占用过多内存。 + DefaultMaxLineSize = 256 * 1024 + // DefaultMaxStreamTotalSize 定义单个 SSE 流允许读取的默认总量上限。 + DefaultMaxStreamTotalSize = 10 << 20 +) + +// BoundedReader 在逐行读取 SSE 数据时执行单行与总量双重限制。 +type BoundedReader struct { + reader *bufio.Reader + totalRead int64 + maxLineSize int + maxStreamTotalSize int64 +} + +// NewBoundedReader 使用默认限制创建 SSE 有界读取器。 +func NewBoundedReader(r io.Reader) *BoundedReader { + return NewBoundedReaderWithLimits(r, DefaultMaxLineSize, DefaultMaxStreamTotalSize) +} + +// NewBoundedReaderWithLimits 使用自定义限制创建 SSE 有界读取器。 +func NewBoundedReaderWithLimits(r io.Reader, maxLineSize int, maxStreamTotalSize int64) *BoundedReader { + if maxLineSize <= 0 { + maxLineSize = DefaultMaxLineSize + } + if maxStreamTotalSize <= 0 { + maxStreamTotalSize = DefaultMaxStreamTotalSize + } + return &BoundedReader{ + reader: bufio.NewReaderSize(r, maxLineSize+1), + maxLineSize: maxLineSize, + maxStreamTotalSize: maxStreamTotalSize, + } +} + +// ReadLine 读取一行 SSE 数据并执行长度限制;返回值会去除行尾换行符。 +func (r *BoundedReader) ReadLine() (string, error) { + line, err := r.reader.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + return "", provider.ErrLineTooLong + } + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + + rawLen := len(line) + if rawLen > 0 && line[rawLen-1] == '\n' { + rawLen-- + } + if rawLen > r.maxLineSize { + return "", provider.ErrLineTooLong + } + + r.totalRead += int64(len(line)) + if r.totalRead > r.maxStreamTotalSize { + return "", provider.ErrStreamTooLarge + } + + return TrimLineEnding(string(line)), err +} + +// TrimLineEnding 去除字符串末尾连续的 \r 与 \n。 +func TrimLineEnding(line string) string { + for len(line) > 0 && (line[len(line)-1] == '\n' || line[len(line)-1] == '\r') { + line = line[:len(line)-1] + } + return line +} diff --git a/internal/provider/openaicompat/chatcompletions/sse_reader_test.go b/internal/provider/streaming/sse/reader_test.go similarity index 68% rename from internal/provider/openaicompat/chatcompletions/sse_reader_test.go rename to internal/provider/streaming/sse/reader_test.go index 45f16817..51d53df1 100644 --- a/internal/provider/openaicompat/chatcompletions/sse_reader_test.go +++ b/internal/provider/streaming/sse/reader_test.go @@ -1,4 +1,4 @@ -package chatcompletions +package sse import ( "errors" @@ -9,7 +9,7 @@ import ( "neo-code/internal/provider" ) -func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { +func TestBoundedReaderAndTrimLineEnding(t *testing.T) { t.Parallel() tests := []struct { @@ -29,7 +29,7 @@ func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - reader := NewBoundedSSEReader(strings.NewReader(tt.input)) + reader := NewBoundedReader(strings.NewReader(tt.input)) got, err := reader.ReadLine() if got != tt.want { t.Fatalf("ReadLine() = %q, want %q", got, tt.want) @@ -46,7 +46,7 @@ func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { }) } - reader := NewBoundedSSEReader(strings.NewReader("line1\nline2\n\nline4\n")) + reader := NewBoundedReader(strings.NewReader("line1\nline2\n\nline4\n")) for _, want := range []string{"line1", "line2", "", "line4"} { got, err := reader.ReadLine() if err != nil || got != want { @@ -57,27 +57,27 @@ func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { t.Fatalf("expected io.EOF, got %v", err) } - if _, err := NewBoundedSSEReader(strings.NewReader(strings.Repeat("x", MaxSSELineSize+1) + "\n")).ReadLine(); !errors.Is(err, provider.ErrLineTooLong) { + if _, err := NewBoundedReader(strings.NewReader(strings.Repeat("x", DefaultMaxLineSize+1) + "\n")).ReadLine(); !errors.Is(err, provider.ErrLineTooLong) { t.Fatalf("expected ErrLineTooLong, got %v", err) } - if got, err := NewBoundedSSEReader(strings.NewReader(strings.Repeat("a", MaxSSELineSize) + "\n")).ReadLine(); err != nil || len(got) != MaxSSELineSize { + if got, err := NewBoundedReader(strings.NewReader(strings.Repeat("a", DefaultMaxLineSize) + "\n")).ReadLine(); err != nil || len(got) != DefaultMaxLineSize { t.Fatalf("expected exact limit to pass, got len=%d err=%v", len(got), err) } - if got, err := NewBoundedSSEReader(strings.NewReader(strings.Repeat("b", MaxSSELineSize))).ReadLine(); !errors.Is(err, io.EOF) || len(got) != MaxSSELineSize { + if got, err := NewBoundedReader(strings.NewReader(strings.Repeat("b", DefaultMaxLineSize))).ReadLine(); !errors.Is(err, io.EOF) || len(got) != DefaultMaxLineSize { t.Fatalf("expected exact limit EOF read, got len=%d err=%v", len(got), err) } - if _, err := NewBoundedSSEReader(strings.NewReader(strings.Repeat("c", MaxSSELineSize+1))).ReadLine(); !errors.Is(err, provider.ErrLineTooLong) { + if _, err := NewBoundedReader(strings.NewReader(strings.Repeat("c", DefaultMaxLineSize+1))).ReadLine(); !errors.Is(err, provider.ErrLineTooLong) { t.Fatalf("expected ErrLineTooLong for oversized EOF line, got %v", err) } line := strings.Repeat("x", 1024) + "\n" lineSize := int64(len(line)) var builder strings.Builder - for i := 0; i < int(MaxStreamTotalSize/lineSize)+1; i++ { + for i := 0; i < int(DefaultMaxStreamTotalSize/lineSize)+1; i++ { builder.WriteString(line) } - reader = NewBoundedSSEReader(strings.NewReader(builder.String())) - for i := 0; i < int(MaxStreamTotalSize/lineSize); i++ { + reader = NewBoundedReader(strings.NewReader(builder.String())) + for i := 0; i < int(DefaultMaxStreamTotalSize/lineSize); i++ { if _, err := reader.ReadLine(); err != nil { t.Fatalf("unexpected error before size limit: %v", err) } @@ -86,7 +86,7 @@ func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { t.Fatalf("expected ErrStreamTooLarge, got %v", err) } - if _, err := NewBoundedSSEReader(&readerErr{err: io.ErrClosedPipe}).ReadLine(); !errors.Is(err, io.ErrClosedPipe) { + if _, err := NewBoundedReader(&readerErr{err: io.ErrClosedPipe}).ReadLine(); !errors.Is(err, io.ErrClosedPipe) { t.Fatalf("expected io.ErrClosedPipe, got %v", err) } diff --git a/internal/provider/types/model.go b/internal/provider/types/model.go index bff9b3ba..df3fa57d 100644 --- a/internal/provider/types/model.go +++ b/internal/provider/types/model.go @@ -1,6 +1,9 @@ package types -import "strings" +import ( + "strconv" + "strings" +) // ModelCapabilityState 表示模型能力提示的三态枚举。 type ModelCapabilityState string @@ -40,22 +43,65 @@ type ModelDescriptor struct { // DescriptorFromRawModel 将原始 provider 模型对象标准化为 ModelDescriptor。 func DescriptorFromRawModel(raw map[string]any) (ModelDescriptor, bool) { + return DescriptorFromRawModelWithAliases(raw, nil) +} + +// DescriptorFromRawModelWithAliases 在默认字段映射基础上追加别名映射,兼容第三方返回字段差异。 +func DescriptorFromRawModelWithAliases(raw map[string]any, aliases map[string][]string) (ModelDescriptor, bool) { + candidates := descriptorRawCandidates(raw) + + idKeys := extendAliasKeys( + []string{"id", "model_id", "modelId", "model_name", "modelName", "name"}, + aliases, + "id", + "model_id", + "model", + ) + nameKeys := extendAliasKeys( + []string{"display_name", "displayName", "displayname", "name", "model_name", "modelName"}, + aliases, + "name", + "display_name", + ) + descriptionKeys := extendAliasKeys( + []string{"description", "desc", "model_description", "modelDescription"}, + aliases, + "description", + ) + contextWindowKeys := extendAliasKeys( + []string{"context_window", "contextWindow", "contextLength", "input_token_limit", "inputTokenLimit", "max_context_tokens"}, + aliases, + "context_window", + ) + maxOutputKeys := extendAliasKeys( + []string{"max_output_tokens", "maxOutputTokens", "output_token_limit", "outputTokenLimit", "max_tokens", "maxTokens"}, + aliases, + "max_output_tokens", + ) + capabilityKeys := extendAliasKeys( + []string{"capabilities", "capability", "features"}, + aliases, + "capabilities", + ) + id := firstNonEmptyString( - stringValue(raw["id"]), - stringValue(raw["model"]), - stringValue(raw["name"]), + stringValueFromCandidates(candidates, idKeys...), + stringValue(lookupRawValue(raw, "model")), ) if id == "" { return ModelDescriptor{}, false } descriptor := ModelDescriptor{ - ID: id, - Name: firstNonEmptyString(stringValue(raw["name"]), stringValue(raw["display_name"]), id), - Description: stringValue(raw["description"]), - ContextWindow: firstPositiveInt(raw["context_window"], raw["contextLength"], raw["input_token_limit"], raw["max_context_tokens"]), - MaxOutputTokens: firstPositiveInt(raw["max_output_tokens"], raw["output_token_limit"], raw["max_tokens"]), - CapabilityHints: modelCapabilityHintsFromValue(raw["capabilities"]), + ID: id, + Name: firstNonEmptyString( + stringValueFromCandidates(candidates, nameKeys...), + id, + ), + Description: stringValueFromCandidates(candidates, descriptionKeys...), + ContextWindow: firstPositiveIntFromCandidates(candidates, contextWindowKeys...), + MaxOutputTokens: firstPositiveIntFromCandidates(candidates, maxOutputKeys...), + CapabilityHints: modelCapabilityHintsFromValue(lookupRawValueFromCandidates(candidates, capabilityKeys...)), } return normalizeModelDescriptor(descriptor), true } @@ -292,7 +338,138 @@ func firstPositiveInt(values ...any) int { if typed > 0 { return int(typed) } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil && parsed > 0 { + return parsed + } + } + } + return 0 +} + +// lookupRawValue 按候选键读取字段,兼容大小写和下划线/连字符差异。 +func lookupRawValue(raw map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := raw[key]; ok { + return value + } + } + + for _, key := range keys { + target := normalizeFieldAliasKey(key) + for currentKey, currentValue := range raw { + if normalizeFieldAliasKey(currentKey) == target { + return currentValue + } + } + } + return nil +} + +// normalizeFieldAliasKey 统一字段别名比较规则:忽略大小写、下划线、连字符和空白。 +func normalizeFieldAliasKey(key string) string { + trimmed := strings.TrimSpace(strings.ToLower(key)) + replacer := strings.NewReplacer("_", "", "-", "", " ", "") + return replacer.Replace(trimmed) +} + +// descriptorRawCandidates 生成 descriptor 字段读取候选对象,优先原始对象,再回退嵌套模型对象。 +func descriptorRawCandidates(raw map[string]any) []map[string]any { + candidates := make([]map[string]any, 0, 5) + if raw != nil { + candidates = append(candidates, raw) + } + + for _, key := range []string{"model", "model_info", "modelInfo", "spec", "data"} { + nested, ok := lookupRawValue(raw, key).(map[string]any) + if !ok || len(nested) == 0 { + continue + } + candidates = append(candidates, nested) + } + return candidates +} + +// lookupRawValueFromCandidates 按候选对象顺序读取字段,支持第三方把模型元数据包装在嵌套对象中。 +func lookupRawValueFromCandidates(candidates []map[string]any, keys ...string) any { + for _, candidate := range candidates { + if candidate == nil { + continue + } + if value := lookupRawValue(candidate, keys...); value != nil { + return value + } + } + return nil +} + +// stringValueFromCandidates 按候选对象顺序提取首个非空字符串,遇到 map/array 等非字符串值会自动跳过。 +func stringValueFromCandidates(candidates []map[string]any, keys ...string) string { + for _, candidate := range candidates { + if candidate == nil { + continue + } + for _, key := range keys { + if value := stringValue(lookupRawValue(candidate, key)); value != "" { + return value + } + } + } + return "" +} + +// firstPositiveIntFromCandidates 按候选对象顺序提取首个正整数,兼容字符串与数字类型。 +func firstPositiveIntFromCandidates(candidates []map[string]any, keys ...string) int { + for _, candidate := range candidates { + if candidate == nil { + continue + } + values := make([]any, 0, len(keys)) + for _, key := range keys { + values = append(values, lookupRawValue(candidate, key)) + } + if result := firstPositiveInt(values...); result > 0 { + return result } } return 0 } + +// extendAliasKeys 在默认字段别名基础上追加配置别名,保持原有优先级并去重。 +func extendAliasKeys(defaultKeys []string, aliases map[string][]string, canonicalKeys ...string) []string { + combined := make([]string, 0, len(defaultKeys)+4) + seen := make(map[string]struct{}, len(defaultKeys)+4) + + appendKey := func(key string) { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return + } + normalized := normalizeFieldAliasKey(trimmed) + if normalized == "" { + return + } + if _, exists := seen[normalized]; exists { + return + } + seen[normalized] = struct{}{} + combined = append(combined, trimmed) + } + + for _, key := range defaultKeys { + appendKey(key) + } + + if len(aliases) == 0 { + return combined + } + + for _, canonicalKey := range canonicalKeys { + for _, alias := range aliases[canonicalKey] { + appendKey(alias) + } + } + + return combined +} diff --git a/internal/provider/types/model_test.go b/internal/provider/types/model_test.go index 1e2a725d..b52e0204 100644 --- a/internal/provider/types/model_test.go +++ b/internal/provider/types/model_test.go @@ -42,6 +42,47 @@ func TestDescriptorFromRawModel(t *testing.T) { }, wantOK: true, }, + { + name: "name from displayName alias", + raw: map[string]any{ + "id": "gemini-2.5-flash", + "displayName": "Gemini 2.5 Flash", + }, + want: ModelDescriptor{ + ID: "gemini-2.5-flash", + Name: "Gemini 2.5 Flash", + }, + wantOK: true, + }, + { + name: "id from model_id alias and name from displayname alias", + raw: map[string]any{ + "model_id": "qwen-plus", + "displayname": "Qwen Plus", + }, + want: ModelDescriptor{ + ID: "qwen-plus", + Name: "Qwen Plus", + }, + wantOK: true, + }, + { + name: "case-insensitive model id and string token fields", + raw: map[string]any{ + "ModelId": "qwen-max", + "model_description": "Best model", + "contextWindow": "128000", + "maxTokens": "8192", + }, + want: ModelDescriptor{ + ID: "qwen-max", + Name: "qwen-max", + Description: "Best model", + ContextWindow: 128000, + MaxOutputTokens: 8192, + }, + wantOK: true, + }, { name: "capabilities map becomes hints", raw: map[string]any{ @@ -63,6 +104,39 @@ func TestDescriptorFromRawModel(t *testing.T) { }, wantOK: true, }, + { + name: "nested model object fields", + raw: map[string]any{ + "model": map[string]any{ + "id": "Tencent-Hunyuan/HY-World-2.0", + "display_name": "混元世界模型2.0 HY-World-2.0", + }, + }, + want: ModelDescriptor{ + ID: "Tencent-Hunyuan/HY-World-2.0", + Name: "混元世界模型2.0 HY-World-2.0", + }, + wantOK: true, + }, + { + name: "nested model_info with token metadata", + raw: map[string]any{ + "model_info": map[string]any{ + "model_id": "vendor-reasoning", + "modelDescription": "reasoning model", + "input_token_limit": "262144", + "maxTokens": 16384, + }, + }, + want: ModelDescriptor{ + ID: "vendor-reasoning", + Name: "vendor-reasoning", + Description: "reasoning model", + ContextWindow: 262144, + MaxOutputTokens: 16384, + }, + wantOK: true, + }, } for _, tt := range tests { diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index e89c81f6..cc0ba48d 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -122,19 +122,21 @@ type pendingImageAttachment struct { // providerAddFormState 保存添加新 provider 表单的状态。 type providerAddFormState struct { - Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 - Name string - Driver string - BaseURL string - APIStyle string - DeploymentMode string - APIVersion string - APIKeyEnv string - APIKey string - Error string - ErrorIsHard bool - Submitting bool - Drivers []string // 可选的 Driver 列表 + Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 + Name string + Driver string + BaseURL string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string + APIKeyEnv string + APIKey string + Error string + ErrorIsHard bool + Submitting bool + Drivers []string // 可选的 Driver 列表 } type App struct { diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index bc0c4316..a46549d4 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -2187,6 +2187,8 @@ const ( providerAddFieldAPIStyle providerAddFieldDeploymentMode providerAddFieldAPIVersion + providerAddFieldDiscoveryEndpointPath + providerAddFieldDiscoveryResponseProfile providerAddFieldAPIKeyEnv providerAddFieldAPIKey ) @@ -2207,6 +2209,7 @@ func providerAddVisibleFields(driver string) []providerAddFieldID { fields = append(fields, providerAddFieldAPIVersion) } + fields = append(fields, providerAddFieldDiscoveryEndpointPath, providerAddFieldDiscoveryResponseProfile) fields = append(fields, providerAddFieldAPIKeyEnv, providerAddFieldAPIKey) return fields } @@ -2242,18 +2245,20 @@ func currentProviderAddField(form *providerAddFormState) providerAddFieldID { func (a *App) startProviderAddForm() { a.providerAddForm = &providerAddFormState{ - Step: 0, - Name: "", - Driver: provider.DriverOpenAICompat, - BaseURL: "", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DeploymentMode: "", - APIVersion: "", - APIKeyEnv: "", - APIKey: "", - Error: "", - ErrorIsHard: false, - Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, + Step: 0, + Name: "", + Driver: provider.DriverOpenAICompat, + BaseURL: "", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DeploymentMode: "", + APIVersion: "", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, + APIKeyEnv: "", + APIKey: "", + Error: "", + ErrorIsHard: false, + Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, } a.state.ActivePicker = pickerProviderAdd a.state.StatusText = "Add new provider" @@ -2296,6 +2301,10 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerAddForm.DeploymentMode = trimLastRune(a.providerAddForm.DeploymentMode) case providerAddFieldAPIVersion: a.providerAddForm.APIVersion = trimLastRune(a.providerAddForm.APIVersion) + case providerAddFieldDiscoveryEndpointPath: + a.providerAddForm.DiscoveryEndpointPath = trimLastRune(a.providerAddForm.DiscoveryEndpointPath) + case providerAddFieldDiscoveryResponseProfile: + a.providerAddForm.DiscoveryResponseProfile = trimLastRune(a.providerAddForm.DiscoveryResponseProfile) case providerAddFieldAPIKeyEnv: a.providerAddForm.APIKeyEnv = trimLastRune(a.providerAddForm.APIKeyEnv) case providerAddFieldAPIKey: @@ -2344,6 +2353,10 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerAddForm.DeploymentMode += cleanInput case providerAddFieldAPIVersion: a.providerAddForm.APIVersion += cleanInput + case providerAddFieldDiscoveryEndpointPath: + a.providerAddForm.DiscoveryEndpointPath += cleanInput + case providerAddFieldDiscoveryResponseProfile: + a.providerAddForm.DiscoveryResponseProfile += cleanInput case providerAddFieldAPIKeyEnv: a.providerAddForm.APIKeyEnv += cleanInput case providerAddFieldAPIKey: @@ -2383,14 +2396,16 @@ func (a *App) submitProviderAddForm() tea.Cmd { } type providerAddRequest struct { - Name string - Driver string - BaseURL string - APIStyle string - DeploymentMode string - APIVersion string - APIKeyEnv string - APIKey string + Name string + Driver string + BaseURL string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string + APIKeyEnv string + APIKey string } type providerAddResultMsg struct { @@ -2402,14 +2417,16 @@ type providerAddResultMsg struct { func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, string) { request := providerAddRequest{ - Name: normalizeProviderAddFieldValue(form.Name), - Driver: provider.NormalizeProviderDriver(normalizeProviderAddFieldValue(form.Driver)), - BaseURL: normalizeProviderAddFieldValue(form.BaseURL), - APIStyle: normalizeProviderAddFieldValue(form.APIStyle), - DeploymentMode: normalizeProviderAddFieldValue(form.DeploymentMode), - APIVersion: normalizeProviderAddFieldValue(form.APIVersion), - APIKeyEnv: normalizeProviderAddFieldValue(form.APIKeyEnv), - APIKey: normalizeProviderAddFieldValue(form.APIKey), + Name: normalizeProviderAddFieldValue(form.Name), + Driver: provider.NormalizeProviderDriver(normalizeProviderAddFieldValue(form.Driver)), + BaseURL: normalizeProviderAddFieldValue(form.BaseURL), + APIStyle: normalizeProviderAddFieldValue(form.APIStyle), + DeploymentMode: normalizeProviderAddFieldValue(form.DeploymentMode), + APIVersion: normalizeProviderAddFieldValue(form.APIVersion), + DiscoveryEndpointPath: normalizeProviderAddFieldValue(form.DiscoveryEndpointPath), + DiscoveryResponseProfile: normalizeProviderAddFieldValue(form.DiscoveryResponseProfile), + APIKeyEnv: normalizeProviderAddFieldValue(form.APIKeyEnv), + APIKey: normalizeProviderAddFieldValue(form.APIKey), } if request.Name == "" { @@ -2461,6 +2478,27 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str request.DeploymentMode = "" request.APIVersion = "" } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + request.Driver, + "", + "", + "", + request.DiscoveryEndpointPath, + "", + "", + request.APIStyle, + request.DiscoveryResponseProfile, + ) + if err != nil { + return providerAddRequest{}, err.Error() + } + if request.Driver == provider.DriverOpenAICompat { + request.APIStyle = normalizedProtocols.LegacyAPIStyle + } else { + request.APIStyle = "" + } + request.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + request.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile return request, "" } @@ -2526,14 +2564,16 @@ func (a *App) runProviderAddFlow(request providerAddRequest) tea.Cmd { defer cancel() selection, err := providerSvc.CreateCustomProvider(ctx, configstate.CreateCustomProviderInput{ - Name: request.Name, - Driver: request.Driver, - BaseURL: request.BaseURL, - APIStyle: request.APIStyle, - DeploymentMode: request.DeploymentMode, - APIVersion: request.APIVersion, - APIKeyEnv: request.APIKeyEnv, - APIKey: request.APIKey, + Name: request.Name, + Driver: request.Driver, + BaseURL: request.BaseURL, + APIStyle: request.APIStyle, + DeploymentMode: request.DeploymentMode, + APIVersion: request.APIVersion, + DiscoveryEndpointPath: request.DiscoveryEndpointPath, + DiscoveryResponseProfile: request.DiscoveryResponseProfile, + APIKeyEnv: request.APIKeyEnv, + APIKey: request.APIKey, }) if err != nil { if errors.Is(err, context.DeadlineExceeded) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 2b54cb4a..8152ad20 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -2372,7 +2372,7 @@ func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { t.Fatalf("expected name field append, got %q", app.providerAddForm.Name) } - app.providerAddForm.Step = 4 // api key env + app.providerAddForm.Step = 6 // 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") @@ -2518,6 +2518,12 @@ func TestBuildProviderAddRequest(t *testing.T) { if req.APIStyle != provider.OpenAICompatibleAPIStyleChatCompletions { t.Fatalf("expected default api style") } + if req.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { + t.Fatalf("expected default discovery endpoint, got %q", req.DiscoveryEndpointPath) + } + if req.DiscoveryResponseProfile != provider.DiscoveryResponseProfileOpenAI { + t.Fatalf("expected default discovery response profile, got %q", req.DiscoveryResponseProfile) + } }) t.Run("strips control chars from env key before validation", func(t *testing.T) { @@ -2564,6 +2570,30 @@ func TestBuildProviderAddRequest(t *testing.T) { } }) + t.Run("rejects invalid discovery endpoint path", func(t *testing.T) { + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + APIKey: "k", + APIKeyEnv: "OPENAI_COMPAT_API_KEY", + DiscoveryEndpointPath: "https://api.example.com/models", + }); !strings.Contains(err, "relative path") { + t.Fatalf("expected invalid discovery endpoint path error, got %q", err) + } + }) + + t.Run("rejects invalid discovery response profile", func(t *testing.T) { + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + APIKey: "k", + APIKeyEnv: "OPENAI_COMPAT_API_KEY", + DiscoveryResponseProfile: "unsupported-profile", + }); !strings.Contains(err, "unsupported") { + t.Fatalf("expected invalid discovery response profile error, got %q", err) + } + }) + t.Run("anthropic/custom require base url", func(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ Name: "anthropic", diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index f5791ca1..18cae3c4 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -234,6 +234,26 @@ func (a App) renderProviderAddForm() string { fields = append(fields, renderField{label: "Deployment Mode", value: a.providerAddForm.DeploymentMode}) case providerAddFieldAPIVersion: fields = append(fields, renderField{label: "API Version", value: a.providerAddForm.APIVersion}) + case providerAddFieldDiscoveryEndpointPath: + note := "" + if strings.TrimSpace(a.providerAddForm.DiscoveryEndpointPath) == "" { + note = "OpenAI-compatible 默认 /models" + } + fields = append(fields, renderField{ + label: "Discovery Endpoint", + value: a.providerAddForm.DiscoveryEndpointPath, + note: note, + }) + case providerAddFieldDiscoveryResponseProfile: + note := "支持 openai / gemini / generic" + if strings.TrimSpace(a.providerAddForm.DiscoveryResponseProfile) == "" { + note = "OpenAI-compatible 默认 openai" + } + fields = append(fields, renderField{ + label: "Discovery Profile", + value: a.providerAddForm.DiscoveryResponseProfile, + note: note, + }) case providerAddFieldAPIKeyEnv: fields = append(fields, renderField{label: "API Key Env", value: a.providerAddForm.APIKeyEnv, required: true}) case providerAddFieldAPIKey: From db60f83240382f7a5529e5e31b8f8f71fdd83262 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 05:36:41 +0000 Subject: [PATCH 2/6] test(app): fix unsupported driver startup assertion Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/app/bootstrap_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 29265de6..efc03ef1 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -112,11 +112,9 @@ func TestBuildRuntimeRejectsUnsupportedSelectedProviderDriverOnStartup(t *testin rawProvider := []byte( "name: company-gateway\n" + - "driver: anthropic\n" + + "driver: unsupported-driver\n" + "api_key_env: COMPANY_GATEWAY_API_KEY\n" + - "anthropic:\n" + - " base_url: https://api.anthropic.example/v1\n" + - " api_version: 2023-06-01\n", + "base_url: https://api.example.com/v1\n", ) if err := os.WriteFile(filepath.Join(providerDir, "provider.yaml"), rawProvider, 0o644); err != nil { t.Fatalf("write provider config: %v", err) From 6c54c7a35db747742a28c3af4a65dc903e9ed988 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 05:44:00 +0000 Subject: [PATCH 3/6] fix(provider): resolve unresolved discovery review issues Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/provider/discovery/http/client.go | 16 ++++++++++- .../provider/discovery/http/client_test.go | 27 +++++++++++++++++++ internal/provider/discovery/models.go | 3 --- internal/provider/discovery/models_test.go | 8 ++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/provider/discovery/http/client.go b/internal/provider/discovery/http/client.go index b0d5ef1e..3d680f93 100644 --- a/internal/provider/discovery/http/client.go +++ b/internal/provider/discovery/http/client.go @@ -14,6 +14,8 @@ import ( "neo-code/internal/provider/discovery" ) +const maxDiscoveryResponseBodyBytes int64 = 2 * 1024 * 1024 + // RequestConfig 描述通用 HTTP discovery 请求的必要输入。 type RequestConfig struct { BaseURL string @@ -72,8 +74,20 @@ func DiscoverRawModels(ctx context.Context, client *http.Client, cfg RequestConf return nil, parseHTTPError(resp) } + limitedBody := io.LimitReader(resp.Body, maxDiscoveryResponseBodyBytes+1) + rawPayload, err := io.ReadAll(limitedBody) + if err != nil { + return nil, fmt.Errorf("provider discovery: read models response: %w", err) + } + if int64(len(rawPayload)) > maxDiscoveryResponseBodyBytes { + return nil, provider.NewProviderErrorFromStatus( + http.StatusRequestEntityTooLarge, + fmt.Sprintf("provider discovery: models response body too large (limit=%d bytes)", maxDiscoveryResponseBodyBytes), + ) + } + var payload any - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + if err := json.Unmarshal(rawPayload, &payload); err != nil { return nil, fmt.Errorf("provider discovery: decode models response: %w", err) } diff --git a/internal/provider/discovery/http/client_test.go b/internal/provider/discovery/http/client_test.go index a8361588..821f7188 100644 --- a/internal/provider/discovery/http/client_test.go +++ b/internal/provider/discovery/http/client_test.go @@ -3,8 +3,10 @@ package httpdiscovery import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "neo-code/internal/provider" @@ -56,3 +58,28 @@ func TestDiscoverRawModelsRejectsInvalidEndpointPath(t *testing.T) { t.Fatalf("expected discovery config error, got %v", err) } } + +func TestDiscoverRawModelsRejectsTooLargeResponseBody(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-4.1"}],"padding":"` + strings.Repeat("x", int(maxDiscoveryResponseBodyBytes)) + `"}`)) + })) + defer server.Close() + + _, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, + }) + if err == nil { + t.Fatal("expected oversized body error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + if pErr.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("expected status 413, got %d", pErr.StatusCode) + } +} diff --git a/internal/provider/discovery/models.go b/internal/provider/discovery/models.go index 7777428d..d0bc1ee0 100644 --- a/internal/provider/discovery/models.go +++ b/internal/provider/discovery/models.go @@ -286,8 +286,5 @@ func looksLikeSingleModelObject(raw map[string]any) bool { if modelID, ok := lookupObjectValue(raw, "modelId"); ok && strings.TrimSpace(fmt.Sprint(modelID)) != "" { return true } - if name, ok := lookupObjectValue(raw, "name"); ok && strings.TrimSpace(fmt.Sprint(name)) != "" { - return true - } return false } diff --git a/internal/provider/discovery/models_test.go b/internal/provider/discovery/models_test.go index 14859427..b37a2f0a 100644 --- a/internal/provider/discovery/models_test.go +++ b/internal/provider/discovery/models_test.go @@ -86,6 +86,14 @@ func TestExtractRawModels(t *testing.T) { profile: provider.DiscoveryResponseProfileOpenAI, wantLen: 1, }, + { + name: "single object with name only should not be treated as model", + payload: map[string]any{ + "name": "not-a-model", + }, + profile: provider.DiscoveryResponseProfileOpenAI, + wantErr: true, + }, { name: "unsupported payload type", payload: map[string]any{ From abd48acf65db9306510f49ef902a50c5f025de0c Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 06:11:24 +0000 Subject: [PATCH 4/6] test(provider): increase coverage for discovery and driver protocol branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/provider/anthropic/driver_test.go | 108 ++++++++++ .../provider/discovery/http/client_test.go | 191 ++++++++++++++++++ internal/provider/gemini/driver_test.go | 64 ++++++ .../openaicompat/shared/shared_test.go | 47 +++++ internal/provider/protocols_test.go | 137 +++++++++++++ 5 files changed, 547 insertions(+) diff --git a/internal/provider/anthropic/driver_test.go b/internal/provider/anthropic/driver_test.go index f86f524d..a574128a 100644 --- a/internal/provider/anthropic/driver_test.go +++ b/internal/provider/anthropic/driver_test.go @@ -2,6 +2,10 @@ package anthropic import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "neo-code/internal/provider" @@ -22,3 +26,107 @@ func TestDriverBuildRejectsUnsupportedAnthropicMessages(t *testing.T) { t.Fatal("expected unsupported anthropic messages error") } } + +func TestDriverBuildSuccessWithOpenAICompatPath(t *testing.T) { + t.Parallel() + + driver := Driver() + p, err := driver.Build(context.Background(), provider.RuntimeConfig{ + Driver: DriverName, + BaseURL: "https://api.anthropic.com/v1", + APIKey: "test-key", + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + AuthStrategy: provider.AuthStrategyAnthropic, + }) + if err != nil { + t.Fatalf("expected build success, got %v", err) + } + if p == nil { + t.Fatal("expected non-nil provider") + } +} + +func TestDriverDiscover(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + t.Fatalf("expected /models path, got %s", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Fatalf("expected anthropic x-api-key header, got %q", got) + } + if got := r.Header.Get("anthropic-version"); got == "" { + t.Fatal("expected anthropic-version header") + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"id": "claude-3-7-sonnet", "name": "Claude 3.7 Sonnet"}, + }, + }) + })) + defer server.Close() + + driver := Driver() + models, err := driver.Discover(context.Background(), provider.RuntimeConfig{ + Driver: DriverName, + BaseURL: server.URL, + APIKey: "test-key", + DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, + ResponseProfile: provider.DiscoveryResponseProfileGeneric, + DiscoveryEndpointPath: "/models", + AuthStrategy: provider.AuthStrategyAnthropic, + }) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(models) != 1 || models[0].ID != "claude-3-7-sonnet" { + t.Fatalf("unexpected models result: %+v", models) + } +} + +func TestDriverValidateCatalogIdentity(t *testing.T) { + t.Parallel() + + driver := Driver() + + t.Run("valid identity", func(t *testing.T) { + t.Parallel() + + err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + ChatProtocol: provider.ChatProtocolAnthropicMessages, + DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, + DiscoveryEndpointPath: "/models", + AuthStrategy: provider.AuthStrategyAnthropic, + ResponseProfile: provider.DiscoveryResponseProfileGeneric, + }) + if err != nil { + t.Fatalf("expected valid identity, got %v", err) + } + }) + + t.Run("invalid auth strategy for anthropic chat protocol", func(t *testing.T) { + t.Parallel() + + err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + ChatProtocol: provider.ChatProtocolAnthropicMessages, + DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, + DiscoveryEndpointPath: "/models", + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileGeneric, + }) + if err == nil { + t.Fatal("expected discovery config error") + } + if !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error, got %v", err) + } + if !strings.Contains(err.Error(), "does not allow auth strategy") { + t.Fatalf("unexpected error message: %v", err) + } + }) +} diff --git a/internal/provider/discovery/http/client_test.go b/internal/provider/discovery/http/client_test.go index 821f7188..a6a9abd4 100644 --- a/internal/provider/discovery/http/client_test.go +++ b/internal/provider/discovery/http/client_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "io" + "net" "net/http" "net/http/httptest" "strings" @@ -12,6 +14,28 @@ import ( "neo-code/internal/provider" ) +type roundTripperFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type timeoutNetError struct { + message string +} + +func (e timeoutNetError) Error() string { + return e.message +} + +func (e timeoutNetError) Timeout() bool { + return true +} + +func (e timeoutNetError) Temporary() bool { + return true +} + func TestDiscoverRawModels(t *testing.T) { t.Parallel() @@ -83,3 +107,170 @@ func TestDiscoverRawModelsRejectsTooLargeResponseBody(t *testing.T) { t.Fatalf("expected status 413, got %d", pErr.StatusCode) } } + +func TestDiscoverRawModelsReturnsHTTPClassifiedErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + wantCode provider.ProviderErrorCode + wantPart string + }{ + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + wantCode: provider.ErrorCodeAuthFailed, + wantPart: "unauthorized (401)", + }, + { + name: "forbidden", + statusCode: http.StatusForbidden, + wantCode: provider.ErrorCodeForbidden, + wantPart: "forbidden (403)", + }, + { + name: "not found", + statusCode: http.StatusNotFound, + wantCode: provider.ErrorCodeNotFound, + wantPart: "endpoint not found (404)", + }, + { + name: "server error", + statusCode: http.StatusBadGateway, + wantCode: provider.ErrorCodeServer, + wantPart: "upstream server error", + }, + { + name: "generic client error", + statusCode: http.StatusBadRequest, + wantCode: provider.ErrorCodeClient, + wantPart: "status=400", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = io.WriteString(w, "error") + })) + defer server.Close() + + _, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + }) + if err == nil { + t.Fatal("expected provider error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + if pErr.StatusCode != tt.statusCode { + t.Fatalf("expected status %d, got %d", tt.statusCode, pErr.StatusCode) + } + if pErr.Code != tt.wantCode { + t.Fatalf("expected code %q, got %q", tt.wantCode, pErr.Code) + } + if !strings.Contains(pErr.Message, tt.wantPart) { + t.Fatalf("expected message to contain %q, got %q", tt.wantPart, pErr.Message) + } + }) + } +} + +func TestDiscoverRawModelsReturnsTransportErrors(t *testing.T) { + t.Parallel() + + t.Run("timeout via deadline exceeded", func(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + }), + } + _, err := DiscoverRawModels(context.Background(), client, RequestConfig{ + BaseURL: "https://api.example.com", + }) + assertTransportProviderError(t, err, provider.ErrorCodeTimeout, "timeout") + }) + + t.Run("timeout via net error", func(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, timeoutNetError{message: "i/o timeout"} + }), + } + _, err := DiscoverRawModels(context.Background(), client, RequestConfig{ + BaseURL: "https://api.example.com", + }) + assertTransportProviderError(t, err, provider.ErrorCodeTimeout, "i/o timeout") + }) + + t.Run("network error", func(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("refused")} + }), + } + _, err := DiscoverRawModels(context.Background(), client, RequestConfig{ + BaseURL: "https://api.example.com", + }) + assertTransportProviderError(t, err, provider.ErrorCodeNetwork, "send models request") + }) +} + +func TestDiscoverRawModelsContextAndClientValidation(t *testing.T) { + t.Parallel() + + t.Run("cancelled context", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := DiscoverRawModels(ctx, &http.Client{}, RequestConfig{BaseURL: "https://api.example.com"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + }) + + t.Run("nil client", func(t *testing.T) { + t.Parallel() + + _, err := DiscoverRawModels(context.Background(), nil, RequestConfig{BaseURL: "https://api.example.com"}) + if err == nil || err.Error() != "provider discovery: http client is nil" { + t.Fatalf("expected nil client error, got %v", err) + } + }) +} + +func assertTransportProviderError(t *testing.T, err error, wantCode provider.ProviderErrorCode, wantPart string) { + t.Helper() + + if err == nil { + t.Fatal("expected provider error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + if pErr.Code != wantCode { + t.Fatalf("expected code %q, got %q", wantCode, pErr.Code) + } + if !strings.Contains(pErr.Message, wantPart) { + t.Fatalf("expected message to contain %q, got %q", wantPart, pErr.Message) + } + if pErr.StatusCode != 0 { + t.Fatalf("expected status 0 for transport error, got %d", pErr.StatusCode) + } +} diff --git a/internal/provider/gemini/driver_test.go b/internal/provider/gemini/driver_test.go index 44553e05..194ebe2c 100644 --- a/internal/provider/gemini/driver_test.go +++ b/internal/provider/gemini/driver_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "neo-code/internal/provider" @@ -39,3 +40,66 @@ func TestDriverDiscover(t *testing.T) { t.Fatalf("expected one model, got %+v", models) } } + +func TestDriverBuild(t *testing.T) { + t.Parallel() + + driver := Driver() + p, err := driver.Build(context.Background(), provider.RuntimeConfig{ + Driver: DriverName, + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + APIKey: "test-key", + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + AuthStrategy: provider.AuthStrategyBearer, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if p == nil { + t.Fatal("expected non-nil provider") + } +} + +func TestDriverValidateCatalogIdentity(t *testing.T) { + t.Parallel() + + driver := Driver() + + t.Run("valid identity", func(t *testing.T) { + t.Parallel() + + err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, + DiscoveryEndpointPath: "/models", + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileGemini, + }) + if err != nil { + t.Fatalf("expected valid identity, got %v", err) + } + }) + + t.Run("invalid discovery protocol", func(t *testing.T) { + t.Parallel() + + err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ + Driver: DriverName, + ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, + DiscoveryProtocol: "unknown-discovery", + DiscoveryEndpointPath: "/models", + AuthStrategy: provider.AuthStrategyBearer, + ResponseProfile: provider.DiscoveryResponseProfileGemini, + }) + if err == nil { + t.Fatal("expected discovery config error") + } + if !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error, got %v", err) + } + if !strings.Contains(err.Error(), "discovery protocol") { + t.Fatalf("unexpected error message: %v", err) + } + }) +} diff --git a/internal/provider/openaicompat/shared/shared_test.go b/internal/provider/openaicompat/shared/shared_test.go index bb73fc4d..38c49aba 100644 --- a/internal/provider/openaicompat/shared/shared_test.go +++ b/internal/provider/openaicompat/shared/shared_test.go @@ -128,3 +128,50 @@ func TestSetBearerAuthorization(t *testing.T) { } }) } + +func TestApplyAuthHeaders(t *testing.T) { + t.Parallel() + + t.Run("x_api_key", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + ApplyAuthHeaders(header, provider.RuntimeConfig{ + AuthStrategy: provider.AuthStrategyXAPIKey, + APIKey: "x-key", + }) + if got := header.Get("X-API-Key"); got != "x-key" { + t.Fatalf("expected x-api-key header, got %q", got) + } + }) + + t.Run("anthropic default version", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + ApplyAuthHeaders(header, provider.RuntimeConfig{ + AuthStrategy: provider.AuthStrategyAnthropic, + APIKey: "anthropic-key", + }) + if got := header.Get("x-api-key"); got != "anthropic-key" { + t.Fatalf("expected anthropic x-api-key, got %q", got) + } + if got := header.Get("anthropic-version"); got == "" { + t.Fatal("expected default anthropic version") + } + }) + + t.Run("anthropic explicit version", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + ApplyAuthHeaders(header, provider.RuntimeConfig{ + AuthStrategy: provider.AuthStrategyAnthropic, + APIKey: "anthropic-key", + APIVersion: "2024-01-01", + }) + if got := header.Get("anthropic-version"); got != "2024-01-01" { + t.Fatalf("expected explicit anthropic version, got %q", got) + } + }) +} diff --git a/internal/provider/protocols_test.go b/internal/provider/protocols_test.go index a6cf43b6..4b2454fd 100644 --- a/internal/provider/protocols_test.go +++ b/internal/provider/protocols_test.go @@ -71,3 +71,140 @@ func TestNormalizeProviderProtocolSettingsUnknownDriverKeepsLegacyAPIStyleEmpty( t.Fatalf("expected unknown driver to keep legacy api style empty, got %q", settings.LegacyAPIStyle) } } + +func TestNormalizeProviderProtocolSettingsRejectsUnsupportedEnums(t *testing.T) { + t.Parallel() + + t.Run("unsupported chat protocol", func(t *testing.T) { + t.Parallel() + + _, err := NormalizeProviderProtocolSettings( + DriverOpenAICompat, + "unsupported-chat", + "", + DiscoveryProtocolOpenAIModels, + "", + AuthStrategyBearer, + DiscoveryResponseProfileOpenAI, + "", + "", + ) + if err == nil { + t.Fatal("expected unsupported chat protocol error") + } + }) + + t.Run("unsupported discovery protocol", func(t *testing.T) { + t.Parallel() + + _, err := NormalizeProviderProtocolSettings( + DriverOpenAICompat, + ChatProtocolOpenAIChatCompletions, + "", + "unsupported-discovery", + "", + AuthStrategyBearer, + DiscoveryResponseProfileOpenAI, + "", + "", + ) + if err == nil { + t.Fatal("expected unsupported discovery protocol error") + } + }) + + t.Run("unsupported auth strategy", func(t *testing.T) { + t.Parallel() + + _, err := NormalizeProviderProtocolSettings( + DriverOpenAICompat, + ChatProtocolOpenAIChatCompletions, + "", + DiscoveryProtocolOpenAIModels, + "", + "unsupported-auth", + DiscoveryResponseProfileOpenAI, + "", + "", + ) + if err == nil { + t.Fatal("expected unsupported auth strategy error") + } + }) +} + +func TestNormalizeProviderProtocolSettingsDefaults(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + driver string + chatProtocol string + discoveryProtocol string + wantChatEndpoint string + wantDiscoveryEndpoint string + wantAuthStrategy string + wantResponseProfile string + }{ + { + name: "openai defaults", + driver: DriverOpenAICompat, + wantChatEndpoint: "/chat/completions", + wantDiscoveryEndpoint: "/models", + wantAuthStrategy: AuthStrategyBearer, + wantResponseProfile: DiscoveryResponseProfileOpenAI, + }, + { + name: "anthropic defaults", + driver: DriverAnthropic, + wantChatEndpoint: "/messages", + wantDiscoveryEndpoint: "/models", + wantAuthStrategy: AuthStrategyAnthropic, + wantResponseProfile: DiscoveryResponseProfileGeneric, + }, + { + name: "gemini responses endpoint mapping", + driver: DriverGemini, + chatProtocol: ChatProtocolOpenAIResponses, + discoveryProtocol: DiscoveryProtocolGeminiModels, + wantChatEndpoint: "/responses", + wantDiscoveryEndpoint: "/models", + wantAuthStrategy: AuthStrategyBearer, + wantResponseProfile: DiscoveryResponseProfileGemini, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + settings, err := NormalizeProviderProtocolSettings( + tt.driver, + tt.chatProtocol, + "", + tt.discoveryProtocol, + "", + "", + "", + "", + "", + ) + if err != nil { + t.Fatalf("NormalizeProviderProtocolSettings() error = %v", err) + } + if settings.ChatEndpointPath != tt.wantChatEndpoint { + t.Fatalf("expected chat endpoint %q, got %q", tt.wantChatEndpoint, settings.ChatEndpointPath) + } + if settings.DiscoveryEndpointPath != tt.wantDiscoveryEndpoint { + t.Fatalf("expected discovery endpoint %q, got %q", tt.wantDiscoveryEndpoint, settings.DiscoveryEndpointPath) + } + if settings.AuthStrategy != tt.wantAuthStrategy { + t.Fatalf("expected auth strategy %q, got %q", tt.wantAuthStrategy, settings.AuthStrategy) + } + if settings.ResponseProfile != tt.wantResponseProfile { + t.Fatalf("expected response profile %q, got %q", tt.wantResponseProfile, settings.ResponseProfile) + } + }) + } +} From 3b83b4404386d8e447c2608c5088bdae90c3dc2b Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 07:38:05 +0000 Subject: [PATCH 5/6] fix(provider): address security and discovery error handling regressions - reject base_url userinfo and sanitize runtime base_url output - surface catalog persistence failure even with default-model fallback - include bounded sanitized upstream body in discovery HTTP errors Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/config/provider.go | 21 +++++++- internal/config/provider_test.go | 38 ++++++++++++++ internal/provider/catalog/service.go | 6 ++- internal/provider/catalog/service_test.go | 35 +++++++++++++ internal/provider/discovery/http/client.go | 52 ++++++++++++++++++- .../provider/discovery/http/client_test.go | 51 ++++++++++++++++++ internal/provider/identity.go | 3 ++ internal/provider/identity_test.go | 3 ++ 8 files changed, 205 insertions(+), 4 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index 690dd8c7..e65e143b 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "net/url" "os" "strings" @@ -235,11 +236,12 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { p.APIStyle, p.DiscoveryResponseProfile, ) + baseURL := sanitizeRuntimeBaseURL(p.BaseURL) return provider.RuntimeConfig{ Name: p.Name, Driver: p.Driver, - BaseURL: p.BaseURL, + BaseURL: baseURL, DefaultModel: p.Model, APIKey: p.APIKey, ChatProtocol: normalizedProtocols.ChatProtocol, @@ -256,6 +258,23 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { } } +// sanitizeRuntimeBaseURL 对运行时 base_url 做最小安全规整,确保不会透传 userinfo 等敏感片段。 +func sanitizeRuntimeBaseURL(raw string) string { + normalized, err := provider.NormalizeProviderBaseURL(raw) + if err == nil { + return normalized + } + + parsed, parseErr := url.Parse(strings.TrimSpace(raw)) + if parseErr != nil { + return strings.TrimSpace(raw) + } + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimSpace(parsed.String()) +} + const ( OpenAIName = "openai" OpenAIDefaultBaseURL = "https://api.openai.com/v1" diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 7a34be75..700389ee 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -227,6 +227,22 @@ func TestProviderConfigValidateRejectsInvalidDiscoverySettings(t *testing.T) { } } +func TestProviderConfigValidateRejectsBaseURLWithUserinfo(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: providerpkg.DriverOpenAICompat, + BaseURL: "https://token@api.openai.com/v1", + Model: "gpt-4.1", + APIKeyEnv: "TEST_KEY", + Source: ProviderSourceBuiltin, + } + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "must not include userinfo") { + t.Fatalf("expected base_url userinfo validation error, got %v", err) + } +} + func TestCloneProviderConfigModelDescriptorsIndependence(t *testing.T) { t.Parallel() @@ -519,3 +535,25 @@ func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) } } + +func TestResolvedProviderConfigToRuntimeConfigStripsBaseURLUserinfo(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://token@llm.example.com/v1", + Model: "server-default", + }, + APIKey: "secret-key", + } + + got := resolved.ToRuntimeConfig() + if strings.Contains(got.BaseURL, "token@") { + t.Fatalf("expected runtime base URL to strip userinfo, got %q", got.BaseURL) + } + if got.BaseURL != "https://llm.example.com/v1" { + t.Fatalf("expected sanitized runtime base URL, got %q", got.BaseURL) + } +} diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index 42622b3e..53124fca 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -17,6 +17,8 @@ const ( defaultBackgroundTimeout = 30 * time.Second ) +var errCatalogPersist = errors.New("provider catalog: persist discovered models") + type Service struct { registry *provider.Registry store Store @@ -115,7 +117,7 @@ func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogI if !catalogOK && options.allowSyncRefresh { discovered, err := s.discoverAndPersist(ctx, input) if err != nil { - if len(defaultModels) == 0 || provider.IsDiscoveryConfigError(err) { + if len(defaultModels) == 0 || provider.IsDiscoveryConfigError(err) || errors.Is(err, errCatalogPersist) { return nil, err } } else { @@ -186,7 +188,7 @@ func (s *Service) discoverAndPersist(ctx context.Context, input provider.Catalog ExpiresAt: now.Add(s.catalogTTL), Models: discovered, }); err != nil { - return nil, fmt.Errorf("provider catalog: persist discovered models: %w", err) + return nil, fmt.Errorf("%w: %v", errCatalogPersist, err) } return discovered, nil } diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 85f566e6..ebdf741c 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -501,6 +501,29 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { t.Fatalf("expected empty discovery not to be cached, got %v", loadErr) } }) + + t.Run("persist failure returns error even when default models exist", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil + }) + store := &failSaveStore{ + memoryStore: newMemoryStore(), + saveErr: errors.New("disk full"), + } + service := NewService("", registry, store) + + models, err := service.ListProviderModels(context.Background(), openAIProviderSource()) + if err == nil { + t.Fatal("expected persist failure to be returned") + } + if models != nil { + t.Fatalf("expected nil models on persist failure, got %+v", models) + } + if !errors.Is(err, errCatalogPersist) { + t.Fatalf("expected persist sentinel error, got %v", err) + } + }) } func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { @@ -719,3 +742,15 @@ func (s *memoryStore) Save(ctx context.Context, modelCatalog ModelCatalog) error } const testAPIKeyEnv = "OPENAI_API_KEY" + +type failSaveStore struct { + *memoryStore + saveErr error +} + +func (s *failSaveStore) Save(ctx context.Context, modelCatalog ModelCatalog) error { + if s.saveErr != nil { + return s.saveErr + } + return s.memoryStore.Save(ctx, modelCatalog) +} diff --git a/internal/provider/discovery/http/client.go b/internal/provider/discovery/http/client.go index 3d680f93..6782340e 100644 --- a/internal/provider/discovery/http/client.go +++ b/internal/provider/discovery/http/client.go @@ -9,12 +9,14 @@ import ( "net" "net/http" "strings" + "unicode" "neo-code/internal/provider" "neo-code/internal/provider/discovery" ) const maxDiscoveryResponseBodyBytes int64 = 2 * 1024 * 1024 +const maxHTTPErrorSummaryBytes int64 = 4 * 1024 // RequestConfig 描述通用 HTTP discovery 请求的必要输入。 type RequestConfig struct { @@ -120,8 +122,10 @@ func resolveResponseProfile(discoveryProtocol string, profile string) (string, e // parseHTTPError 将 discovery HTTP 错误映射为可分类 ProviderError。 func parseHTTPError(resp *http.Response) error { - _, _ = io.Copy(io.Discard, resp.Body) message := httpErrorMessage(resp.StatusCode) + if summary := readHTTPErrorSummary(resp.Body, maxHTTPErrorSummaryBytes); summary != "" { + message += "; upstream body: " + summary + } return provider.NewProviderErrorFromStatus(resp.StatusCode, message) } @@ -153,6 +157,52 @@ func wrapTransportError(err error) error { return provider.NewNetworkProviderError("provider discovery: send models request: " + message) } +// readHTTPErrorSummary 读取并清洗受限长度的响应体摘要,用于保留上游可观测上下文。 +func readHTTPErrorSummary(body io.Reader, limit int64) string { + if body == nil || limit <= 0 { + return "" + } + payload, err := io.ReadAll(io.LimitReader(body, limit+1)) + if err != nil { + return "" + } + truncated := int64(len(payload)) > limit + if truncated { + payload = payload[:limit] + } + summary := sanitizePrintableText(payload) + if summary == "" { + return "" + } + if truncated { + return summary + " ...(truncated)" + } + return summary +} + +// sanitizePrintableText 清洗不可打印字符并折叠空白,避免错误消息污染终端输出。 +func sanitizePrintableText(payload []byte) string { + var b strings.Builder + lastWasSpace := false + + for _, r := range string(payload) { + if r == '\n' || r == '\r' || r == '\t' || r == ' ' { + if lastWasSpace { + continue + } + b.WriteByte(' ') + lastWasSpace = true + continue + } + if !unicode.IsPrint(r) { + continue + } + b.WriteRune(r) + lastWasSpace = false + } + return strings.TrimSpace(b.String()) +} + // isTimeoutTransportError 判断网络错误是否由超时触发。 func isTimeoutTransportError(err error) bool { if err == nil { diff --git a/internal/provider/discovery/http/client_test.go b/internal/provider/discovery/http/client_test.go index a6a9abd4..64f77213 100644 --- a/internal/provider/discovery/http/client_test.go +++ b/internal/provider/discovery/http/client_test.go @@ -183,6 +183,57 @@ func TestDiscoverRawModelsReturnsHTTPClassifiedErrors(t *testing.T) { } } +func TestDiscoverRawModelsIncludesSanitizedHTTPErrorBody(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, "invalid\napi\tkey\x00") + })) + defer server.Close() + + _, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + }) + if err == nil { + t.Fatal("expected provider error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + if pErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", pErr.StatusCode) + } + if !strings.Contains(pErr.Message, "upstream body: invalid api key") { + t.Fatalf("expected sanitized upstream body summary, got %q", pErr.Message) + } +} + +func TestDiscoverRawModelsTruncatesHTTPErrorBodySummary(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, strings.Repeat("x", int(maxHTTPErrorSummaryBytes)+64)) + })) + defer server.Close() + + _, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + }) + if err == nil { + t.Fatal("expected provider error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + if !strings.Contains(pErr.Message, "...(truncated)") { + t.Fatalf("expected truncated marker in message, got %q", pErr.Message) + } +} + func TestDiscoverRawModelsReturnsTransportErrors(t *testing.T) { t.Parallel() diff --git a/internal/provider/identity.go b/internal/provider/identity.go index 45bc0b8b..755ec1c9 100644 --- a/internal/provider/identity.go +++ b/internal/provider/identity.go @@ -190,6 +190,9 @@ func NormalizeProviderBaseURL(raw string) (string, error) { if parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("provider base_url %q must include scheme and host", raw) } + if parsed.User != nil { + return "", fmt.Errorf("provider base_url %q must not include userinfo", raw) + } parsed.Scheme = strings.ToLower(parsed.Scheme) parsed.Host = strings.ToLower(parsed.Host) diff --git a/internal/provider/identity_test.go b/internal/provider/identity_test.go index 4af0f219..a8367ef1 100644 --- a/internal/provider/identity_test.go +++ b/internal/provider/identity_test.go @@ -113,6 +113,9 @@ func TestNewProviderIdentityValidatesInputs(t *testing.T) { if _, err := NewProviderIdentity("openaicompat", "not-a-url"); err == nil { t.Fatalf("expected invalid base URL to fail") } + if _, err := NewProviderIdentity("openaicompat", "https://token@api.example.com/v1"); err == nil { + t.Fatalf("expected base URL with userinfo to fail") + } } func TestNormalizeProviderIdentityAnthropicAndUnknownDriver(t *testing.T) { From ae2ebd911b0042293facb4f8aa04af16d46c468d Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sat, 18 Apr 2026 07:57:06 +0000 Subject: [PATCH 6/6] fix(provider): redact sensitive discovery error body summary Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/provider/discovery/http/client.go | 31 ++++++++++++++ .../provider/discovery/http/client_test.go | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/internal/provider/discovery/http/client.go b/internal/provider/discovery/http/client.go index 6782340e..c0a267ea 100644 --- a/internal/provider/discovery/http/client.go +++ b/internal/provider/discovery/http/client.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "regexp" "strings" "unicode" @@ -18,6 +19,13 @@ import ( const maxDiscoveryResponseBodyBytes int64 = 2 * 1024 * 1024 const maxHTTPErrorSummaryBytes int64 = 4 * 1024 +var ( + bearerTokenPattern = regexp.MustCompile(`(?i)\bbearer\s+([a-z0-9\-._~+/]+=*)`) + headerSecretPattern = regexp.MustCompile(`(?i)\b(x-?api-?key|api[_-]?key|authorization)\b\s*[:=]\s*([^\s,;]+)`) + jsonSecretPattern = regexp.MustCompile(`(?i)"(x-?api-?key|api[_-]?key|authorization)"\s*:\s*"[^"]*"`) + openAIKeyLikeIDPattern = regexp.MustCompile(`\bsk-[a-zA-Z0-9]{8,}\b`) +) + // RequestConfig 描述通用 HTTP discovery 请求的必要输入。 type RequestConfig struct { BaseURL string @@ -174,6 +182,7 @@ func readHTTPErrorSummary(body io.Reader, limit int64) string { if summary == "" { return "" } + summary = redactSensitiveSummary(summary) if truncated { return summary + " ...(truncated)" } @@ -203,6 +212,28 @@ func sanitizePrintableText(payload []byte) string { return strings.TrimSpace(b.String()) } +// redactSensitiveSummary 对错误摘要中的密钥和令牌执行脱敏,降低日志与事件链路泄漏风险。 +func redactSensitiveSummary(summary string) string { + if strings.TrimSpace(summary) == "" { + return "" + } + + redacted := bearerTokenPattern.ReplaceAllString(summary, "Bearer [REDACTED]") + redacted = headerSecretPattern.ReplaceAllString(redacted, "$1: [REDACTED]") + redacted = jsonSecretPattern.ReplaceAllStringFunc(redacted, redactJSONSecretField) + redacted = openAIKeyLikeIDPattern.ReplaceAllString(redacted, "sk-[REDACTED]") + return redacted +} + +// redactJSONSecretField 保留 JSON 字段名并清空敏感字段值,避免破坏可读性。 +func redactJSONSecretField(matched string) string { + idx := strings.Index(matched, ":") + if idx < 0 { + return matched + } + return matched[:idx+1] + ` "[REDACTED]"` +} + // isTimeoutTransportError 判断网络错误是否由超时触发。 func isTimeoutTransportError(err error) bool { if err == nil { diff --git a/internal/provider/discovery/http/client_test.go b/internal/provider/discovery/http/client_test.go index 64f77213..b01ed928 100644 --- a/internal/provider/discovery/http/client_test.go +++ b/internal/provider/discovery/http/client_test.go @@ -210,6 +210,47 @@ func TestDiscoverRawModelsIncludesSanitizedHTTPErrorBody(t *testing.T) { } } +func TestDiscoverRawModelsRedactsSensitiveHTTPErrorBody(t *testing.T) { + t.Parallel() + + const ( + bearerSecret = "sk-secret-value-123456" + apiKeySecret = "secret-api-key-value" + authSecret = "raw-auth-header-value" + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString( + w, + `authorization: Bearer `+bearerSecret+ + ` x-api-key: `+apiKeySecret+ + ` {"api_key":"`+apiKeySecret+`","authorization":"`+authSecret+`"}`, + ) + })) + defer server.Close() + + _, err := DiscoverRawModels(context.Background(), server.Client(), RequestConfig{ + BaseURL: server.URL, + }) + if err == nil { + t.Fatal("expected provider error") + } + var pErr *provider.ProviderError + if !errors.As(err, &pErr) { + t.Fatalf("expected provider error, got %T: %v", err, err) + } + + for _, secret := range []string{bearerSecret, apiKeySecret, authSecret} { + if strings.Contains(pErr.Message, secret) { + t.Fatalf("expected secret %q to be redacted, got %q", secret, pErr.Message) + } + } + if !strings.Contains(pErr.Message, "[REDACTED]") { + t.Fatalf("expected redaction marker in message, got %q", pErr.Message) + } +} + func TestDiscoverRawModelsTruncatesHTTPErrorBodySummary(t *testing.T) { t.Parallel()