diff --git a/README.md b/README.md index d175c0d5..2093fe7f 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,9 @@ go run ./cmd/neocode - **`internal/config`** — 配置管理、环境变量、YAML 加载 - **`internal/context`** — system prompt、消息裁剪与上下文构建 - **`internal/provider`** — Provider 契约、驱动注册与通用领域类型 +- **`internal/provider/openaicompat`** — OpenAI-compatible 协议入口、discovery 与 `api_style` 分流 +- **`internal/provider/openaicompat/chatcompletions`** — `/chat/completions` 请求组装、SSE 解析与 tool-call 增量处理 - **`internal/provider/catalog`** — 模型发现、catalog 缓存与后台刷新 -- **`internal/provider/selection`** — provider/model 选择与配置同步 - **`internal/provider/builtin`** — 内建 driver 注册 - **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型) - **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`) @@ -115,7 +116,7 @@ go run ./cmd/neocode │ ├── provider # Provider 契约与驱动注册 │ │ ├── builtin # 内建 driver 注册 │ │ ├── catalog # 模型发现与缓存 -│ │ └── selection # provider/model 选择服务 +│ │ └── openaicompat # OpenAI-compatible 协议入口与子协议实现 │ ├── runtime # ReAct 循环与事件流 │ ├── session # 会话模型与持久化 │ ├── tools # 工具系统 diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index d66ac018..67407a4e 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -684,7 +684,7 @@ func TestBuildRuntimeRejectsInvalidConfigFile(t *testing.T) { } _, err := BuildRuntime(context.Background(), BootstrapOptions{}) - if err == nil || !strings.Contains(err.Error(), "no longer supported") { + if err == nil || !strings.Contains(err.Error(), "workdir not found") { t.Fatalf("expected legacy config error, got %v", err) } } diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go index 88c8f0fe..5dfca5b5 100644 --- a/internal/config/builtin_providers.go +++ b/internal/config/builtin_providers.go @@ -1,5 +1,7 @@ package config +import "neo-code/internal/provider" + const ( OpenAIName = "openai" OpenAIDefaultBaseURL = "https://api.openai.com/v1" @@ -20,21 +22,17 @@ const ( QiniuDefaultBaseURL = "https://api.qnaigc.com/v1" QiniuDefaultModel = "openai/gpt-5" QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" - - defaultOpenAICompatibleAPIStyle = "chat_completions" ) -const OpenaiCompatDriver = "openaicompat" - // OpenAIProvider returns the builtin OpenAI provider definition. func OpenAIProvider() ProviderConfig { return ProviderConfig{ Name: OpenAIName, - Driver: OpenaiCompatDriver, + Driver: provider.DriverOpenAICompat, BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, - APIStyle: defaultOpenAICompatibleAPIStyle, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } @@ -43,11 +41,11 @@ func OpenAIProvider() ProviderConfig { func GeminiProvider() ProviderConfig { return ProviderConfig{ Name: GeminiName, - Driver: OpenaiCompatDriver, + Driver: provider.DriverOpenAICompat, BaseURL: GeminiDefaultBaseURL, Model: GeminiDefaultModel, APIKeyEnv: GeminiDefaultAPIKeyEnv, - APIStyle: defaultOpenAICompatibleAPIStyle, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } @@ -56,11 +54,11 @@ func GeminiProvider() ProviderConfig { func OpenLLProvider() ProviderConfig { return ProviderConfig{ Name: OpenLLName, - Driver: OpenaiCompatDriver, + Driver: provider.DriverOpenAICompat, BaseURL: OpenLLDefaultBaseURL, Model: OpenLLDefaultModel, APIKeyEnv: OpenLLDefaultAPIKeyEnv, - APIStyle: defaultOpenAICompatibleAPIStyle, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } @@ -69,11 +67,11 @@ func OpenLLProvider() ProviderConfig { func QiniuProvider() ProviderConfig { return ProviderConfig{ Name: QiniuName, - Driver: OpenaiCompatDriver, + Driver: provider.DriverOpenAICompat, BaseURL: QiniuDefaultBaseURL, Model: QiniuDefaultModel, APIKeyEnv: QiniuDefaultAPIKeyEnv, - APIStyle: defaultOpenAICompatibleAPIStyle, + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3d79682b..5f3595e7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,8 @@ import ( "strings" "sync" "testing" + + providerpkg "neo-code/internal/provider" ) const ( @@ -19,11 +21,11 @@ const ( func testDefaultProviderConfig() ProviderConfig { return ProviderConfig{ Name: testProviderName, - Driver: "openaicompat", + Driver: providerpkg.DriverOpenAICompat, BaseURL: testBaseURL, Model: testModel, APIKeyEnv: testAPIKeyEnv, - APIStyle: defaultOpenAICompatibleAPIStyle, + APIStyle: providerpkg.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } @@ -47,24 +49,17 @@ func TestParseConfigFormats(t *testing.T) { assert func(t *testing.T, cfg *Config) }{ { - name: "current format ignores persisted provider metadata", + name: "current format parses runtime settings only", data: ` selected_provider: openai current_model: gpt-5.4 shell: powershell - -provider_overrides: tools: webfetch: max_response_bytes: 4096 supported_content_types: - text/html - text/plain -providers: - - name: openai - base_url: https://example.com/v1 - model: gpt-5.4 - api_key_env: OPENAI_API_KEY `, assert: func(t *testing.T, cfg *Config) { t.Helper() @@ -97,7 +92,7 @@ current_model: gpt-4.1 default_workdir: ./from-default shell: powershell `, - err: "legacy config key \"default_workdir\" is no longer supported", + err: "field default_workdir not found", }, { name: "legacy workdir key is rejected", @@ -107,10 +102,10 @@ current_model: gpt-4.1 workdir: ./from-legacy shell: powershell `, - err: "legacy config key \"workdir\" is no longer supported", + err: "field workdir not found", }, { - name: "legacy persisted providers list keeps selection only", + name: "legacy persisted providers list is rejected", data: ` selected_provider: openai current_model: gpt-5.4 @@ -122,25 +117,10 @@ providers: model: gpt-5.4 api_key_env: OPENAI_API_KEY `, - assert: func(t *testing.T, cfg *Config) { - t.Helper() - provider, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("selected provider: %v", err) - } - if provider.BaseURL != testBaseURL { - t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL) - } - if provider.Model != testModel { - t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) - } - if cfg.CurrentModel != "gpt-5.4" { - t.Fatalf("expected selected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) - } - }, + err: `field providers not found`, }, { - name: "legacy fields are ignored", + name: "legacy unknown fields are rejected", data: ` selected_provider: openai current_model: gpt-4o @@ -155,31 +135,7 @@ providers: models: - gpt-4o `, - assert: func(t *testing.T, cfg *Config) { - t.Helper() - if cfg.MaxLoops != DefaultMaxLoops { - t.Fatalf("expected legacy max_loop to be ignored, got %d", cfg.MaxLoops) - } - provider, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("selected provider: %v", err) - } - if provider.Model != testModel { - t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) - } - if cfg.CurrentModel != "gpt-4o" { - t.Fatalf("expected current model %q, got %q", "gpt-4o", cfg.CurrentModel) - } - if strings.Contains(filepath.ToSlash(cfg.Workdir), "definitely-legacy-root") { - t.Fatalf("expected legacy workspace_root to be ignored, got %q", cfg.Workdir) - } - if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes { - t.Fatalf("expected default max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes) - } - if len(cfg.Tools.WebFetch.SupportedContentTypes) != len(DefaultWebFetchSupportedContentTypes()) { - t.Fatalf("expected default supported content types, got %+v", cfg.Tools.WebFetch.SupportedContentTypes) - } - }, + err: `field workspace_root not found`, }, } @@ -603,23 +559,11 @@ func TestProviderConfigValidateFailures(t *testing.T) { }, expectErr: "driver is empty", }, - { - name: "legacy openai driver rejected", - provider: ProviderConfig{ - Name: "custom-openai", - Driver: "openai", - BaseURL: "https://example.com/v1", - Model: "gpt-4.1", - APIKeyEnv: "CUSTOM_API_KEY", - Source: ProviderSourceCustom, - }, - expectErr: "no longer supported", - }, { name: "custom provider must not define model", provider: ProviderConfig{ Name: "custom-openai", - Driver: "openaicompat", + Driver: providerpkg.DriverOpenAICompat, BaseURL: "https://example.com/v1", Model: "gpt-4.1", APIKeyEnv: "CUSTOM_API_KEY", @@ -631,7 +575,7 @@ func TestProviderConfigValidateFailures(t *testing.T) { name: "missing base url", provider: ProviderConfig{ Name: testProviderName, - Driver: "openaicompat", + Driver: providerpkg.DriverOpenAICompat, }, expectErr: "base_url is empty", }, @@ -639,7 +583,7 @@ func TestProviderConfigValidateFailures(t *testing.T) { name: "missing model", provider: ProviderConfig{ Name: testProviderName, - Driver: "openaicompat", + Driver: providerpkg.DriverOpenAICompat, BaseURL: testBaseURL, }, expectErr: "model is empty", @@ -648,7 +592,7 @@ func TestProviderConfigValidateFailures(t *testing.T) { name: "missing api key env", provider: ProviderConfig{ Name: testProviderName, - Driver: "openaicompat", + Driver: providerpkg.DriverOpenAICompat, BaseURL: testBaseURL, Model: testModel, }, @@ -668,6 +612,21 @@ func TestProviderConfigValidateFailures(t *testing.T) { } } +func TestProviderConfigValidateAllowsStructurallyValidCustomDriver(t *testing.T) { + t.Parallel() + + err := (ProviderConfig{ + Name: "custom-openai", + Driver: "custom-driver", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_API_KEY", + Source: ProviderSourceCustom, + }).Validate() + if err != nil { + t.Fatalf("expected custom driver to pass structural validation, got %v", err) + } +} + func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { t.Setenv(testAPIKeyEnv, "lookup-key") diff --git a/internal/config/loader.go b/internal/config/loader.go index c053f930..5e240b0d 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -22,15 +22,13 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - LegacyDefaultWorkdir *string `yaml:"default_workdir,omitempty"` - LegacyWorkdir *string `yaml:"workdir,omitempty"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Context persistedContextConfig `yaml:"context,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Context persistedContextConfig `yaml:"context,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } type persistedContextConfig struct { @@ -182,16 +180,11 @@ func parseConfigWithContextDefaults(data []byte, contextDefaults ContextConfig) func parseCurrentConfig(data []byte, contextDefaults ContextConfig) (*Config, error) { var file persistedConfig - if err := yaml.Unmarshal(data, &file); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&file); err != nil { return nil, err } - if file.LegacyDefaultWorkdir != nil { - return nil, fmt.Errorf("legacy config key %q is no longer supported", "default_workdir") - } - if file.LegacyWorkdir != nil { - return nil, fmt.Errorf("legacy config key %q is no longer supported", "workdir") - } - cfg := &Config{ SelectedProvider: strings.TrimSpace(file.SelectedProvider), CurrentModel: strings.TrimSpace(file.CurrentModel), diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d9e1429e..9d2755c1 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -63,7 +63,7 @@ shell: powershell } _, err := loader.Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "legacy config key \"workdir\" is no longer supported") { + if err == nil || !strings.Contains(err.Error(), "field workdir not found") { t.Fatalf("expected legacy workdir rejection, got %v", err) } } @@ -86,7 +86,7 @@ shell: powershell } _, err := loader.Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "legacy config key \"default_workdir\" is no longer supported") { + if err == nil || !strings.Contains(err.Error(), "field default_workdir not found") { t.Fatalf("expected legacy default_workdir rejection, got %v", err) } } @@ -107,7 +107,7 @@ func TestLoaderLoadInvalidBaseDir(t *testing.T) { } } -func TestLoaderRewritesLegacyProvidersFormatOnLoad(t *testing.T) { +func TestLoaderRejectsLegacyProvidersFormatOnLoad(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -130,37 +130,9 @@ providers: t.Fatalf("write legacy config: %v", err) } - cfg, err := loader.Load(context.Background()) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - provider, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if provider.BaseURL != testBaseURL { - t.Fatalf("expected builtin provider base url %q, got %q", testBaseURL, provider.BaseURL) - } - if cfg.CurrentModel != "gpt-5.4" { - t.Fatalf("expected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) - } - - rewritten, err := os.ReadFile(loader.ConfigPath()) - if err != nil { - t.Fatalf("read rewritten config: %v", err) - } - text := string(rewritten) - if strings.Contains(text, "default_workdir:") || strings.Contains(text, "\nworkdir:") || strings.HasPrefix(text, "workdir:") { - t.Fatalf("expected rewritten config to avoid any workdir keys, got:\n%s", text) - } - if strings.Contains(text, "provider_overrides:") { - t.Fatalf("expected rewritten config to drop provider overrides, got:\n%s", text) - } - if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { - t.Fatalf("expected rewritten config to omit providers list, got:\n%s", text) - } - if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { - t.Fatalf("expected rewritten config to omit provider metadata, got:\n%s", text) + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "field providers not found") { + t.Fatalf("expected legacy providers format rejection, got %v", err) } } @@ -303,7 +275,6 @@ name: company-gateway driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY openai_compatible: - profile: generic base_url: https://llm.example.com/v1 api_style: chat_completions ` @@ -426,8 +397,8 @@ openai_compatible: } _, err := loader.Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "does not support default_model") { - t.Fatalf("expected default_model rejection, got %v", err) + if err == nil || !strings.Contains(err.Error(), "field default_model not found") { + t.Fatalf("expected unknown field rejection for default_model, got %v", err) } } @@ -541,31 +512,6 @@ openai_compatible: } } -func TestLoaderRejectsCustomProviderLegacyOpenAIDriver(t *testing.T) { - t.Parallel() - - loader := NewLoader(t.TempDir(), testDefaultConfig()) - customDir := filepath.Join(loader.BaseDir(), providersDirName, "legacy-gateway") - if err := os.MkdirAll(customDir, 0o755); err != nil { - t.Fatalf("mkdir custom provider dir: %v", err) - } - - providerYAML := ` -name: legacy-gateway -driver: openai -api_key_env: LEGACY_GATEWAY_API_KEY -base_url: https://legacy.example.com/v1 -` - if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { - t.Fatalf("write provider.yaml: %v", err) - } - - _, err := loader.Load(context.Background()) - if err == nil || !strings.Contains(err.Error(), "no longer supported") { - t.Fatalf("expected legacy driver rejection, got %v", err) - } -} - func TestLoaderUsesOnlyDriverSpecificCustomProviderFields(t *testing.T) { t.Parallel() @@ -688,7 +634,7 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { }, }, { - name: "gemini uses deployment mode only from gemini block", + name: "gemini uses base_url only from gemini block", file: customProviderFile{ Driver: "gemini", BaseURL: " https://gateway.example.com ", @@ -701,7 +647,7 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { }, }, want: customProviderSettings{ - BaseURL: "https://gateway.example.com", + BaseURL: "https://gemini.example.com", DeploymentMode: "vertex", }, }, @@ -720,9 +666,10 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { }, }, { - name: "unknown driver falls back across known blocks", + name: "unknown driver ignores protocol blocks", file: customProviderFile{ - Driver: "custom-driver", + Driver: "custom-driver", + BaseURL: " https://custom.example.com/v1 ", Gemini: customGeminiProviderFile{ BaseURL: " https://gemini.example.com/v1beta ", }, @@ -731,7 +678,7 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { }, }, want: customProviderSettings{ - BaseURL: "https://gemini.example.com/v1beta", + BaseURL: "https://custom.example.com/v1", }, }, } @@ -748,25 +695,70 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { } } -func TestResolveFallbackCustomProviderBaseURL(t *testing.T) { +func TestLoaderLoadsUnknownCustomProviderDriverUsingTopLevelBaseURL(t *testing.T) { t.Parallel() - got := resolveFallbackCustomProviderBaseURL(customProviderFile{ - OpenAICompatible: customOpenAICompatibleFile{ - BaseURL: " ", - }, - Gemini: customGeminiProviderFile{ - BaseURL: " https://gemini.example.com/v1beta ", - }, - Anthropic: customAnthropicProviderFile{ - BaseURL: "https://anthropic.example.com/v1", - }, - }) - if got != "https://gemini.example.com/v1beta" { - t.Fatalf("expected gemini fallback base URL, got %q", got) + 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) } - if got := resolveFallbackCustomProviderBaseURL(customProviderFile{}); got != "" { - t.Fatalf("expected empty fallback base URL, got %q", got) + providerYAML := ` +name: company-gateway +driver: custom-driver +base_url: https://custom.example.com/v1 +api_key_env: COMPANY_GATEWAY_API_KEY +gemini: + base_url: https://gemini.example.com/v1beta +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("expected unknown custom driver with top-level base_url to load, got %v", err) + } + + customProvider, err := cfg.ProviderByName("company-gateway") + if err != nil { + t.Fatalf("ProviderByName(company-gateway) error = %v", err) + } + if customProvider.Driver != "custom-driver" { + t.Fatalf("expected custom driver to be preserved, got %q", customProvider.Driver) + } + if customProvider.BaseURL != "https://custom.example.com/v1" { + t.Fatalf("expected top-level base_url to be used, got %q", customProvider.BaseURL) + } + if customProvider.APIStyle != "" || customProvider.DeploymentMode != "" || customProvider.APIVersion != "" { + t.Fatalf("expected protocol-specific fields to stay empty for unknown driver, got %+v", customProvider) + } +} + +func TestLoaderRejectsUnknownCustomProviderField(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 +openai_compatible: + profile: generic + base_url: https://llm.example.com/v1 +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "field profile not found") { + t.Fatalf("expected unknown field rejection, got %v", err) } } diff --git a/internal/config/model.go b/internal/config/model.go index 9c0f1643..480c854f 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -295,12 +295,9 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") } - if strings.TrimSpace(p.Driver) == "" { + if normalizeProviderDriver(p.Driver) == "" { return fmt.Errorf("provider %q driver is empty", p.Name) } - if normalizeProviderDriver(p.Driver) == "openai" { - return fmt.Errorf("provider %q driver %q is no longer supported", p.Name, p.Driver) - } if strings.TrimSpace(p.BaseURL) == "" { return fmt.Errorf("provider %q base_url is empty", p.Name) } diff --git a/internal/config/provider_adapter.go b/internal/config/provider_adapter.go index bc6b9984..89599ee3 100644 --- a/internal/config/provider_adapter.go +++ b/internal/config/provider_adapter.go @@ -25,8 +25,8 @@ func normalizeProviderDriver(driver string) string { // providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { apiStyle := cfg.APIStyle - if normalizeProviderDriver(cfg.Driver) == OpenaiCompatDriver && strings.TrimSpace(apiStyle) == "" { - apiStyle = defaultOpenAICompatibleAPIStyle + if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(apiStyle) == "" { + apiStyle = provider.OpenAICompatibleAPIStyleChatCompletions } return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ Driver: cfg.Driver, diff --git a/internal/config/provider_adapter_test.go b/internal/config/provider_adapter_test.go index b5e129db..8636e950 100644 --- a/internal/config/provider_adapter_test.go +++ b/internal/config/provider_adapter_test.go @@ -110,8 +110,12 @@ func TestNewProviderCatalogInputDefaultsOpenAICompatibleIdentityAPIStyle(t *test t.Fatalf("NewProviderCatalogInput() error = %v", err) } - if input.Identity.APIStyle != defaultOpenAICompatibleAPIStyle { - t.Fatalf("expected default api_style %q, got %+v", defaultOpenAICompatibleAPIStyle, input.Identity) + if input.Identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf( + "expected default api_style %q, got %+v", + providerpkg.OpenAICompatibleAPIStyleChatCompletions, + input.Identity, + ) } runtimeConfig, err := input.ResolveDiscoveryConfig() diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 346b6a87..f7a2b2ac 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "fmt" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "strings" "gopkg.in/yaml.v3" + "neo-code/internal/provider" ) const ( @@ -18,7 +20,6 @@ const ( type customProviderFile struct { Name string `yaml:"name"` Driver string `yaml:"driver"` - DefaultModel string `yaml:"default_model"` APIKeyEnv string `yaml:"api_key_env"` BaseURL string `yaml:"base_url,omitempty"` OpenAICompatible customOpenAICompatibleFile `yaml:"openai_compatible,omitempty"` @@ -27,7 +28,6 @@ type customProviderFile struct { } type customOpenAICompatibleFile struct { - Profile string `yaml:"profile,omitempty"` BaseURL string `yaml:"base_url"` APIStyle string `yaml:"api_style,omitempty"` } @@ -49,7 +49,7 @@ type customProviderSettings struct { APIVersion string } -// loadCustomProviders 扫描 baseDir/providers 下的一级子目录,并将其中的 provider.yaml 解析为运行时配置。 +// loadCustomProviders 扫描 baseDir/providers 下的一层子目录,并将其中的 provider.yaml 解析为运行时配置。 func loadCustomProviders(baseDir string) ([]ProviderConfig, error) { providersDir := filepath.Join(strings.TrimSpace(baseDir), providersDirName) entries, err := os.ReadDir(providersDir) @@ -99,15 +99,11 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { } var file customProviderFile - if err := yaml.Unmarshal(data, &file); err != nil { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&file); err != nil { return ProviderConfig{}, fmt.Errorf("config: parse %s: %w", providerPath, err) } - if strings.TrimSpace(file.DefaultModel) != "" { - return ProviderConfig{}, fmt.Errorf( - "config: custom provider %q does not support default_model; models must come from remote discovery", - filepath.Base(providerDir), - ) - } settings := resolveCustomProviderSettings(file) cfg := ProviderConfig{ @@ -121,8 +117,8 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { Source: ProviderSourceCustom, } - if normalizeProviderDriver(cfg.Driver) == "openaicompat" && strings.TrimSpace(cfg.APIStyle) == "" { - cfg.APIStyle = defaultOpenAICompatibleAPIStyle + if normalizeProviderDriver(cfg.Driver) == provider.DriverOpenAICompat && strings.TrimSpace(cfg.APIStyle) == "" { + cfg.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions } if err := cfg.Validate(); err != nil { @@ -133,46 +129,23 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { } // resolveCustomProviderSettings 根据 driver 只提取当前协议真正生效的配置字段,避免误吃其他协议块的值。 +// 已知 driver 仅从协议块读取 base_url;未知 driver 使用顶层 base_url 作为唯一入口。 func resolveCustomProviderSettings(file customProviderFile) customProviderSettings { - settings := customProviderSettings{ - BaseURL: strings.TrimSpace(file.BaseURL), - } + settings := customProviderSettings{} switch normalizeProviderDriver(file.Driver) { - case "openaicompat": - if settings.BaseURL == "" { - settings.BaseURL = strings.TrimSpace(file.OpenAICompatible.BaseURL) - } + case provider.DriverOpenAICompat: + settings.BaseURL = strings.TrimSpace(file.OpenAICompatible.BaseURL) settings.APIStyle = strings.TrimSpace(file.OpenAICompatible.APIStyle) - case "gemini": - if settings.BaseURL == "" { - settings.BaseURL = strings.TrimSpace(file.Gemini.BaseURL) - } + case provider.DriverGemini: + settings.BaseURL = strings.TrimSpace(file.Gemini.BaseURL) settings.DeploymentMode = strings.TrimSpace(file.Gemini.DeploymentMode) - case "anthropic": - if settings.BaseURL == "" { - settings.BaseURL = strings.TrimSpace(file.Anthropic.BaseURL) - } + case provider.DriverAnthropic: + settings.BaseURL = strings.TrimSpace(file.Anthropic.BaseURL) settings.APIVersion = strings.TrimSpace(file.Anthropic.APIVersion) default: - if settings.BaseURL == "" { - settings.BaseURL = resolveFallbackCustomProviderBaseURL(file) - } + settings.BaseURL = strings.TrimSpace(file.BaseURL) } return settings } - -// resolveFallbackCustomProviderBaseURL 为未知 driver 保留兼容兜底顺序,避免切断既有自定义接入配置。 -func resolveFallbackCustomProviderBaseURL(file customProviderFile) string { - for _, value := range []string{ - file.OpenAICompatible.BaseURL, - file.Gemini.BaseURL, - file.Anthropic.BaseURL, - } { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/internal/config/selection.go b/internal/config/selection.go index 0e65eba9..d6516dd7 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -103,8 +103,8 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri if err != nil { return ProviderSelection{}, ErrProviderNotFound } - if !s.supporters.Supports(providerCfg.Driver) { - return ProviderSelection{}, ErrDriverUnsupported + if err := s.ensureSupportedProvider(providerCfg); err != nil { + return ProviderSelection{}, err } input, err := catalogInputFromProvider(providerCfg) @@ -133,8 +133,8 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri if err != nil { return ErrProviderNotFound } - if !s.supporters.Supports(selected.Driver) { - return ErrDriverUnsupported + if err := s.ensureSupportedProvider(selected); err != nil { + return err } cfg.SelectedProvider = selected.Name @@ -163,8 +163,8 @@ func (s *SelectionService) ListModels(ctx context.Context) ([]providertypes.Mode if err != nil { return nil, err } - if !s.supporters.Supports(selected.Driver) { - return nil, ErrDriverUnsupported + if err := s.ensureSupportedProvider(selected); err != nil { + return nil, err } input, err := catalogInputFromProvider(selected) if err != nil { @@ -186,8 +186,8 @@ func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]providerty if err != nil { return nil, err } - if !s.supporters.Supports(selected.Driver) { - return nil, ErrDriverUnsupported + if err := s.ensureSupportedProvider(selected); err != nil { + return nil, err } input, err := catalogInputFromProvider(selected) if err != nil { @@ -211,8 +211,8 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) if err != nil { return ProviderSelection{}, err } - if !s.supporters.Supports(selected.Driver) { - return ProviderSelection{}, ErrDriverUnsupported + if err := s.ensureSupportedProvider(selected); err != nil { + return ProviderSelection{}, err } input, err := catalogInputFromProvider(selected) @@ -246,33 +246,8 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) return selection, nil } -// fallbackToProviderDefaultModel 在切换模型失败时回退到当前 provider 的默认模型,并持久化修正后的选择状态。 -func (s *SelectionService) fallbackToProviderDefaultModel(ctx context.Context, selected ProviderConfig, modelID string) (ProviderSelection, error) { - modelID = strings.TrimSpace(modelID) - if modelID == "" { - return ProviderSelection{}, ErrModelNotFound - } - - var selection ProviderSelection - err := s.manager.Update(ctx, func(cfg *Config) error { - currentSelected, err := cfg.SelectedProviderConfig() - if err != nil { - return err - } - if normalizeProviderName(currentSelected.Name) != normalizeProviderName(selected.Name) { - return ErrProviderNotFound - } - cfg.CurrentModel = modelID - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - return selection, nil -} - // EnsureSelection 确保当前 provider 和 model 仍然有效,必要时自动修正。 +// EnsureSelection 统一修正当前选择,使 selected_provider/current_model 始终落在当前可用目录内。 func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelection, error) { if err := s.validate(); err != nil { return ProviderSelection{}, err @@ -286,13 +261,8 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti if err != nil { return ProviderSelection{}, err } - if !s.supporters.Supports(selected.Driver) { - return ProviderSelection{}, fmt.Errorf( - "selection: provider %q driver %q: %w", - selected.Name, - selected.Driver, - ErrDriverUnsupported, - ) + if err := s.ensureSupportedProvider(selected); err != nil { + return ProviderSelection{}, err } input, err := catalogInputFromProvider(selected) @@ -367,6 +337,19 @@ func (s *SelectionService) selectedProviderConfig() (ProviderConfig, error) { return cfg.SelectedProviderConfig() } +// ensureSupportedProvider 统一校验当前运行时是否支持指定 provider,确保各选择入口返回一致的类型化错误。 +func (s *SelectionService) ensureSupportedProvider(cfg ProviderConfig) error { + if s.supporters.Supports(cfg.Driver) { + return nil + } + return fmt.Errorf( + "selection: provider %q driver %q: %w", + cfg.Name, + cfg.Driver, + ErrDriverUnsupported, + ) +} + func selectionFromConfig(cfg Config) ProviderSelection { return ProviderSelection{ ProviderID: cfg.SelectedProvider, diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index 9dc55f59..418711b2 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -7,6 +7,8 @@ import ( "testing" "neo-code/internal/provider" + "neo-code/internal/provider/catalog" + "neo-code/internal/provider/openaicompat" providertypes "neo-code/internal/provider/types" ) @@ -45,6 +47,46 @@ func TestSelectionServiceListProvidersUsesCatalogModels(t *testing.T) { } } +func TestSelectionServiceBuiltinUnsupportedAPIStyleFailsAcrossSnapshotPaths(t *testing.T) { + t.Parallel() + + providerCfg := OpenAIProvider() + providerCfg.APIStyle = "responses" + + defaults := DefaultConfig() + defaults.Providers = []ProviderConfig{providerCfg} + defaults.SelectedProvider = providerCfg.Name + defaults.CurrentModel = providerCfg.Model + + manager := newSelectionTestManager(t, defaults) + registry := provider.NewRegistry() + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) + } + service := NewSelectionService(manager, newDriverSupporterStub(), catalog.NewService("", registry, nil)) + + if _, err := service.ListProviders(context.Background()); !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected ListProviders() to surface discovery config error, 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.EnsureSelection(context.Background()); !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected EnsureSelection() to surface discovery config error, got %v", err) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.SelectedProvider != providerCfg.Name { + t.Fatalf("expected selected provider to stay on %q, got %q", providerCfg.Name, reloaded.SelectedProvider) + } + if reloaded.CurrentModel != providerCfg.Model { + t.Fatalf("expected current model to stay on %q, got %q", providerCfg.Model, reloaded.CurrentModel) + } +} + func TestSelectionServiceListModelsUsesCurrentSelectedProvider(t *testing.T) { t.Parallel() @@ -99,26 +141,17 @@ func TestSelectionServiceListModelsSnapshotUsesSnapshotCatalog(t *testing.T) { func TestSelectionServiceListModelsSnapshotRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - defaults := DefaultConfig() - defaults.Providers = []ProviderConfig{{ - Name: OpenAIName, - Driver: "missing-driver", - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - Source: ProviderSourceBuiltin, - }} - defaults.SelectedProvider = OpenAIName - defaults.CurrentModel = OpenAIDefaultModel - - manager := newSelectionTestManager(t, defaults) - supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + manager := newSelectionTestManager(t, DefaultConfig()) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} service := NewSelectionService(manager, supporters, newCatalogStub()) _, err := service.ListModelsSnapshot(context.Background()) if !errors.Is(err, ErrDriverUnsupported) { t.Fatalf("expected ErrDriverUnsupported, got %v", err) } + if !strings.Contains(err.Error(), `provider "openai" driver "openaicompat"`) { + t.Fatalf("expected contextual unsupported-driver error, got %v", err) + } } func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { @@ -173,20 +206,8 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { func TestSelectionServiceSetCurrentModelRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - defaults := DefaultConfig() - defaults.Providers = []ProviderConfig{{ - Name: OpenAIName, - Driver: "missing-driver", - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - Source: ProviderSourceBuiltin, - }} - defaults.SelectedProvider = OpenAIName - defaults.CurrentModel = OpenAIDefaultModel - - manager := newSelectionTestManager(t, defaults) - supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + manager := newSelectionTestManager(t, DefaultConfig()) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} service := NewSelectionService(manager, supporters, newCatalogStub()) _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) @@ -533,24 +554,14 @@ func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t func TestSelectionServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - defaults := DefaultConfig() - defaults.Providers = []ProviderConfig{{ - Name: OpenAIName, - Driver: "missing-driver", - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - Source: ProviderSourceBuiltin, - }} - defaults.SelectedProvider = OpenAIName - defaults.CurrentModel = OpenAIDefaultModel - - manager := newSelectionTestManager(t, defaults) - supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + manager := newSelectionTestManager(t, DefaultConfig()) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} service := NewSelectionService(manager, supporters, newCatalogStub()) if _, err := service.SelectProvider(context.Background(), OpenAIName); !errors.Is(err, ErrDriverUnsupported) { t.Fatalf("expected SelectProvider() to preserve driver error, got %v", err) + } else if !strings.Contains(err.Error(), `provider "openai" driver "openaicompat"`) { + t.Fatalf("expected contextual unsupported-driver error, got %v", err) } } diff --git a/internal/provider/catalog/additional_test.go b/internal/provider/catalog/additional_test.go new file mode 100644 index 00000000..ffdd813b --- /dev/null +++ b/internal/provider/catalog/additional_test.go @@ -0,0 +1,260 @@ +package catalog + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat" + providertypes "neo-code/internal/provider/types" +) + +func TestNewServiceCreatesJSONStoreWhenBaseDirProvided(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + service := NewService(baseDir, provider.NewRegistry(), nil) + store, ok := service.store.(*jsonStore) + if !ok { + t.Fatalf("expected jsonStore, got %T", service.store) + } + if store.dir != filepath.Join(baseDir, "cache", "models") { + t.Fatalf("unexpected store dir: %s", store.dir) + } +} + +func TestLoadCatalogWithoutStoreReturnsNotFound(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), nil) + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + _, err = service.loadCatalog(context.Background(), identity) + if !errors.Is(err, ErrCatalogNotFound) { + t.Fatalf("expected ErrCatalogNotFound, got %v", err) + } +} + +func TestDiscoverAndPersistRejectsNilResolver(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + service := NewService("", newRegistry(t, openaicompat.DriverName, func(context.Context, provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil + }), newMemoryStore()) + + input := openAIProviderSource() + input.ResolveDiscoveryConfig = nil + + models, err := service.discoverAndPersist(context.Background(), input) + if err == nil || models != nil || !strings.Contains(err.Error(), "discovery config resolver is nil") { + t.Fatalf("expected nil resolver error, got models=%+v err=%v", models, err) + } +} + +func TestQueueRefreshSkipsIncompleteIdentity(t *testing.T) { + t.Parallel() + + var discoverCalls int32 + registry := newRegistry(t, openaicompat.DriverName, func(context.Context, provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + return nil, nil + }) + service := NewService("", registry, newMemoryStore()) + service.backgroundTimeout = 50 * time.Millisecond + + service.queueRefresh(provider.CatalogInput{ + Identity: provider.ProviderIdentity{ + Driver: openaicompat.DriverName, + }, + }) + + time.Sleep(100 * time.Millisecond) + if atomic.LoadInt32(&discoverCalls) != 0 { + t.Fatalf("expected no background discovery, got %d calls", discoverCalls) + } + if len(service.inFlightByID) != 0 { + t.Fatalf("expected no in-flight markers, got %+v", service.inFlightByID) + } +} + +func TestJSONStoreAdditionalFilesystemErrors(t *testing.T) { + t.Parallel() + + t.Run("load read failure", func(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + if err := os.MkdirAll(store.catalogPath(identity), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + _, err = store.Load(context.Background(), identity) + if err == nil || !strings.Contains(err.Error(), "read model catalog") { + t.Fatalf("expected read model catalog error, got %v", err) + } + }) + + t.Run("save write failure", func(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + blocker := filepath.Join(baseDir, "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + store := &jsonStore{dir: blocker} + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + err = store.Save(context.Background(), ModelCatalog{Identity: identity}) + if err == nil || !strings.Contains(err.Error(), "write model catalog") { + t.Fatalf("expected write model catalog error, got %v", err) + } + }) +} + +func TestCatalogSnapshotOnMissingCatalog(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), newMemoryStore()) + input, err := config.NewProviderCatalogInput(config.OpenAIProvider()) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + snapshot := service.catalogSnapshot(context.Background(), input) + if snapshot.ok || snapshot.expired || len(snapshot.models) != 0 { + t.Fatalf("expected empty snapshot on cache miss, got %+v", snapshot) + } +} + +func TestListProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := provider.NewRegistry() + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) + } + + service := NewService("", registry, newMemoryStore()) + cfg := customGatewayProvider() + cfg.APIStyle = "responses" + + input, err := config.NewProviderCatalogInput(cfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() 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) + } +} + +func TestListBuiltinProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := provider.NewRegistry() + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) + } + + service := NewService("", registry, newMemoryStore()) + cfg := config.OpenAIProvider() + cfg.APIStyle = "responses" + + input, err := config.NewProviderCatalogInput(cfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() 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) + } +} + +func TestListBuiltinProviderModelsSnapshotRejectsUnsupportedOpenAICompatibleAPIStyleBeforeFallback(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := provider.NewRegistry() + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) + } + + service := NewService("", registry, newMemoryStore()) + cfg := config.OpenAIProvider() + cfg.APIStyle = "responses" + + input, err := config.NewProviderCatalogInput(cfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() 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 !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { + t.Fatalf("unexpected snapshot error: %v", err) + } +} + +func TestListBuiltinProviderModelsSnapshotAndCachedRejectUnsupportedAPIStyleWithCachedCatalog(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := provider.NewRegistry() + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) + } + + store := newMemoryStore() + service := NewService("", registry, store) + cfg := config.OpenAIProvider() + cfg.APIStyle = "responses" + + input, err := config.NewProviderCatalogInput(cfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + 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) + } + + tests := []struct { + name string + call func(context.Context, provider.CatalogInput) ([]providertypes.ModelDescriptor, error) + }{ + {name: "snapshot", call: service.ListProviderModelsSnapshot}, + {name: "cached", call: service.ListProviderModelsCached}, + } + + 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 !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { + t.Fatalf("unexpected cached %s error: %v", tt.name, err) + } + }) + } +} diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index b9cf4593..5df9bcdf 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -90,13 +90,16 @@ type queryOptions struct { } type catalogSnapshot struct { - models []providertypes.ModelDescriptor - ok bool - expired bool - identity provider.ProviderIdentity + models []providertypes.ModelDescriptor + ok bool + expired bool } func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogInput, options queryOptions) ([]providertypes.ModelDescriptor, error) { + if err := s.registry.ValidateCatalogIdentity(input.Identity); err != nil { + return nil, err + } + configuredModels := providertypes.MergeModelDescriptors(input.ConfiguredModels) defaultModels := providertypes.MergeModelDescriptors(input.DefaultModels) snapshot := s.catalogSnapshot(ctx, input) @@ -107,7 +110,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 { + if len(defaultModels) == 0 || provider.IsDiscoveryConfigError(err) { return nil, err } } else { @@ -117,33 +120,22 @@ func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogI } } - if options.queueRefresh && snapshot.expired { - s.queueRefresh(input) - } - if options.queueRefresh && !snapshot.ok && !performedSyncRefresh { + if shouldQueueRefresh(options, snapshot, performedSyncRefresh) { s.queueRefresh(input) } - if !catalogOK { - if len(defaultModels) == 0 { - return nil, nil - } - return providertypes.MergeModelDescriptors(configuredModels, defaultModels), nil - } - return providertypes.MergeModelDescriptors(configuredModels, models, defaultModels), nil + return mergeResolvedModels(catalogOK, configuredModels, models, defaultModels), nil } func (s *Service) catalogSnapshot(ctx context.Context, input provider.CatalogInput) catalogSnapshot { - identity := input.Identity - modelCatalog, err := s.loadCatalog(ctx, identity) + modelCatalog, err := s.loadCatalog(ctx, input.Identity) if err != nil { - return catalogSnapshot{identity: identity} + return catalogSnapshot{} } return catalogSnapshot{ - models: modelCatalog.Models, - ok: true, - expired: modelCatalog.Expired(s.now()), - identity: identity, + models: modelCatalog.Models, + ok: true, + expired: modelCatalog.Expired(s.now()), } } @@ -155,15 +147,11 @@ func (s *Service) loadCatalog(ctx context.Context, identity provider.ProviderIde } func (s *Service) discoverAndPersist(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { - driverType := strings.TrimSpace(input.Identity.Driver) - if !s.registry.Supports(driverType) { - return nil, nil - } - caps, err := s.registry.DriverTransportCapabilities(driverType) + supported, err := s.supportsModelDiscovery(input.Identity.Driver) if err != nil { return nil, err } - if !caps.ModelDiscovery { + if !supported { return nil, nil } @@ -198,12 +186,12 @@ func (s *Service) discoverAndPersist(ctx context.Context, input provider.Catalog } func (s *Service) queueRefresh(input provider.CatalogInput) { - driverType := strings.TrimSpace(input.Identity.Driver) - if s.store == nil || !s.registry.Supports(driverType) { + if s.store == nil { return } - caps, err := s.registry.DriverTransportCapabilities(driverType) - if err != nil || !caps.ModelDiscovery { + + supported, err := s.supportsModelDiscovery(input.Identity.Driver) + if err != nil || !supported { return } identity := input.Identity @@ -232,3 +220,39 @@ func (s *Service) queueRefresh(input provider.CatalogInput) { _, _ = s.discoverAndPersist(ctx, input) }() } + +func (s *Service) supportsModelDiscovery(driverType string) (bool, error) { + driverType = strings.TrimSpace(driverType) + if !s.registry.Supports(driverType) { + return false, nil + } + + caps, err := s.registry.DriverTransportCapabilities(driverType) + if err != nil { + return false, err + } + + return caps.ModelDiscovery, nil +} + +func shouldQueueRefresh(options queryOptions, snapshot catalogSnapshot, performedSyncRefresh bool) bool { + if !options.queueRefresh { + return false + } + if snapshot.expired { + return true + } + return !snapshot.ok && !performedSyncRefresh +} + +func mergeResolvedModels( + catalogOK bool, + configuredModels []providertypes.ModelDescriptor, + discoveredModels []providertypes.ModelDescriptor, + defaultModels []providertypes.ModelDescriptor, +) []providertypes.ModelDescriptor { + if !catalogOK { + return providertypes.MergeModelDescriptors(configuredModels, defaultModels) + } + return providertypes.MergeModelDescriptors(configuredModels, discoveredModels, defaultModels) +} diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 6691a4be..91d06765 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -33,7 +33,7 @@ func TestNewService(t *testing.T) { func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) { t.Parallel() - service := NewService("", provider.NewRegistry(), newMemoryStore()) + service := NewService("", newRegistry(t, openaicompat.DriverName, nil), newMemoryStore()) models, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) @@ -44,9 +44,9 @@ func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) } func TestListProviderModelsCustomProviderDoesNotFallbackWithoutDiscovery(t *testing.T) { - t.Parallel() + t.Setenv(testAPIKeyEnv, "test-key") - service := NewService("", provider.NewRegistry(), newMemoryStore()) + service := NewService("", newRegistry(t, openaicompat.DriverName, nil), newMemoryStore()) models, err := service.ListProviderModels(context.Background(), customGatewayProviderSource()) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index 83de59b3..e4a49d7c 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -17,7 +17,7 @@ func TestJSONStoreRoundTrip(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -128,7 +128,7 @@ func TestJSONStoreMissingCatalog(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -144,7 +144,7 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { baseDir := t.TempDir() store := newJSONStore(baseDir) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -219,7 +219,7 @@ func TestJSONStoreLoadHonorsContextError(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -238,7 +238,7 @@ func TestJSONStoreLoadRejectsInvalidIdentity(t *testing.T) { store := newJSONStore(t.TempDir()) _, err := store.Load(context.Background(), provider.ProviderIdentity{ - Driver: "openai", + Driver: "openaicompat", BaseURL: "://bad", }) if err == nil || !strings.Contains(err.Error(), "normalize model catalog key") { @@ -250,7 +250,7 @@ func TestJSONStoreLoadRejectsInvalidJSON(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -272,7 +272,7 @@ func TestJSONStoreSaveHonorsContextError(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openaicompat", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } diff --git a/internal/provider/constants.go b/internal/provider/constants.go new file mode 100644 index 00000000..404c105d --- /dev/null +++ b/internal/provider/constants.go @@ -0,0 +1,11 @@ +package provider + +// Driver 与 OpenAI-compatible 协议常量用于在 config/provider 间共享稳定枚举值,避免字面量漂移。 +const ( + DriverOpenAICompat = "openaicompat" + DriverGemini = "gemini" + DriverAnthropic = "anthropic" + + OpenAICompatibleAPIStyleChatCompletions = "chat_completions" + OpenAICompatibleAPIStyleResponses = "responses" +) diff --git a/internal/provider/errors.go b/internal/provider/errors.go index 8107f190..b7e25b82 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -11,6 +11,7 @@ import ( var ( ErrDriverNotFound = errors.New("provider driver not found") ErrDriverAlreadyRegistered = errors.New("provider: driver already registered") + ErrDiscoveryConfig = errors.New("provider: discovery config invalid") // 流级哨兵错误,用于区分可恢复/不可恢复的流中断原因。 ErrStreamInterrupted = errors.New("provider: stream interrupted") @@ -71,6 +72,21 @@ func (e *ProviderError) Error() string { return fmt.Sprintf("provider error (code=%s): %s", e.Code, e.Message) } +func NewDiscoveryConfigError(message string) error { + message = strings.TrimSpace(message) + if message == "" { + return ErrDiscoveryConfig + } + return fmt.Errorf("%w: %s", ErrDiscoveryConfig, message) +} + +func IsDiscoveryConfigError(err error) bool { + if err == nil { + return false + } + return errors.Is(err, ErrDiscoveryConfig) +} + // IsRetryableStatus 根据通用 HTTP 语义判断状态码是否可重试。 // 429(限流)和 5xx(服务端错误)视为可重试。 func IsRetryableStatus(statusCode int) bool { diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index b5843755..e13a01d3 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -197,6 +197,27 @@ func TestProviderError_As(t *testing.T) { } } +func TestDiscoveryConfigError(t *testing.T) { + t.Parallel() + + err := NewDiscoveryConfigError("unsupported api_style") + if err == nil || !strings.Contains(err.Error(), "unsupported api_style") { + t.Fatalf("unexpected discovery config error: %v", err) + } + if !errors.Is(err, ErrDiscoveryConfig) { + t.Fatalf("expected errors.Is to match ErrDiscoveryConfig") + } + if !IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config classification") + } + if !IsDiscoveryConfigError(fmt.Errorf("wrapped: %w", err)) { + t.Fatalf("expected wrapped discovery config classification") + } + if IsDiscoveryConfigError(errors.New("other")) { + t.Fatalf("expected other errors to stay false") + } +} + func TestIsContextTooLong(t *testing.T) { t.Parallel() diff --git a/internal/provider/identity.go b/internal/provider/identity.go index 838fb21e..4ebc14bc 100644 --- a/internal/provider/identity.go +++ b/internal/provider/identity.go @@ -112,8 +112,12 @@ func NewProviderIdentity(driver string, baseURL string) (ProviderIdentity, error // NormalizeProviderIdentity 根据 driver 规则规范化连接身份,保留参与缓存去重的专用字段。 func NormalizeProviderIdentity(identity ProviderIdentity) (ProviderIdentity, error) { normalizedDriver := NormalizeProviderDriver(identity.Driver) + if normalizedDriver == "" { + return ProviderIdentity{}, fmt.Errorf("provider driver is empty") + } + switch normalizedDriver { - case "openaicompat": + case DriverOpenAICompat: baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) if err != nil { return ProviderIdentity{}, err @@ -123,7 +127,7 @@ func NormalizeProviderIdentity(identity ProviderIdentity) (ProviderIdentity, err BaseURL: baseURL, APIStyle: NormalizeProviderAPIStyle(identity.APIStyle), }, nil - case "gemini": + case DriverGemini: baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) if err != nil { return ProviderIdentity{}, err @@ -133,7 +137,7 @@ func NormalizeProviderIdentity(identity ProviderIdentity) (ProviderIdentity, err BaseURL: baseURL, DeploymentMode: NormalizeProviderDeploymentMode(identity.DeploymentMode), }, nil - case "anthropic": + case DriverAnthropic: baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) if err != nil { return ProviderIdentity{}, err diff --git a/internal/provider/identity_test.go b/internal/provider/identity_test.go index 26639d2d..2fad0219 100644 --- a/internal/provider/identity_test.go +++ b/internal/provider/identity_test.go @@ -96,7 +96,7 @@ func TestNewProviderIdentityValidatesInputs(t *testing.T) { } } -func TestNormalizeProviderIdentityAnthropicAndFallback(t *testing.T) { +func TestNormalizeProviderIdentityAnthropicAndUnknownDriver(t *testing.T) { t.Parallel() anthropicIdentity, err := NormalizeProviderIdentity(ProviderIdentity{ diff --git a/internal/provider/openaicompat/events.go b/internal/provider/openaicompat/chatcompletions/events.go similarity index 61% rename from internal/provider/openaicompat/events.go rename to internal/provider/openaicompat/chatcompletions/events.go index 6b15f2da..7e1f70aa 100644 --- a/internal/provider/openaicompat/events.go +++ b/internal/provider/openaicompat/chatcompletions/events.go @@ -1,4 +1,4 @@ -package openaicompat +package chatcompletions import ( "context" @@ -6,33 +6,33 @@ import ( providertypes "neo-code/internal/provider/types" ) -// emitTextDelta 发送文本增量事件,空文本时跳过。 -func emitTextDelta(ctx context.Context, events chan<- providertypes.StreamEvent, text string) error { +// EmitTextDelta 发送文本增量事件,空文本时跳过。 +func EmitTextDelta(ctx context.Context, events chan<- providertypes.StreamEvent, text string) error { if text == "" { return nil } return emitStreamEvent(ctx, events, providertypes.NewTextDeltaStreamEvent(text)) } -// emitToolCallStart 发送工具调用开始事件,空名称时跳过。 -func emitToolCallStart(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, name string) error { +// EmitToolCallStart 发送工具调用开始事件,空名称时跳过。 +func EmitToolCallStart(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, name string) error { if name == "" { return nil } return emitStreamEvent(ctx, events, providertypes.NewToolCallStartStreamEvent(index, id, name)) } -// emitToolCallDelta 发送工具调用参数增量事件。 -// id 为工具调用 ID,由上游 mergeToolCallDelta 从累积状态中传入。 -func emitToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, argumentsDelta string) error { +// EmitToolCallDelta 发送工具调用参数增量事件。 +// id 为工具调用 ID,由上游 MergeToolCallDelta 从累积状态中传入。 +func EmitToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, argumentsDelta string) error { if argumentsDelta == "" { return nil } return emitStreamEvent(ctx, events, providertypes.NewToolCallDeltaStreamEvent(index, id, argumentsDelta)) } -// emitMessageDone 发送消息完成事件。 -func emitMessageDone(ctx context.Context, events chan<- providertypes.StreamEvent, finishReason string, usage *providertypes.Usage) error { +// EmitMessageDone 发送消息完成事件。 +func EmitMessageDone(ctx context.Context, events chan<- providertypes.StreamEvent, finishReason string, usage *providertypes.Usage) error { event := providertypes.NewMessageDoneStreamEvent(finishReason, usage) if ctx == nil || ctx.Err() == nil { return emitStreamEvent(ctx, events, event) @@ -49,11 +49,25 @@ func emitMessageDone(ctx context.Context, events chan<- providertypes.StreamEven } } +// FlushDataLines 逐行处理缓冲的 data lines,每行作为独立 payload 交给 processChunk。 +// OpenAI 的 SSE 实际按单行 JSON 发送,因此逐行处理更可靠。 +func FlushDataLines(dataLines []string, processChunk func(string) error) error { + for _, line := range dataLines { + if err := processChunk(line); err != nil { + return err + } + } + return nil +} + // emitStreamEvent 通过 channel 安全发送流式事件,支持上下文取消和 nil channel 保护。 func emitStreamEvent(ctx context.Context, events chan<- providertypes.StreamEvent, event providertypes.StreamEvent) error { if events == nil { return nil } + if ctx == nil { + ctx = context.Background() + } select { case events <- event: @@ -62,15 +76,3 @@ func emitStreamEvent(ctx context.Context, events chan<- providertypes.StreamEven return ctx.Err() } } - -// flushDataLines 逐行处理缓冲的 data lines,每行作为独立 payload 通过 processChunk 处理。 -// SSE 规范允许同一事件内多行 data 拼接,但 OpenAI 实际行为是每行 data 为独立 JSON, -// 因此逐行处理更可靠,避免拼接产生无效 JSON。 -func flushDataLines(dataLines []string, processChunk func(string) error) error { - for _, line := range dataLines { - if err := processChunk(line); err != nil { - return err - } - } - return nil -} diff --git a/internal/provider/openaicompat/chatcompletions/events_internal_test.go b/internal/provider/openaicompat/chatcompletions/events_internal_test.go new file mode 100644 index 00000000..9a8e3860 --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/events_internal_test.go @@ -0,0 +1,66 @@ +package chatcompletions + +import ( + "context" + "errors" + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestEmitStreamEvent_NilEventsChannel(t *testing.T) { + t.Parallel() + + event := providertypes.NewTextDeltaStreamEvent("test") + if err := emitStreamEvent(context.Background(), nil, event); err != nil { + t.Fatalf("expected nil channel to return nil, got %v", err) + } +} + +func TestEmitStreamEvent_NormalSend(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 1) + event := providertypes.NewTextDeltaStreamEvent("hello") + if err := emitStreamEvent(context.Background(), events, event); err != nil { + t.Fatalf("emitStreamEvent() error = %v", err) + } + + got := <-events + if got.Type != providertypes.StreamEventTextDelta { + t.Fatalf("unexpected event type: %s", got.Type) + } +} + +func TestEmitStreamEvent_NilContext(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 1) + event := providertypes.NewTextDeltaStreamEvent("hello") + if err := emitStreamEvent(nil, events, event); err != nil { + t.Fatalf("emitStreamEvent() with nil context error = %v", err) + } + + got := <-events + if got.Type != providertypes.StreamEventTextDelta { + t.Fatalf("unexpected event type: %s", got.Type) + } +} + +func TestEmitStreamEvent_ContextCancelled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + events := make(chan providertypes.StreamEvent) + event := providertypes.NewTextDeltaStreamEvent("test") + + err := emitStreamEvent(ctx, events, event) + if err == nil { + t.Fatal("expected context cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} diff --git a/internal/provider/openaicompat/chatcompletions/provider.go b/internal/provider/openaicompat/chatcompletions/provider.go new file mode 100644 index 00000000..de9ea143 --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/provider.go @@ -0,0 +1,75 @@ +package chatcompletions + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/shared" + providertypes "neo-code/internal/provider/types" +) + +// Provider 封装 Chat Completions 端点的请求组装、发送与流式响应解析。 +type Provider struct { + cfg provider.RuntimeConfig + client *http.Client +} + +// New 基于共享运行时配置与 HTTP client 创建 Chat Completions provider。 +func New(cfg provider.RuntimeConfig, client *http.Client) (*Provider, error) { + if err := shared.ValidateRuntimeConfig(cfg); err != nil { + return nil, err + } + if client == nil { + return nil, fmt.Errorf("%sclient is nil", shared.ErrorPrefix) + } + + return &Provider{ + cfg: cfg, + client: client, + }, nil +} + +// Generate 发起 SSE 流式生成请求。 +// 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 +func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + payload, err := BuildRequest(p.cfg, req) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("%smarshal request: %w", shared.ErrorPrefix, err) + } + + endpoint := shared.Endpoint(p.cfg.BaseURL, "/chat/completions") + 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) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := p.client.Do(httpReq) + if err != nil { + return fmt.Errorf("%ssend request: %w", shared.ErrorPrefix, err) + } + defer func(body io.ReadCloser) { + if closeErr := body.Close(); closeErr != nil { + log.Printf("%sclose response body: %v", shared.ErrorPrefix, closeErr) + } + }(resp.Body) + + if resp.StatusCode >= http.StatusBadRequest { + return ParseError(resp) + } + + return p.ConsumeStream(ctx, resp.Body, events) +} diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go new file mode 100644 index 00000000..36ca4a79 --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -0,0 +1,643 @@ +package chatcompletions + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +func TestNewAndBuildRequest(t *testing.T) { + t.Parallel() + + t.Run("new validation", func(t *testing.T) { + t.Parallel() + + if _, err := New(testCfg("", "gpt-4.1", "test-key"), &http.Client{}); err == nil || !strings.Contains(err.Error(), "base url is empty") { + t.Fatalf("expected base url error, got %v", err) + } + if _, err := New(testCfg("https://api.example.com/v1", "gpt-4.1", ""), &http.Client{}); err == nil || !strings.Contains(err.Error(), "api key is empty") { + t.Fatalf("expected api key error, got %v", err) + } + if _, err := New(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), nil); err == nil || !strings.Contains(err.Error(), "client is nil") { + t.Fatalf("expected nil client error, got %v", err) + } + }) + + t.Run("build request variants", func(t *testing.T) { + t.Parallel() + + if _, err := BuildRequest(testCfg("https://api.example.com/v1", "", "test-key"), providertypes.GenerateRequest{}); err == nil || !strings.Contains(err.Error(), "model is empty") { + t.Fatalf("expected model error, got %v", err) + } + + payload, err := BuildRequest(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{ + Model: "gpt-5.4", + SystemPrompt: "system", + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call_1", Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}}}, + {Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "done"}, + }, + Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit file", Schema: map[string]any{"type": "object"}}}, + }) + if err != nil { + t.Fatalf("BuildRequest() error = %v", err) + } + if payload.Model != "gpt-5.4" || !payload.Stream || payload.ToolChoice != "auto" { + t.Fatalf("unexpected payload: %+v", payload) + } + if len(payload.Messages) != 4 || payload.Messages[0].Role != providertypes.RoleSystem { + t.Fatalf("unexpected messages: %+v", payload.Messages) + } + if len(payload.Tools) != 1 || payload.Tools[0].Function.Name != "filesystem_edit" { + t.Fatalf("unexpected tools: %+v", payload.Tools) + } + + fallback, err := BuildRequest(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{ + SystemPrompt: " ", + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Tools: []providertypes.ToolSpec{}, + }) + if err != nil { + t.Fatalf("BuildRequest() fallback error = %v", err) + } + if fallback.Model != "gpt-4.1" || len(fallback.Messages) != 1 || len(fallback.Tools) != 0 || fallback.ToolChoice != "" { + t.Fatalf("unexpected fallback payload: %+v", fallback) + } + }) + + t.Run("message conversion", func(t *testing.T) { + t.Parallel() + + user := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleUser, Content: "hello"}) + if user.Role != providertypes.RoleUser || user.Content != "hello" || len(user.ToolCalls) != 0 { + t.Fatalf("unexpected user message: %+v", user) + } + + assistant := ToOpenAIMessage(providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`}, + }, + }) + if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].Type != "function" || assistant.ToolCalls[0].Function.Name != "read_file" { + t.Fatalf("unexpected assistant message: %+v", assistant) + } + + tool := ToOpenAIMessage(providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "result"}) + if tool.Role != providertypes.RoleTool || tool.ToolCallID != "call_1" { + t.Fatalf("unexpected tool message: %+v", tool) + } + }) +} + +func TestParseErrorVariants(t *testing.T) { + t.Parallel() + + if err := ParseError(&http.Response{ + Status: "400 Bad Request", + StatusCode: http.StatusBadRequest, + Body: &failingReadCloser{err: errors.New("broken body")}, + }); err == nil || !strings.Contains(err.Error(), "read error response") { + t.Fatalf("expected body read error, got %v", err) + } + + if err := ParseError(&http.Response{ + Status: "401 Unauthorized", + StatusCode: http.StatusUnauthorized, + Body: ioNopCloser(`{"error":{"message":"invalid api key"}}`), + }); err == nil || !strings.Contains(err.Error(), "invalid api key") { + t.Fatalf("expected json error payload, got %v", err) + } + + if err := ParseError(&http.Response{ + Status: "502 Bad Gateway", + StatusCode: http.StatusBadGateway, + Body: ioNopCloser("gateway timeout"), + }); err == nil || !strings.Contains(err.Error(), "gateway timeout") { + t.Fatalf("expected plain text fallback, got %v", err) + } + + if err := ParseError(&http.Response{ + Status: "500 Internal Server Error", + StatusCode: http.StatusInternalServerError, + Body: ioNopCloser(" "), + }); err == nil || !strings.Contains(err.Error(), "500 Internal Server Error") { + t.Fatalf("expected status fallback, got %v", err) + } + + contextErr := ParseError(&http.Response{ + Status: "400 Bad Request", + StatusCode: http.StatusBadRequest, + Body: ioNopCloser( + `{"error":{"message":"This model's maximum context length is 128000 tokens. However, your messages resulted in 140000 tokens."}}`, + ), + }) + if contextErr == nil || !provider.IsContextTooLong(contextErr) { + t.Fatalf("expected context-too-long classification, got %v", contextErr) + } +} + +func TestEmitFlushMergeAndUsage(t *testing.T) { + t.Parallel() + + if err := EmitTextDelta(context.Background(), nil, "hello"); err != nil { + t.Fatalf("nil channel guard failed: %v", err) + } + + nilCtxEvents := make(chan providertypes.StreamEvent, 4) + if err := EmitTextDelta(nil, nilCtxEvents, "nil-ctx-text"); err != nil { + t.Fatalf("nil context text emit failed: %v", err) + } + if got := mustText(t, <-nilCtxEvents); got.Text != "nil-ctx-text" { + t.Fatalf("unexpected nil context text payload: %+v", got) + } + if err := EmitToolCallStart(nil, nilCtxEvents, 0, "call_nil", "tool_nil"); err != nil { + t.Fatalf("nil context start emit failed: %v", err) + } + if got := mustStart(t, <-nilCtxEvents); got.ID != "call_nil" || got.Name != "tool_nil" { + t.Fatalf("unexpected nil context start payload: %+v", got) + } + if err := EmitToolCallDelta(nil, nilCtxEvents, 0, "call_nil", "{\"x\":1}"); err != nil { + t.Fatalf("nil context delta emit failed: %v", err) + } + if got := mustDelta(t, <-nilCtxEvents); got.ID != "call_nil" || got.ArgumentsDelta != "{\"x\":1}" { + t.Fatalf("unexpected nil context delta payload: %+v", got) + } + if err := EmitMessageDone(nil, nilCtxEvents, "stop", &providertypes.Usage{TotalTokens: 3}); err != nil { + t.Fatalf("nil context done emit failed: %v", err) + } + if got := mustDone(t, <-nilCtxEvents); got.FinishReason != "stop" || got.Usage == nil || got.Usage.TotalTokens != 3 { + t.Fatalf("unexpected nil context done payload: %+v", got) + } + + events := make(chan providertypes.StreamEvent, 8) + if err := EmitTextDelta(context.Background(), events, ""); err != nil { + t.Fatalf("empty text guard failed: %v", err) + } + select { + case evt := <-events: + t.Fatalf("expected no event for empty text, got %+v", evt) + default: + } + + if err := EmitTextDelta(context.Background(), events, "chunk"); err != nil { + t.Fatalf("EmitTextDelta() error = %v", err) + } + if got := mustText(t, <-events); got.Text != "chunk" { + t.Fatalf("unexpected text payload: %+v", got) + } + + if err := EmitToolCallStart(context.Background(), events, 1, "call_1", "filesystem_edit"); err != nil { + t.Fatalf("EmitToolCallStart() error = %v", err) + } + if got := mustStart(t, <-events); got.Index != 1 || got.ID != "call_1" || got.Name != "filesystem_edit" { + t.Fatalf("unexpected start payload: %+v", got) + } + + if err := EmitToolCallDelta(context.Background(), events, 1, "call_1", "{}"); err != nil { + t.Fatalf("EmitToolCallDelta() error = %v", err) + } + if got := mustDelta(t, <-events); got.ID != "call_1" || got.ArgumentsDelta != "{}" { + t.Fatalf("unexpected delta payload: %+v", got) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := EmitTextDelta(ctx, make(chan providertypes.StreamEvent), "x"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled text emit, got %v", err) + } + if err := EmitToolCallStart(ctx, make(chan providertypes.StreamEvent), 0, "call_1", "tool"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled start emit, got %v", err) + } + if err := EmitToolCallDelta(ctx, make(chan providertypes.StreamEvent), 0, "call_1", "{}"); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled delta emit, got %v", err) + } + + doneEvents := make(chan providertypes.StreamEvent, 1) + if err := EmitMessageDone(ctx, doneEvents, "stop", &providertypes.Usage{TotalTokens: 10}); err != nil { + t.Fatalf("expected canceled message_done fallback, got %v", err) + } + if got := mustDone(t, <-doneEvents); got.FinishReason != "stop" || got.Usage == nil || got.Usage.TotalTokens != 10 { + t.Fatalf("unexpected message_done payload: %+v", got) + } + if err := EmitMessageDone(ctx, make(chan providertypes.StreamEvent), "stop", &providertypes.Usage{}); err != nil { + t.Fatalf("expected non-blocking drop on canceled context, got %v", err) + } + + var flushed []string + if err := FlushDataLines([]string{"a", "b"}, func(line string) error { + flushed = append(flushed, line) + return nil + }); err != nil || strings.Join(flushed, ",") != "a,b" { + t.Fatalf("unexpected flush result: lines=%v err=%v", flushed, err) + } + flushErr := errors.New("flush failed") + if err := FlushDataLines([]string{"x"}, func(string) error { return flushErr }); !errors.Is(err, flushErr) { + t.Fatalf("expected flush error, got %v", err) + } + + toolCalls := map[int]*providertypes.ToolCall{} + if err := MergeToolCallDelta(context.Background(), events, toolCalls, ToolCallDelta{Index: 0, ID: "call_0"}); err != nil { + t.Fatalf("MergeToolCallDelta() id-only error = %v", err) + } + select { + case evt := <-events: + t.Fatalf("expected no event for id-only delta, got %+v", evt) + default: + } + if err := MergeToolCallDelta(context.Background(), events, toolCalls, ToolCallDelta{ + Index: 0, + Function: FunctionCall{Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() name error = %v", err) + } + if err := MergeToolCallDelta(context.Background(), events, toolCalls, ToolCallDelta{ + Index: 0, + Function: FunctionCall{Arguments: `,"replace":"new"}`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() args error = %v", err) + } + if mustStart(t, <-events).Name != "filesystem_edit" { + t.Fatal("expected tool_call_start after name arrival") + } + if mustDelta(t, <-events).ArgumentsDelta != `{"path":"main.go"}` { + t.Fatal("expected first tool call delta") + } + if mustDelta(t, <-events).ArgumentsDelta != `,"replace":"new"}` { + t.Fatal("expected second tool call delta") + } + if toolCalls[0].Arguments != `{"path":"main.go"},"replace":"new"}` { + t.Fatalf("unexpected accumulated tool call: %+v", toolCalls[0]) + } + + if err := MergeToolCallDelta(ctx, make(chan providertypes.StreamEvent), map[int]*providertypes.ToolCall{}, ToolCallDelta{ + Index: 1, + Function: FunctionCall{Name: "filesystem_edit"}, + }); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled merge, got %v", err) + } + + var usage providertypes.Usage + ExtractStreamUsage(&usage, nil) + ExtractStreamUsage(&usage, &Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}) + if usage.InputTokens != 10 || usage.OutputTokens != 5 || usage.TotalTokens != 15 { + t.Fatalf("unexpected usage mapping: %+v", usage) + } +} + +func testCfg(baseURL, model, apiKey string) provider.RuntimeConfig { + return provider.RuntimeConfig{ + Name: "openaicompat", + Driver: "openaicompat", + BaseURL: baseURL, + DefaultModel: model, + APIKey: apiKey, + } +} + +func mustProvider(t *testing.T) *Provider { + t.Helper() + p, err := New(testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), &http.Client{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return p +} + +func mustText(t *testing.T, evt providertypes.StreamEvent) providertypes.TextDeltaPayload { + t.Helper() + payload, err := evt.TextDeltaValue() + if err != nil { + t.Fatalf("TextDeltaValue() error = %v", err) + } + return payload +} + +func mustStart(t *testing.T, evt providertypes.StreamEvent) providertypes.ToolCallStartPayload { + t.Helper() + payload, err := evt.ToolCallStartValue() + if err != nil { + t.Fatalf("ToolCallStartValue() error = %v", err) + } + return payload +} + +func mustDelta(t *testing.T, evt providertypes.StreamEvent) providertypes.ToolCallDeltaPayload { + t.Helper() + payload, err := evt.ToolCallDeltaValue() + if err != nil { + t.Fatalf("ToolCallDeltaValue() error = %v", err) + } + return payload +} + +func mustDone(t *testing.T, evt providertypes.StreamEvent) providertypes.MessageDonePayload { + t.Helper() + payload, err := evt.MessageDoneValue() + if err != nil { + t.Fatalf("MessageDoneValue() error = %v", err) + } + return payload +} + +func ioNopCloser(body string) io.ReadCloser { return &readCloser{Reader: strings.NewReader(body)} } + +type readCloser struct{ *strings.Reader } + +func (r *readCloser) Close() error { return nil } + +type failingReadCloser struct{ err error } + +func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } +func (f *failingReadCloser) Close() error { return f.err } + +func TestConsumeStreamVariants(t *testing.T) { + t.Parallel() + + t.Run("comment and done", func(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 4) + err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader( + ": heartbeat\n"+ + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n"+ + "data: [DONE]\n\n", + ), events) + if err != nil { + t.Fatalf("ConsumeStream() error = %v", err) + } + drained := drain(events) + if len(drained) != 2 || drained[0].Type != providertypes.StreamEventTextDelta || drained[1].Type != providertypes.StreamEventMessageDone { + t.Fatalf("unexpected events: %+v", drained) + } + }) + + t.Run("payload and decode errors", func(t *testing.T) { + t.Parallel() + + if err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader("data: {\"error\":{\"message\":\"rate limit exceeded\"}}\n"), make(chan providertypes.StreamEvent, 1)); err == nil || !strings.Contains(err.Error(), "rate limit exceeded") { + t.Fatalf("expected payload error, got %v", err) + } + if err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan providertypes.StreamEvent, 1)); err == nil || !strings.Contains(err.Error(), "decode stream chunk") { + t.Fatalf("expected decode error, got %v", err) + } + }) + + t.Run("eof without done flushes and interrupts", func(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 4) + err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n"), events) + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got %v", err) + } + drained := drain(events) + if len(drained) == 0 || drained[0].Type != providertypes.StreamEventTextDelta { + t.Fatalf("expected pending text delta, got %+v", drained) + } + }) + + t.Run("cancellation beats interruption", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := mustProvider(t).ConsumeStream(ctx, strings.NewReader("data: {}\n\n"), make(chan providertypes.StreamEvent, 1)); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + + ctx2, cancel2 := context.WithCancel(context.Background()) + err := mustProvider(t).ConsumeStream(ctx2, &cancelThenErrorReader{cancel: cancel2, err: io.ErrClosedPipe}, make(chan providertypes.StreamEvent, 1)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected read cancellation, got %v", err) + } + }) + + t.Run("done then read error still finishes", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + events := make(chan providertypes.StreamEvent, 2) + err := mustProvider(t).ConsumeStream(ctx, &cancelAfterDoneReader{ + payload: []byte("data: [DONE]\n"), + cancel: cancel, + err: io.ErrClosedPipe, + }, events) + if err != nil { + t.Fatalf("expected completed stream, got %v", err) + } + drained := drain(events) + if len(drained) != 1 || drained[0].Type != providertypes.StreamEventMessageDone { + t.Fatalf("unexpected completion events: %+v", drained) + } + }) + + t.Run("flush pending data on non eof error", func(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 4) + body := io.MultiReader( + strings.NewReader("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n"), + &errReader{err: io.ErrClosedPipe}, + ) + err := mustProvider(t).ConsumeStream(context.Background(), body, events) + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected interrupted error, got %v", err) + } + drained := drain(events) + if len(drained) == 0 || drained[0].Type != providertypes.StreamEventTextDelta { + t.Fatalf("expected flushed text delta, got %+v", drained) + } + }) + + t.Run("tool call stream records usage", func(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 8) + err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello \"}}]}\n"+ + "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"filesystem_edit\",\"arguments\":\"{\\\"path\\\":\\\"main.go\\\"}\"}}]}}]}\n"+ + "data: {\"choices\":[{\"index\":0,\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n"+ + "data: [DONE]\n\n", + ), events) + if err != nil { + t.Fatalf("ConsumeStream() error = %v", err) + } + drained := drain(events) + if len(drained) != 4 || drained[0].Type != providertypes.StreamEventTextDelta || drained[1].Type != providertypes.StreamEventToolCallStart || drained[2].Type != providertypes.StreamEventToolCallDelta { + t.Fatalf("unexpected stream events: %+v", drained) + } + done := mustDone(t, drained[3]) + if done.FinishReason != "tool_calls" || done.Usage == nil || done.Usage.TotalTokens != 15 { + t.Fatalf("unexpected done payload: %+v", done) + } + }) +} + +func TestGenerateVariants(t *testing.T) { + t.Parallel() + + t.Run("build and marshal errors", func(t *testing.T) { + t.Parallel() + + emptyModel := &Provider{cfg: testCfg("https://api.example.com/v1", "", "test-key"), client: &http.Client{}} + if err := emptyModel.Generate(context.Background(), providertypes.GenerateRequest{}, nil); err == nil || !strings.Contains(err.Error(), "model is empty") { + t.Fatalf("expected model error, got %v", err) + } + + if err := mustProvider(t).Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + Tools: []providertypes.ToolSpec{{Name: "bad", Schema: map[string]any{"bad": func() {}}}}, + }, nil); err == nil || !strings.Contains(err.Error(), "marshal request") { + t.Fatalf("expected marshal error, got %v", err) + } + }) + + t.Run("request build and send errors", func(t *testing.T) { + t.Parallel() + + invalidURL := &Provider{cfg: testCfg("://bad", "gpt-4.1", "test-key"), client: &http.Client{}} + if err := invalidURL.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + }, nil); err == nil || !strings.Contains(err.Error(), "build request") { + t.Fatalf("expected build request error, got %v", err) + } + + sendErr := &Provider{ + cfg: testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), + client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, io.ErrClosedPipe + })}, + } + if err := sendErr.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + }, nil); err == nil || !strings.Contains(err.Error(), "send request") { + t.Fatalf("expected send request error, got %v", err) + } + }) + + t.Run("http error response", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"invalid api key"}}`)) + })) + defer server.Close() + + p, err := New(testCfg(server.URL, "gpt-4.1", "test-key"), server.Client()) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if err := p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + }, nil); err == nil || !strings.Contains(err.Error(), "invalid api key") { + t.Fatalf("expected parsed provider error, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" || r.Header.Get("Accept") != "text/event-stream" { + t.Fatalf("unexpected headers: auth=%q accept=%q", r.Header.Get("Authorization"), r.Header.Get("Accept")) + } + + var payload Request + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload.Model != "gpt-5.4" || !payload.Stream { + t.Fatalf("unexpected payload: %+v", payload) + } + + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte( + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n" + + "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n" + + "data: [DONE]\n\n", + )) + })) + defer server.Close() + + p, err := New(testCfg(server.URL+"/", "gpt-4.1", "test-key"), server.Client()) + if err != nil { + t.Fatalf("New() error = %v", err) + } + events := make(chan providertypes.StreamEvent, 4) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Model: "gpt-5.4", + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + }, events) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + drained := drain(events) + if len(drained) != 2 || drained[0].Type != providertypes.StreamEventTextDelta { + t.Fatalf("unexpected generate events: %+v", drained) + } + done := mustDone(t, drained[1]) + if done.FinishReason != "stop" || done.Usage == nil || done.Usage.TotalTokens != 2 { + t.Fatalf("unexpected done payload: %+v", done) + } + }) +} + +func drain(events <-chan providertypes.StreamEvent) []providertypes.StreamEvent { + var drained []providertypes.StreamEvent + for { + select { + case evt := <-events: + drained = append(drained, evt) + default: + return drained + } + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type errReader struct{ err error } + +func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err } + +type cancelThenErrorReader struct { + cancel func() + err error +} + +func (r *cancelThenErrorReader) Read(_ []byte) (int, error) { + r.cancel() + return 0, r.err +} + +type cancelAfterDoneReader struct { + payload []byte + cancel func() + err error + read bool +} + +func (r *cancelAfterDoneReader) Read(p []byte) (int, error) { + if !r.read { + r.read = true + return copy(p, r.payload), nil + } + r.cancel() + return 0, r.err +} diff --git a/internal/provider/openaicompat/request.go b/internal/provider/openaicompat/chatcompletions/request.go similarity index 56% rename from internal/provider/openaicompat/request.go rename to internal/provider/openaicompat/chatcompletions/request.go index c3a3938c..23ed957a 100644 --- a/internal/provider/openaicompat/request.go +++ b/internal/provider/openaicompat/chatcompletions/request.go @@ -1,4 +1,4 @@ -package openaicompat +package chatcompletions import ( "encoding/json" @@ -9,44 +9,45 @@ import ( "strings" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/shared" providertypes "neo-code/internal/provider/types" ) -// buildRequest 将 provider.GenerateRequest 转换为 OpenAI API 请求结构。 +// BuildRequest 将 provider.GenerateRequest 转换为 Chat Completions 请求结构。 // 模型优先取 req.Model,其次使用配置中的默认模型。 -func (p *Provider) buildRequest(req providertypes.GenerateRequest) (chatCompletionRequest, error) { +func BuildRequest(cfg provider.RuntimeConfig, req providertypes.GenerateRequest) (Request, error) { model := strings.TrimSpace(req.Model) if model == "" { - model = strings.TrimSpace(p.cfg.DefaultModel) + model = strings.TrimSpace(cfg.DefaultModel) } if model == "" { - return chatCompletionRequest{}, errors.New("openai provider: model is empty") + return Request{}, errors.New(shared.ErrorPrefix + "model is empty") } - payload := chatCompletionRequest{ + payload := Request{ Model: model, Stream: true, - Messages: make([]openAIMessage, 0, len(req.Messages)+1), + Messages: make([]Message, 0, len(req.Messages)+1), } if strings.TrimSpace(req.SystemPrompt) != "" { - payload.Messages = append(payload.Messages, openAIMessage{ + payload.Messages = append(payload.Messages, Message{ Role: providertypes.RoleSystem, Content: req.SystemPrompt, }) } for _, message := range req.Messages { - payload.Messages = append(payload.Messages, toOpenAIMessage(message)) + payload.Messages = append(payload.Messages, ToOpenAIMessage(message)) } if len(req.Tools) > 0 { payload.ToolChoice = "auto" - payload.Tools = make([]openAIToolDefinition, 0, len(req.Tools)) + payload.Tools = make([]ToolDefinition, 0, len(req.Tools)) for _, spec := range req.Tools { - payload.Tools = append(payload.Tools, openAIToolDefinition{ + payload.Tools = append(payload.Tools, ToolDefinition{ Type: "function", - Function: openAIFunctionDefinition{ + Function: FunctionDefinition{ Name: spec.Name, Description: spec.Description, Parameters: spec.Schema, @@ -58,21 +59,21 @@ func (p *Provider) buildRequest(req providertypes.GenerateRequest) (chatCompleti return payload, nil } -// toOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 -func toOpenAIMessage(message providertypes.Message) openAIMessage { - out := openAIMessage{ +// ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 +func ToOpenAIMessage(message providertypes.Message) Message { + out := Message{ Role: message.Role, Content: message.Content, ToolCallID: message.ToolCallID, } if len(message.ToolCalls) > 0 { - out.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls)) + out.ToolCalls = make([]ToolCall, 0, len(message.ToolCalls)) for _, call := range message.ToolCalls { - out.ToolCalls = append(out.ToolCalls, openAIToolCall{ + out.ToolCalls = append(out.ToolCalls, ToolCall{ ID: call.ID, Type: "function", - Function: openAIFunctionCall{ + Function: FunctionCall{ Name: call.Name, Arguments: call.Arguments, }, @@ -83,15 +84,15 @@ func toOpenAIMessage(message providertypes.Message) openAIMessage { return out } -// parseError 解析 HTTP 错误响应并包装为 ProviderError。 -func (p *Provider) parseError(resp *http.Response) error { +// ParseError 解析 HTTP 错误响应并包装为 ProviderError。 +func ParseError(resp *http.Response) error { data, readErr := io.ReadAll(resp.Body) if readErr != nil { return provider.NewProviderErrorFromStatus(resp.StatusCode, - fmt.Sprintf("openai provider: read error response: %v", readErr)) + fmt.Sprintf("%sread error response: %v", shared.ErrorPrefix, readErr)) } - var parsed openAIErrorResponse + var parsed ErrorResponse if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { return provider.NewProviderErrorFromStatus(resp.StatusCode, parsed.Error.Message) } diff --git a/internal/provider/openaicompat/response.go b/internal/provider/openaicompat/chatcompletions/response.go similarity index 66% rename from internal/provider/openaicompat/response.go rename to internal/provider/openaicompat/chatcompletions/response.go index aa592811..a394ebcb 100644 --- a/internal/provider/openaicompat/response.go +++ b/internal/provider/openaicompat/chatcompletions/response.go @@ -1,4 +1,4 @@ -package openaicompat +package chatcompletions import ( "context" @@ -9,16 +9,17 @@ import ( "strings" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/shared" providertypes "neo-code/internal/provider/types" ) -// consumeStream 消费 SSE 响应流,并在 [DONE] 或 message_done 时完成收尾。 -func (p *Provider) consumeStream( +// ConsumeStream 消费 SSE 响应流,并在 [DONE] 或 message_done 时完成收尾。 +func (p *Provider) ConsumeStream( ctx context.Context, body io.Reader, events chan<- providertypes.StreamEvent, ) error { - reader := newBoundedSSEReader(body) + reader := NewBoundedSSEReader(body) var ( finishReason string @@ -29,35 +30,34 @@ func (p *Provider) consumeStream( dataLines := make([]string, 0, 4) - // processChunk 解析单个 SSE data payload,并发出增量事件。 processChunk := func(payload string) error { if strings.TrimSpace(payload) == "[DONE]" { done = true return nil } - var chunk chatCompletionChunk + var chunk Chunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { - return fmt.Errorf("openai provider: decode stream chunk: %w", err) + return fmt.Errorf("%sdecode stream chunk: %w", shared.ErrorPrefix, err) } if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" { return errors.New(chunk.Error.Message) } - extractStreamUsage(&usage, chunk.Usage) + ExtractStreamUsage(&usage, chunk.Usage) for _, choice := range chunk.Choices { if choice.FinishReason != "" { finishReason = choice.FinishReason } if choice.Delta.Content != "" { - if err := emitTextDelta(ctx, events, choice.Delta.Content); err != nil { + if err := EmitTextDelta(ctx, events, choice.Delta.Content); err != nil { return err } } for _, delta := range choice.Delta.ToolCalls { - if err := mergeToolCallDelta(ctx, events, toolCalls, delta); err != nil { + if err := MergeToolCallDelta(ctx, events, toolCalls, delta); err != nil { return err } } @@ -65,20 +65,16 @@ func (p *Provider) consumeStream( return nil } - // finishStream 统一输出 message_done 收尾事件。 finishStream := func() error { - return emitMessageDone(ctx, events, finishReason, &usage) + return EmitMessageDone(ctx, events, finishReason, &usage) } - // flushPendingData 刷新积累的 data 行,保证多行 data payload 正确拼接。 flushPendingData := func() error { defer func() { dataLines = dataLines[:0] }() - return flushDataLines(dataLines, processChunk) + return FlushDataLines(dataLines, processChunk) } for { - // 每次读取前优先响应上下文取消,避免取消请求被误判为流中断。 - // 一旦收到 [DONE],后续取消不应覆盖已完成的流收尾。 if !done { select { case <-ctx.Done(): @@ -104,10 +100,9 @@ func (p *Provider) consumeStream( return fmt.Errorf("%w: %w", provider.ErrStreamInterrupted, err) } - trimmed := line switch { - case strings.HasPrefix(trimmed, "data:"): - data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + case strings.HasPrefix(line, "data:"): + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if data == "[DONE]" { if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -116,15 +111,15 @@ func (p *Provider) consumeStream( } else { dataLines = append(dataLines, data) } - case trimmed == "": + case line == "": if flushErr := flushPendingData(); flushErr != nil { return flushErr } if done { return finishStream() } - case strings.HasPrefix(trimmed, ":"): - // SSE comment/heartbeat; ignore. + case strings.HasPrefix(line, ":"): + // SSE 注释/心跳,直接忽略。 } if errors.Is(err, io.EOF) { @@ -142,8 +137,8 @@ func (p *Provider) consumeStream( } } -// extractStreamUsage 将 OpenAI usage 响应覆盖到累计 token 统计。 -func extractStreamUsage(usage *providertypes.Usage, raw *openAIUsage) { +// ExtractStreamUsage 将 OpenAI usage 响应覆盖到累计 token 统计。 +func ExtractStreamUsage(usage *providertypes.Usage, raw *Usage) { if raw == nil { return } diff --git a/internal/provider/openaicompat/chatcompletions/sse_reader.go b/internal/provider/openaicompat/chatcompletions/sse_reader.go new file mode 100644 index 00000000..07317d7b --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/sse_reader.go @@ -0,0 +1,64 @@ +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/chatcompletions/sse_reader_test.go b/internal/provider/openaicompat/chatcompletions/sse_reader_test.go new file mode 100644 index 00000000..45f16817 --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/sse_reader_test.go @@ -0,0 +1,111 @@ +package chatcompletions + +import ( + "errors" + "io" + "strings" + "testing" + + "neo-code/internal/provider" +) + +func TestBoundedSSEReaderAndTrimLineEnding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + isEOF bool + }{ + {name: "newline", input: "data: hello\n", want: "data: hello"}, + {name: "crlf", input: "data: world\r\n", want: "data: world"}, + {name: "empty", input: "\n", want: ""}, + {name: "comment", input: ": heartbeat\n", want: ": heartbeat"}, + {name: "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() + reader := NewBoundedSSEReader(strings.NewReader(tt.input)) + got, err := reader.ReadLine() + if got != tt.want { + t.Fatalf("ReadLine() = %q, want %q", got, tt.want) + } + if tt.isEOF { + if !errors.Is(err, io.EOF) { + t.Fatalf("expected io.EOF, got %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } + + reader := NewBoundedSSEReader(strings.NewReader("line1\nline2\n\nline4\n")) + for _, want := range []string{"line1", "line2", "", "line4"} { + got, err := reader.ReadLine() + if err != nil || got != want { + t.Fatalf("unexpected line read: got=%q err=%v want=%q", got, err, want) + } + } + if _, err := reader.ReadLine(); !errors.Is(err, io.EOF) { + 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) { + t.Fatalf("expected ErrLineTooLong, got %v", err) + } + if got, err := NewBoundedSSEReader(strings.NewReader(strings.Repeat("a", MaxSSELineSize) + "\n")).ReadLine(); err != nil || len(got) != MaxSSELineSize { + 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 { + 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) { + 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++ { + builder.WriteString(line) + } + reader = NewBoundedSSEReader(strings.NewReader(builder.String())) + for i := 0; i < int(MaxStreamTotalSize/lineSize); i++ { + if _, err := reader.ReadLine(); err != nil { + t.Fatalf("unexpected error before size limit: %v", err) + } + } + if _, err := reader.ReadLine(); !errors.Is(err, provider.ErrStreamTooLarge) { + t.Fatalf("expected ErrStreamTooLarge, got %v", err) + } + + if _, err := NewBoundedSSEReader(&readerErr{err: io.ErrClosedPipe}).ReadLine(); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("expected io.ErrClosedPipe, got %v", err) + } + + for _, tt := range []struct { + input string + want string + }{ + {input: "hello\n", want: "hello"}, + {input: "hello\r\n", want: "hello"}, + {input: "hello\r\n\n", want: "hello"}, + {input: "\r", want: ""}, + {input: "", want: ""}, + } { + if got := TrimLineEnding(tt.input); got != tt.want { + t.Fatalf("TrimLineEnding(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +type readerErr struct{ err error } + +func (r *readerErr) Read(_ []byte) (int, error) { return 0, r.err } diff --git a/internal/provider/openaicompat/chatcompletions/toolcall.go b/internal/provider/openaicompat/chatcompletions/toolcall.go new file mode 100644 index 00000000..8127b5fb --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/toolcall.go @@ -0,0 +1,46 @@ +package chatcompletions + +import ( + "context" + "strings" + + providertypes "neo-code/internal/provider/types" +) + +// MergeToolCallDelta 将单个 tool call delta 累积到 toolCalls map 中, +// 并在名称或参数首次出现时发出对应的增量事件。 +func MergeToolCallDelta( + ctx context.Context, + events chan<- providertypes.StreamEvent, + toolCalls map[int]*providertypes.ToolCall, + delta ToolCallDelta, +) error { + call, exists := toolCalls[delta.Index] + if !exists { + call = &providertypes.ToolCall{} + toolCalls[delta.Index] = call + } + + hadName := strings.TrimSpace(call.Name) != "" // 记录是否已知工具名,用于避免重复发 start 事件。 + + if id := strings.TrimSpace(delta.ID); id != "" { + call.ID = id + } + if name := strings.TrimSpace(delta.Function.Name); name != "" { + call.Name = name + } + + if !hadName && strings.TrimSpace(call.Name) != "" { + if err := EmitToolCallStart(ctx, events, delta.Index, call.ID, call.Name); err != nil { + return err + } + } + + if args := delta.Function.Arguments; args != "" { + call.Arguments += args + if err := EmitToolCallDelta(ctx, events, delta.Index, call.ID, args); err != nil { + return err + } + } + return nil +} diff --git a/internal/provider/openaicompat/chatcompletions/types.go b/internal/provider/openaicompat/chatcompletions/types.go new file mode 100644 index 00000000..da315767 --- /dev/null +++ b/internal/provider/openaicompat/chatcompletions/types.go @@ -0,0 +1,90 @@ +package chatcompletions + +// 以下类型定义了 OpenAI Chat Completions API 的请求和响应结构体, +// 仅在 openai-compatible chat_completions 协议实现内部及其适配层使用。 + +// Request 表示 /chat/completions 端点的请求体。 +type Request struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []ToolDefinition `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Stream bool `json:"stream"` +} + +// Message 表示 OpenAI 协议中的消息格式。 +type Message struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +// ToolDefinition 表示工具定义的 OpenAI 格式。 +type ToolDefinition struct { + Type string `json:"type"` + Function FunctionDefinition `json:"function"` +} + +// FunctionDefinition 表示函数描述的 OpenAI 格式。 +type FunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// ToolCall 表示响应中工具调用的 OpenAI 格式。 +type ToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function FunctionCall `json:"function"` +} + +// FunctionCall 表示函数调用参数的 OpenAI 格式。 +type FunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +// Chunk 表示 SSE 流式响应中的单个 chunk。 +type Chunk struct { + Choices []struct { + Index int `json:"index"` + Delta ChunkDelta `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *Usage `json:"usage,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// ChunkDelta 表示流式 chunk 中的增量内容。 +type ChunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"` +} + +// ToolCallDelta 表示流式 tool call 增量。 +type ToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function FunctionCall `json:"function"` +} + +// Usage 表示 token 使用统计。 +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// ErrorResponse 表示 API 错误响应。 +type ErrorResponse struct { + Error struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` + } `json:"error"` +} diff --git a/internal/provider/openaicompat/discovery.go b/internal/provider/openaicompat/discovery.go index 0266883a..bb0510dd 100644 --- a/internal/provider/openaicompat/discovery.go +++ b/internal/provider/openaicompat/discovery.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "strings" + + "neo-code/internal/provider/openaicompat/shared" ) // openAIModelsResponse 表示 /models 端点的响应结构。 @@ -16,19 +18,17 @@ type openAIModelsResponse struct { // fetchModels 从 OpenAI 兼容的 /models 端点获取原始模型列表。 func (p *Provider) fetchModels(ctx context.Context) ([]map[string]any, error) { - endpoint := strings.TrimRight(strings.TrimSpace(p.cfg.BaseURL), "/") + "/models" + endpoint := shared.Endpoint(p.cfg.BaseURL, "/models") req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, fmt.Errorf("openai provider: build models request: %w", err) + return nil, fmt.Errorf("%sbuild models request: %w", shared.ErrorPrefix, err) } req.Header.Set("Accept", "application/json") - if strings.TrimSpace(p.cfg.APIKey) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.cfg.APIKey)) - } + shared.SetBearerAuthorization(req.Header, p.cfg.APIKey) resp, err := p.client.Do(req) if err != nil { - return nil, fmt.Errorf("openai provider: send models request: %w", err) + return nil, fmt.Errorf("%ssend models request: %w", shared.ErrorPrefix, err) } defer func(body io.ReadCloser) { _ = body.Close() @@ -40,12 +40,12 @@ func (p *Provider) fetchModels(ctx context.Context) ([]map[string]any, error) { if body == "" { body = resp.Status } - return nil, fmt.Errorf("openai provider: models endpoint %s", body) + 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("openai provider: decode models response: %w", err) + return nil, fmt.Errorf("%sdecode models response: %w", shared.ErrorPrefix, err) } return payload.Data, nil } diff --git a/internal/provider/openaicompat/driver.go b/internal/provider/openaicompat/driver.go index 25f5a870..ef6202c4 100644 --- a/internal/provider/openaicompat/driver.go +++ b/internal/provider/openaicompat/driver.go @@ -10,7 +10,7 @@ import ( ) // DriverName 是当前 OpenAI-compatible 协议驱动的唯一标识。 -const DriverName = "openaicompat" +const DriverName = provider.DriverOpenAICompat // defaultRetryTransport 返回内置的带重试 HTTP Transport。 func defaultRetryTransport() http.RoundTripper { @@ -22,6 +22,12 @@ func Driver() provider.DriverDefinition { return driverDefinition(DriverName) } +// validateCatalogIdentity 复用 api_style 分流规则,在 catalog 快照与缓存路径上提前拒绝当前尚不支持的静态配置。 +func validateCatalogIdentity(identity provider.ProviderIdentity) error { + _, err := supportedAPIStyle(identity.APIStyle) + return err +} + // driverDefinition 根据驱动名构造共享的 OpenAI-compatible 协议驱动定义。 func driverDefinition(name string) provider.DriverDefinition { return provider.DriverDefinition{ @@ -36,6 +42,7 @@ func driverDefinition(name string) provider.DriverDefinition { } return p.DiscoverModels(ctx) }, + ValidateCatalogIdentity: validateCatalogIdentity, Capabilities: provider.DriverTransportCapabilities{ Streaming: true, ToolTransport: true, diff --git a/internal/provider/openaicompat/driver_internal_test.go b/internal/provider/openaicompat/driver_internal_test.go new file mode 100644 index 00000000..d1fa6127 --- /dev/null +++ b/internal/provider/openaicompat/driver_internal_test.go @@ -0,0 +1,159 @@ +package openaicompat + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +func TestDriverClosuresAndAPIStyle(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/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", "name": "GPT-4.1"}}, + }) + })) + defer server.Close() + + cfg := provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: server.URL, + DefaultModel: "gpt-4.1", + APIKey: "test-key", + } + driver := Driver() + + built, err := driver.Build(context.Background(), cfg) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + typed, ok := built.(*Provider) + if !ok || typed.client == nil || typed.client.Transport == nil { + t.Fatalf("unexpected built provider: %T %+v", built, typed) + } + + models, err := driver.Discover(context.Background(), cfg) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(models) != 1 || models[0].ID != "gpt-4.1" { + t.Fatalf("unexpected models: %+v", models) + } + + if got := normalizedAPIStyle(""); got != provider.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf("expected default api style, got %q", got) + } + 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 _, err := supportedAPIStyle(" 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"`) { + t.Fatalf("expected unsupported custom api_style, got %v", err) + } +} + +func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { + t.Parallel() + + p := &Provider{ + cfg: provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "://bad", + APIKey: "test-key", + }, + client: &http.Client{}, + } + if _, err := p.fetchModels(context.Background()); err == nil || !strings.Contains(err.Error(), "build models request") { + t.Fatalf("expected build models request error, got %v", err) + } + + var auth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}}) + })) + defer server.Close() + + p = &Provider{ + cfg: provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: server.URL, + APIKey: " ", + }, + client: server.Client(), + } + if _, err := p.fetchModels(context.Background()); err != nil { + t.Fatalf("fetchModels() error = %v", err) + } + if auth != "" { + t.Fatalf("expected no authorization header, got %q", auth) + } + + p, err := New(provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + DefaultModel: "gpt-4.1", + APIKey: "test-key", + APIStyle: "custom_style", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + }, nil) + if err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { + t.Fatalf("expected unsupported api_style error, got %v", err) + } + + p, err = New(provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + DefaultModel: "gpt-4.1", + APIKey: "test-key", + APIStyle: "responses", + }) + 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, err = New(provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "https://api.example.com/v1", + DefaultModel: "gpt-4.1", + APIKey: "test-key", + APIStyle: "custom_style", + }) + 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) + } +} diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index 5bb00d28..6094dd31 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -13,6 +13,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/chatcompletions" providertypes "neo-code/internal/provider/types" ) @@ -158,7 +159,7 @@ func TestToOpenAIMessage_BasicMessage(t *testing.T) { Role: "user", Content: "hello world", } - result := toOpenAIMessage(msg) + result := chatcompletions.ToOpenAIMessage(msg) if result.Role != "user" || result.Content != "hello world" { t.Fatalf("unexpected basic message: role=%q content=%q", result.Role, result.Content) @@ -176,7 +177,7 @@ func TestToOpenAIMessage_ToolRoleMessage(t *testing.T) { Content: "result data", ToolCallID: "call_123", } - result := toOpenAIMessage(msg) + result := chatcompletions.ToOpenAIMessage(msg) if result.Role != "tool" || result.ToolCallID != "call_123" { t.Fatalf("unexpected tool message: role=%q toolCallID=%q", result.Role, result.ToolCallID) @@ -193,7 +194,7 @@ func TestToOpenAIMessage_AssistantWithToolCalls(t *testing.T) { {ID: "call_2", Name: "write_file", Arguments: `{"path":"test.go","content":"..."}`}, }, } - result := toOpenAIMessage(msg) + result := chatcompletions.ToOpenAIMessage(msg) if len(result.ToolCalls) != 2 { t.Fatalf("expected 2 tool calls, got %d", len(result.ToolCalls)) @@ -215,7 +216,7 @@ func TestToOpenAIMessage_EmptyToolCalls(t *testing.T) { t.Parallel() msg := providertypes.Message{Role: "user", Content: "test"} - result := toOpenAIMessage(msg) + result := chatcompletions.ToOpenAIMessage(msg) if len(result.ToolCalls) != 0 { t.Fatalf("expected no tool calls for user message, got %d", len(result.ToolCalls)) } @@ -227,7 +228,7 @@ func TestExtractStreamUsage_NilInput(t *testing.T) { t.Parallel() var usage providertypes.Usage - extractStreamUsage(&usage, nil) + chatcompletions.ExtractStreamUsage(&usage, nil) if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 { t.Fatalf("expected zero values for nil input, got %+v", usage) } @@ -237,8 +238,8 @@ func TestExtractStreamUsage_NormalValues(t *testing.T) { t.Parallel() var usage providertypes.Usage - raw := &openAIUsage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150} - extractStreamUsage(&usage, raw) + raw := &chatcompletions.Usage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150} + chatcompletions.ExtractStreamUsage(&usage, raw) if usage.InputTokens != 100 || usage.OutputTokens != 50 || usage.TotalTokens != 150 { t.Fatalf("unexpected usage values: %+v", usage) } @@ -249,8 +250,8 @@ func TestExtractStreamUsage_ZeroValues(t *testing.T) { var usage providertypes.Usage usage.InputTokens = 999 - raw := &openAIUsage{} - extractStreamUsage(&usage, raw) + raw := &chatcompletions.Usage{} + chatcompletions.ExtractStreamUsage(&usage, raw) if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 { t.Fatalf("expected zero values to overwrite previous, got %+v", usage) } @@ -260,8 +261,8 @@ func TestExtractStreamUsage_MultipleOverwrites(t *testing.T) { t.Parallel() var usage providertypes.Usage - extractStreamUsage(&usage, &openAIUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}) - extractStreamUsage(&usage, &openAIUsage{PromptTokens: 20, CompletionTokens: 10, TotalTokens: 30}) + chatcompletions.ExtractStreamUsage(&usage, &chatcompletions.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}) + chatcompletions.ExtractStreamUsage(&usage, &chatcompletions.Usage{PromptTokens: 20, CompletionTokens: 10, TotalTokens: 30}) if usage.TotalTokens != 30 { t.Fatalf("expected last write to win (total=30), got %d", usage.TotalTokens) } @@ -285,7 +286,7 @@ func TestBuildRequest_EmptyModelReturnsError(t *testing.T) { client: &http.Client{}, } - _, buildErr := p.buildRequest(providertypes.GenerateRequest{}) + _, buildErr := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{}) if buildErr == nil { t.Fatal("expected error for empty model") } @@ -302,7 +303,7 @@ func TestBuildRequest_FallsBackToConfigModel(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -319,7 +320,7 @@ func TestBuildRequest_RequestModelTakesPrecedence(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -336,7 +337,7 @@ func TestBuildRequest_NoSystemPrompt(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -355,7 +356,7 @@ func TestBuildRequest_NoTools(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -372,7 +373,7 @@ func TestBuildRequest_EmptyToolsSlice(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -389,7 +390,7 @@ func TestBuildRequest_MultipleTools(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{ + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{ Messages: []providertypes.Message{{Role: "user", Content: "use tools"}}, Tools: []providertypes.ToolSpec{ {Name: "tool_a", Description: "Tool A", Schema: map[string]any{"type": "object"}}, @@ -415,7 +416,7 @@ func TestBuildRequest_WhitespaceSystemPromptSkipped(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -431,9 +432,9 @@ func TestBuildRequest_WhitespaceSystemPromptSkipped(t *testing.T) { func TestConsumeStream_SSECommentIgnored(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `: heartbeat @@ -442,7 +443,7 @@ data: [DONE] ` events := make(chan providertypes.StreamEvent, 4) - err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + err = p.ConsumeStream(context.Background(), strings.NewReader(sseData), events) if err != nil { t.Fatalf("consumeStream() error = %v", err) } @@ -461,15 +462,15 @@ data: [DONE] func TestConsumeStream_ChunkErrorInPayload(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `data: {"error":{"message":"rate limit exceeded"}} ` events := make(chan providertypes.StreamEvent, 1) - err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + err = p.ConsumeStream(context.Background(), strings.NewReader(sseData), events) if err == nil { t.Fatal("expected error for chunk with error field") } @@ -481,9 +482,9 @@ func TestConsumeStream_ChunkErrorInPayload(t *testing.T) { func TestConsumeStream_MultiLineDataPayload(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `data: {"id":"a","choices":[{"delta":{"content":"part1"},"finish_reason":""}]} @@ -492,7 +493,7 @@ data: [DONE] ` events := make(chan providertypes.StreamEvent, 8) - err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + err = p.ConsumeStream(context.Background(), strings.NewReader(sseData), events) if err != nil { t.Fatalf("consumeStream() error = %v", err) } @@ -505,15 +506,15 @@ data: [DONE] func TestConsumeStream_EOFWithoutDoneReturnsInterrupted(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `data: {"id":"a","choices":[{"delta":{"content":"partial"},"finish_reason":""}]} ` events := make(chan providertypes.StreamEvent, 4) - err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + err = p.ConsumeStream(context.Background(), strings.NewReader(sseData), events) if err == nil { t.Fatal("expected interrupted error for EOF without [DONE]") } @@ -535,9 +536,9 @@ func TestConsumeStream_EOFWithoutDoneReturnsInterrupted(t *testing.T) { func TestConsumeStream_ContextCancellation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) @@ -547,7 +548,7 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { ` events := make(chan providertypes.StreamEvent, 1) - err = p.consumeStream(ctx, strings.NewReader(sseData), events) + err = p.ConsumeStream(ctx, strings.NewReader(sseData), events) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got: %v", err) } @@ -559,15 +560,15 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) body := &cancelThenErrorReader{cancel: cancel, err: io.ErrClosedPipe} - err = p.consumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) + err = p.ConsumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -579,9 +580,9 @@ func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing. func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) @@ -593,7 +594,7 @@ func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *tes } events := make(chan providertypes.StreamEvent, 8) - err = p.consumeStream(ctx, body, events) + err = p.ConsumeStream(ctx, body, events) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -605,9 +606,9 @@ func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *tes func TestConsumeStream_DoneThenCancellationStillFinishes(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) @@ -618,7 +619,7 @@ func TestConsumeStream_DoneThenCancellationStillFinishes(t *testing.T) { } events := make(chan providertypes.StreamEvent, 4) - err = p.consumeStream(ctx, body, events) + err = p.ConsumeStream(ctx, body, events) if err != nil { t.Fatalf("expected completed stream after [DONE], got %v", err) } @@ -637,9 +638,9 @@ func TestConsumeStream_DoneThenCancellationStillFinishes(t *testing.T) { func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `data: {"id":"a","choices":[{"delta":{"content":"text"},"finish_reason":""}]} @@ -648,7 +649,7 @@ data: [DONE] ` events := make(chan providertypes.StreamEvent, 8) - err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + err = p.ConsumeStream(context.Background(), strings.NewReader(sseData), events) if err != nil { t.Fatalf("consumeStream() error = %v", err) } @@ -673,7 +674,7 @@ data: [DONE] func TestEmitTextDelta_NilEventsGuard(t *testing.T) { t.Parallel() - if err := emitTextDelta(context.Background(), nil, "some text"); err != nil { + if err := chatcompletions.EmitTextDelta(context.Background(), nil, "some text"); err != nil { t.Fatalf("expected nil events guard to return nil, got %v", err) } } @@ -681,7 +682,7 @@ func TestEmitTextDelta_NilEventsGuard(t *testing.T) { func TestEmitTextDelta_EmptyTextGuard(t *testing.T) { t.Parallel() events := make(chan providertypes.StreamEvent, 1) - if err := emitTextDelta(context.Background(), events, ""); err != nil { + if err := chatcompletions.EmitTextDelta(context.Background(), events, ""); err != nil { t.Fatalf("expected empty text guard to return nil, got %v", err) } select { @@ -691,49 +692,12 @@ func TestEmitTextDelta_EmptyTextGuard(t *testing.T) { } } -func TestEmitStreamEvent_NilEventsChannel(t *testing.T) { - t.Parallel() - event := providertypes.NewTextDeltaStreamEvent("test") - if err := emitStreamEvent(context.Background(), nil, event); err != nil { - t.Fatalf("expected nil channel to return nil, got %v", err) - } -} - -func TestEmitStreamEvent_NormalSend(t *testing.T) { - t.Parallel() - events := make(chan providertypes.StreamEvent, 1) - event := providertypes.NewTextDeltaStreamEvent("hello") - if err := emitStreamEvent(context.Background(), events, event); err != nil { - t.Fatalf("emitStreamEvent() error = %v", err) - } - got := <-events - if got.Type != providertypes.StreamEventTextDelta { - t.Fatalf("unexpected event type: %s", got.Type) - } -} - -func TestEmitStreamEvent_ContextCancelled(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(context.Background()) - cancel() - events := make(chan providertypes.StreamEvent) - event := providertypes.NewTextDeltaStreamEvent("test") - - err := emitStreamEvent(ctx, events, event) - if err == nil { - t.Fatal("expected context cancellation error") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got: %v", err) - } -} - // --- flushDataLines 测试 --- func TestFlushDataLines_EmptyLines(t *testing.T) { t.Parallel() called := false - err := flushDataLines([]string{}, func(string) error { called = true; return nil }) + err := chatcompletions.FlushDataLines([]string{}, func(string) error { called = true; return nil }) if err != nil { t.Fatalf("flushDataLines() error = %v", err) } @@ -745,7 +709,7 @@ func TestFlushDataLines_EmptyLines(t *testing.T) { func TestFlushDataLines_SingleLine(t *testing.T) { t.Parallel() var received string - err := flushDataLines([]string{"line1"}, func(p string) error { received = p; return nil }) + err := chatcompletions.FlushDataLines([]string{"line1"}, func(p string) error { received = p; return nil }) if err != nil { t.Fatalf("flushDataLines() error = %v", err) } @@ -757,7 +721,7 @@ func TestFlushDataLines_SingleLine(t *testing.T) { func TestFlushDataLines_MultipleLinesProcessedIndividually(t *testing.T) { t.Parallel() var received []string - err := flushDataLines([]string{"a", "b", "c"}, func(p string) error { received = append(received, p); return nil }) + err := chatcompletions.FlushDataLines([]string{"a", "b", "c"}, func(p string) error { received = append(received, p); return nil }) if err != nil { t.Fatalf("flushDataLines() error = %v", err) } @@ -769,7 +733,7 @@ func TestFlushDataLines_MultipleLinesProcessedIndividually(t *testing.T) { func TestFlushDataLines_ProcessChunkError(t *testing.T) { t.Parallel() expectedErr := errors.New("process error") - err := flushDataLines([]string{"data"}, func(string) error { return expectedErr }) + err := chatcompletions.FlushDataLines([]string{"data"}, func(string) error { return expectedErr }) if err != expectedErr { t.Fatalf("expected processChunk error, got %v", err) } @@ -903,18 +867,24 @@ func TestMergeToolCallDelta_MultipleIndices(t *testing.T) { events := make(chan providertypes.StreamEvent, 8) toolCalls := make(map[int]*providertypes.ToolCall) - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{ Index: 0, ID: "call_0", - Function: openAIFunctionCall{Name: "tool_a", Arguments: `{"arg":"a"`}, - }) - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Function: chatcompletions.FunctionCall{Name: "tool_a", Arguments: `{"arg":"a"`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{ Index: 1, ID: "call_1", - Function: openAIFunctionCall{Name: "tool_b", Arguments: `{"arg":"b"}`}, - }) - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Function: chatcompletions.FunctionCall{Name: "tool_b", Arguments: `{"arg":"b"}`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{ Index: 0, - Function: openAIFunctionCall{Arguments: `,"more":"data"}`}, - }) + Function: chatcompletions.FunctionCall{Arguments: `,"more":"data"}`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } if len(toolCalls) != 2 { t.Fatalf("expected 2 tool calls, got %d", len(toolCalls)) @@ -939,7 +909,9 @@ func TestMergeToolCallDelta_IDUpdateOnly(t *testing.T) { events := make(chan providertypes.StreamEvent, 4) toolCalls := make(map[int]*providertypes.ToolCall) - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{Index: 0, ID: "call_only_id"}) + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{Index: 0, ID: "call_only_id"}); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } call := toolCalls[0] if call == nil { @@ -993,15 +965,10 @@ data: [DONE] func TestParseError_ReadBodyFailure(t *testing.T) { t.Parallel() - p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - readErr := errors.New("simulated read failure") resp := &http.Response{Status: "400 Bad Request", StatusCode: 400, Body: &failingReadCloser{err: readErr}} - err = p.parseError(resp) + err := chatcompletions.ParseError(resp) if err == nil { t.Fatal("expected error when body read fails") } @@ -1013,13 +980,8 @@ func TestParseError_ReadBodyFailure(t *testing.T) { func TestParseError_InvalidJSONBody(t *testing.T) { t.Parallel() - p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - resp := &http.Response{Status: "400 Bad Request", StatusCode: 400, Body: ioNopCloser("this is not json at all")} - err = p.parseError(resp) + err := chatcompletions.ParseError(resp) if err == nil { t.Fatal("expected error for non-JSON body") } @@ -1031,17 +993,12 @@ func TestParseError_InvalidJSONBody(t *testing.T) { func TestParseError_ClassifiesContextTooLong(t *testing.T) { t.Parallel() - p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - resp := &http.Response{ Status: "400 Bad Request", StatusCode: 400, Body: ioNopCloser(`{"error":{"message":"This model's maximum context length is 128000 tokens. However, your messages resulted in 140000 tokens."}}`), } - err = p.parseError(resp) + err := chatcompletions.ParseError(resp) if err == nil { t.Fatal("expected context too long error") } @@ -1063,7 +1020,7 @@ func TestProviderGenerateConsumesSSEAndMergesToolCalls(t *testing.T) { t.Fatalf("unexpected auth header: %s", got) } - var payload chatCompletionRequest + var payload chatcompletions.Request if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } @@ -1191,6 +1148,29 @@ func TestProviderGenerateHTTPErrorResponses(t *testing.T) { } } +func TestProviderGenerateRejectsUnsupportedAPIStyle(t *testing.T) { + t.Parallel() + + cfg := resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel) + cfg.APIStyle = "responses" + + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Model: config.OpenAIDefaultModel, + Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, + }, make(chan providertypes.StreamEvent, 1)) + if err == nil { + t.Fatal("expected unsupported api_style error") + } + if !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() @@ -1199,7 +1179,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.GenerateRequest{ + payload, err := chatcompletions.BuildRequest(p.cfg, providertypes.GenerateRequest{ SystemPrompt: "system prompt", Messages: []providertypes.Message{ {Role: "user", Content: "hello"}, @@ -1238,11 +1218,6 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - tests := []struct { name string status string @@ -1258,7 +1233,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() resp := &http.Response{Status: tt.status, Body: ioNopCloser(tt.body)} - err := p.parseError(resp) + err := chatcompletions.ParseError(resp) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) } @@ -1266,7 +1241,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { } eventCh := make(chan providertypes.StreamEvent, 1) - if err := emitTextDelta(context.Background(), eventCh, "chunk"); err != nil { + if err := chatcompletions.EmitTextDelta(context.Background(), eventCh, "chunk"); err != nil { t.Fatalf("emitTextDelta() error = %v", err) } if got := <-eventCh; got.Type != providertypes.StreamEventTextDelta || requireTextDeltaPayload(t, got).Text != "chunk" { @@ -1275,7 +1250,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - if err := emitTextDelta(cancelledCtx, make(chan providertypes.StreamEvent), "chunk"); err == nil { + if err := chatcompletions.EmitTextDelta(cancelledCtx, make(chan providertypes.StreamEvent), "chunk"); err == nil { t.Fatalf("expected cancellation error") } } @@ -1283,12 +1258,12 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + p, err := chatcompletions.New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } - err = p.consumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan providertypes.StreamEvent, 1)) + err = p.ConsumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan providertypes.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), "decode stream chunk") { t.Fatalf("expected dirty JSON decode error, got %v", err) } @@ -1363,7 +1338,7 @@ func requireMessageDonePayload(t *testing.T, event providertypes.StreamEvent) pr return payload } -func containsToolRoleMessage(messages []openAIMessage, toolCallID string, content string) bool { +func containsToolRoleMessage(messages []chatcompletions.Message, toolCallID string, content string) bool { for _, m := range messages { if m.Role == "tool" && m.ToolCallID == toolCallID && m.Content == content { return true @@ -1397,13 +1372,13 @@ func (r *readCloser) Close() error { return nil } func TestConsumeStream_WrapsNonEOFAsInterrupted(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } errReader := &errReader{err: io.ErrClosedPipe} - err = p.consumeStream(context.Background(), errReader, make(chan providertypes.StreamEvent, 1)) + err = p.ConsumeStream(context.Background(), errReader, make(chan providertypes.StreamEvent, 1)) if err == nil { t.Fatal("expected error for broken reader") } @@ -1415,9 +1390,9 @@ func TestConsumeStream_WrapsNonEOFAsInterrupted(t *testing.T) { func TestConsumeStream_FlushesPendingDataOnNonEOFError(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - p, err := New(resolvedConfig("", "")) + p, err := chatcompletions.New(resolvedConfig("", ""), &http.Client{}) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("chatcompletions.New() error = %v", err) } sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"},"finish_reason":""}]} @@ -1425,7 +1400,7 @@ func TestConsumeStream_FlushesPendingDataOnNonEOFError(t *testing.T) { body := io.MultiReader(strings.NewReader(sseData), &errReader{err: io.ErrClosedPipe}) events := make(chan providertypes.StreamEvent, 10) - err = p.consumeStream(context.Background(), body, events) + err = p.ConsumeStream(context.Background(), body, events) if err == nil { t.Fatal("expected error for broken reader") } @@ -1500,12 +1475,12 @@ func TestEmitToolCallStartGuards(t *testing.T) { t.Parallel() ctx := context.Background() - if err := emitToolCallStart(ctx, nil, 0, "call-1", "filesystem_edit"); err != nil { + if err := chatcompletions.EmitToolCallStart(ctx, nil, 0, "call-1", "filesystem_edit"); err != nil { t.Fatalf("expected nil events guard to return nil, got %v", err) } events := make(chan providertypes.StreamEvent, 1) - if err := emitToolCallStart(ctx, events, 0, "call-1", ""); err != nil { + if err := chatcompletions.EmitToolCallStart(ctx, events, 0, "call-1", ""); err != nil { t.Fatalf("expected empty name guard to return nil, got %v", err) } select { @@ -1514,7 +1489,7 @@ func TestEmitToolCallStartGuards(t *testing.T) { default: } - if err := emitToolCallStart(ctx, events, 2, "call-1", "filesystem_edit"); err != nil { + if err := chatcompletions.EmitToolCallStart(ctx, events, 2, "call-1", "filesystem_edit"); err != nil { t.Fatalf("emitToolCallStart() error = %v", err) } got := <-events @@ -1525,7 +1500,7 @@ func TestEmitToolCallStartGuards(t *testing.T) { cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - if err := emitToolCallStart(cancelledCtx, make(chan providertypes.StreamEvent), 0, "call-1", "filesystem_edit"); err == nil { + if err := chatcompletions.EmitToolCallStart(cancelledCtx, make(chan providertypes.StreamEvent), 0, "call-1", "filesystem_edit"); err == nil { t.Fatal("expected cancellation error") } } @@ -1536,16 +1511,20 @@ func TestMergeToolCallDeltaEmitsStartWhenNameArrivesLater(t *testing.T) { events := make(chan providertypes.StreamEvent, 4) toolCalls := make(map[int]*providertypes.ToolCall) - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{Index: 0, ID: "call_late_name"}) + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{Index: 0, ID: "call_late_name"}); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } select { case evt := <-events: t.Fatalf("expected no event before tool name arrives, got %+v", evt) default: } - mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ - Index: 0, Function: openAIFunctionCall{Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}, - }) + if err := chatcompletions.MergeToolCallDelta(context.Background(), events, toolCalls, chatcompletions.ToolCallDelta{ + Index: 0, Function: chatcompletions.FunctionCall{Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}, + }); err != nil { + t.Fatalf("MergeToolCallDelta() error = %v", err) + } start := <-events if start.Type != providertypes.StreamEventToolCallStart { diff --git a/internal/provider/openaicompat/provider.go b/internal/provider/openaicompat/provider.go index 935c23eb..32c546c0 100644 --- a/internal/provider/openaicompat/provider.go +++ b/internal/provider/openaicompat/provider.go @@ -1,18 +1,14 @@ package openaicompat import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" - "io" - "log" "net/http" - "strings" "time" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/chatcompletions" + "neo-code/internal/provider/openaicompat/shared" providertypes "neo-code/internal/provider/types" ) @@ -39,11 +35,8 @@ func withTransport(rt http.RoundTripper) buildOption { // New 创建 OpenAI provider 实例。cfg 必须包含有效的接入地址和 API Key。 func New(cfg provider.RuntimeConfig, opts ...buildOption) (*Provider, error) { - if strings.TrimSpace(cfg.BaseURL) == "" { - return nil, errors.New("openai provider: base url is empty") - } - if strings.TrimSpace(cfg.APIKey) == "" { - return nil, errors.New("openai provider: api key is empty") + if err := shared.ValidateRuntimeConfig(cfg); err != nil { + return nil, err } o := &buildOptions{ @@ -64,6 +57,10 @@ 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 @@ -83,39 +80,38 @@ 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 { - payload, err := p.buildRequest(req) - if err != nil { + if _, err := supportedAPIStyle(p.cfg.APIStyle); err != nil { return err } - body, err := json.Marshal(payload) + impl, err := chatcompletions.New(p.cfg, p.client) if err != nil { - return fmt.Errorf("openai provider: marshal request: %w", err) - } - - endpoint := strings.TrimRight(p.cfg.BaseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("openai provider: build request: %w", err) + return err } - httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") + return impl.Generate(ctx, req, events) +} - resp, err := p.client.Do(httpReq) - if err != nil { - return fmt.Errorf("openai provider: send request: %w", err) +// normalizedAPIStyle 统一规范化 openaicompat 的 api_style,并为空值回退到 chat_completions。 +func normalizedAPIStyle(apiStyle string) string { + normalized := provider.NormalizeProviderAPIStyle(apiStyle) + if normalized == "" { + return provider.OpenAICompatibleAPIStyleChatCompletions } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Printf("openai provider: close response body: %v", err) - } - }(resp.Body) + return normalized +} - if resp.StatusCode >= http.StatusBadRequest { - return p.parseError(resp) +func supportedAPIStyle(apiStyle string) (string, error) { + normalized := normalizedAPIStyle(apiStyle) + switch normalized { + case provider.OpenAICompatibleAPIStyleChatCompletions: + return normalized, nil + case provider.OpenAICompatibleAPIStyleResponses: + return "", provider.NewDiscoveryConfigError( + fmt.Sprintf("openaicompat provider: api_style %q is not supported yet", normalized), + ) + default: + return "", provider.NewDiscoveryConfigError( + fmt.Sprintf("openaicompat provider: unsupported api_style %q", normalized), + ) } - - return p.consumeStream(ctx, resp.Body, events) } diff --git a/internal/provider/openaicompat/shared/shared.go b/internal/provider/openaicompat/shared/shared.go new file mode 100644 index 00000000..a9e11e1b --- /dev/null +++ b/internal/provider/openaicompat/shared/shared.go @@ -0,0 +1,46 @@ +package shared + +import ( + "errors" + "net/http" + "strings" + + "neo-code/internal/provider" +) + +// ErrorPrefix 统一收敛 OpenAI 兼容 provider 的错误前缀,避免历史命名残留继续扩散。 +const ErrorPrefix = "openaicompat provider: " + +func ValidateRuntimeConfig(cfg provider.RuntimeConfig) error { + if strings.TrimSpace(cfg.BaseURL) == "" { + return errors.New(ErrorPrefix + "base url is empty") + } + if strings.TrimSpace(cfg.APIKey) == "" { + return errors.New(ErrorPrefix + "api key is empty") + } + return nil +} + +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 +} + +func SetBearerAuthorization(header http.Header, apiKey string) { + if header == nil { + return + } + + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return + } + + header.Set("Authorization", "Bearer "+apiKey) +} diff --git a/internal/provider/openaicompat/shared/shared_test.go b/internal/provider/openaicompat/shared/shared_test.go new file mode 100644 index 00000000..bb73fc4d --- /dev/null +++ b/internal/provider/openaicompat/shared/shared_test.go @@ -0,0 +1,130 @@ +package shared + +import ( + "net/http" + "testing" + + "neo-code/internal/provider" +) + +func TestValidateRuntimeConfig(t *testing.T) { + t.Parallel() + + t.Run("empty base url", func(t *testing.T) { + t.Parallel() + + err := ValidateRuntimeConfig(provider.RuntimeConfig{ + BaseURL: "", + APIKey: "test-key", + }) + if err == nil || err.Error() != ErrorPrefix+"base url is empty" { + t.Fatalf("expected base url error, got %v", err) + } + }) + + t.Run("empty api key", func(t *testing.T) { + t.Parallel() + + err := ValidateRuntimeConfig(provider.RuntimeConfig{ + BaseURL: "https://api.example.com/v1", + APIKey: " ", + }) + if err == nil || err.Error() != ErrorPrefix+"api key is empty" { + t.Fatalf("expected api key error, got %v", err) + } + }) + + t.Run("valid config", func(t *testing.T) { + t.Parallel() + + err := ValidateRuntimeConfig(provider.RuntimeConfig{ + BaseURL: " https://api.example.com/v1 ", + APIKey: " test-key ", + }) + if err != nil { + t.Fatalf("expected valid config, got %v", err) + } + }) +} + +func TestEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + path string + want string + }{ + { + name: "trims whitespace and trailing slash", + baseURL: " https://api.example.com/v1/ ", + path: "/models", + want: "https://api.example.com/v1/models", + }, + { + name: "adds leading slash for path", + baseURL: "https://api.example.com/v1", + path: "chat/completions", + want: "https://api.example.com/v1/chat/completions", + }, + { + name: "empty path returns normalized base", + baseURL: "https://api.example.com/v1///", + path: "", + want: "https://api.example.com/v1", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := Endpoint(tt.baseURL, tt.path); got != tt.want { + t.Fatalf("Endpoint(%q, %q) = %q, want %q", tt.baseURL, tt.path, got, tt.want) + } + }) + } +} + +func TestSetBearerAuthorization(t *testing.T) { + t.Parallel() + + t.Run("nil header is ignored", func(t *testing.T) { + t.Parallel() + + SetBearerAuthorization(nil, "test-key") + }) + + t.Run("empty api key is ignored", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + SetBearerAuthorization(header, " ") + if got := header.Get("Authorization"); got != "" { + t.Fatalf("expected no authorization header, got %q", got) + } + }) + + t.Run("sets bearer authorization", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + SetBearerAuthorization(header, " test-key ") + if got := header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("expected bearer authorization, got %q", got) + } + }) + + t.Run("overwrites existing authorization", func(t *testing.T) { + t.Parallel() + + header := http.Header{} + header.Set("Authorization", "Basic abc") + SetBearerAuthorization(header, "next-key") + if got := header.Get("Authorization"); got != "Bearer next-key" { + t.Fatalf("expected authorization overwrite, got %q", got) + } + }) +} diff --git a/internal/provider/openaicompat/sse_reader.go b/internal/provider/openaicompat/sse_reader.go deleted file mode 100644 index e0affa96..00000000 --- a/internal/provider/openaicompat/sse_reader.go +++ /dev/null @@ -1,80 +0,0 @@ -package openaicompat - -import ( - "bufio" - "errors" - "io" - - "neo-code/internal/provider" -) - -// 单行与总量上限,防止恶意或异常数据导致内存无限增长。 -const ( - maxSSELineSize = 256 * 1024 // L1: 单行 256KB - maxStreamTotalSize = 10 << 20 // L3: 总量 10MB -) - -// boundedSSEReader 对 bufio.Reader 包装两级有界检查: -// - L1: 每次读取的行不超过 maxSSELineSize -// - L3: 累计读取字节数不超过 maxStreamTotalSize -// -// 纯同步设计,无 goroutine/channel,适用于 SSE 顺序消费场景。 -type boundedSSEReader struct { - reader *bufio.Reader - totalRead int64 -} - -// newBoundedSSEReader 创建有界 SSE 行读取器。 -// -// 内部 bufio.Reader 的缓冲区大小设为 maxSSELineSize+1,使得 ReadSlice('\n') -// 在单行超过 L1 上限时立即返回 bufio.ErrBufferFull,避免 ReadString 那样 -// 先把整行全部读进内存才做长度检查。 -func newBoundedSSEReader(r io.Reader) *boundedSSEReader { - return &boundedSSEReader{ - reader: bufio.NewReaderSize(r, maxSSELineSize+1), - } -} - -// ReadLine 读取一行(以 \n 分隔),同时执行 L1 和 L3 检查。 -// 返回去除尾部 \r\n 的行内容;遇到 io.EOF 时返回空字符串和 nil。 -// -// L1 检查通过 bufio.Reader 的缓冲区大小约束实现:如果一行在缓冲区内 -// 未找到 \n,ReadSlice 直接返回 bufio.ErrBufferFull,无需先将整行载入内存。 -func (r *boundedSSEReader) ReadLine() (string, error) { - line, err := r.reader.ReadSlice('\n') - - // L1: 缓冲区溢出 → 单行超过 maxSSELineSize(触发在读取过程中,而非读完后) - if errors.Is(err, bufio.ErrBufferFull) { - return "", provider.ErrLineTooLong - } - - if err != nil && !errors.Is(err, io.EOF) { - return "", err - } - - // L1 兜底:行内容长度检查(不含末尾 \n) - rawLen := len(line) - if rawLen > 0 && line[rawLen-1] == '\n' { - rawLen-- - } - if rawLen > maxSSELineSize { - return "", provider.ErrLineTooLong - } - - // L3: 总量检查 - r.totalRead += int64(len(line)) - if r.totalRead > maxStreamTotalSize { - return "", provider.ErrStreamTooLarge - } - - // 将 []byte 转为 string(ReadSlice 返回的底层数据在下次读取时会被覆盖) - 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/sse_reader_test.go b/internal/provider/openaicompat/sse_reader_test.go index 3aae7b02..aba86535 100644 --- a/internal/provider/openaicompat/sse_reader_test.go +++ b/internal/provider/openaicompat/sse_reader_test.go @@ -7,6 +7,7 @@ import ( "testing" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat/chatcompletions" ) // --- boundedSSEReader 单元测试 --- @@ -56,7 +57,7 @@ func TestBoundedSSEReader_ReadLine_Normal(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - r := newBoundedSSEReader(strings.NewReader(tt.input)) + r := chatcompletions.NewBoundedSSEReader(strings.NewReader(tt.input)) got, err := r.ReadLine() if got != tt.want { t.Fatalf("ReadLine() = %q, want %q", got, tt.want) @@ -74,7 +75,7 @@ func TestBoundedSSEReader_ReadLine_Normal(t *testing.T) { func TestBoundedSSEReader_ReadLine_MultipleLines(t *testing.T) { t.Parallel() - r := newBoundedSSEReader(strings.NewReader("line1\nline2\n\nline4\n")) + r := chatcompletions.NewBoundedSSEReader(strings.NewReader("line1\nline2\n\nline4\n")) line1, err := r.ReadLine() if err != nil || line1 != "line1" { @@ -107,8 +108,8 @@ func TestBoundedSSEReader_ReadLine_MultipleLines(t *testing.T) { func TestBoundedSSEReader_L1_LineTooLong(t *testing.T) { t.Parallel() - longLine := strings.Repeat("x", maxSSELineSize+1) + "\n" - r := newBoundedSSEReader(strings.NewReader(longLine)) + longLine := strings.Repeat("x", chatcompletions.MaxSSELineSize+1) + "\n" + r := chatcompletions.NewBoundedSSEReader(strings.NewReader(longLine)) _, err := r.ReadLine() if err == nil { @@ -123,15 +124,15 @@ func TestBoundedSSEReader_L1_BoundaryExactLimit(t *testing.T) { t.Parallel() // 恰好等于上限的行应该正常通过(不含 \n) - exactLine := strings.Repeat("a", maxSSELineSize) + "\n" - r := newBoundedSSEReader(strings.NewReader(exactLine)) + 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) != maxSSELineSize { - t.Fatalf("expected line length %d, got %d", maxSSELineSize, len(got)) + if len(got) != chatcompletions.MaxSSELineSize { + t.Fatalf("expected line length %d, got %d", chatcompletions.MaxSSELineSize, len(got)) } } @@ -143,15 +144,15 @@ func TestBoundedSSEReader_L3_StreamTooLarge(t *testing.T) { lineSize := int64(len(line)) var sb strings.Builder - linesToWrite := int(maxStreamTotalSize/lineSize) + 1 + linesToWrite := int(chatcompletions.MaxStreamTotalSize/lineSize) + 1 for range linesToWrite { sb.WriteString(line) } - r := newBoundedSSEReader(strings.NewReader(sb.String())) + r := chatcompletions.NewBoundedSSEReader(strings.NewReader(sb.String())) // 前面的行应能正常读取 - expectedNormal := int(maxStreamTotalSize / lineSize) + expectedNormal := int(chatcompletions.MaxStreamTotalSize / lineSize) for range expectedNormal { _, err := r.ReadLine() if err != nil { @@ -187,7 +188,7 @@ func TestTrimLineEnding(t *testing.T) { } for _, tt := range tests { - got := trimLineEnding(tt.input) + got := chatcompletions.TrimLineEnding(tt.input) if got != tt.want { t.Fatalf("trimLineEnding(%q) = %q, want %q", tt.input, got, tt.want) } @@ -200,15 +201,15 @@ func TestBoundedSSEReader_L1_NoNewlineEOFAtLimit(t *testing.T) { t.Parallel() // 不含 \n 的行,长度恰好等于 maxSSELineSize,以 EOF 结尾 - exactLine := strings.Repeat("b", maxSSELineSize) - r := newBoundedSSEReader(strings.NewReader(exactLine)) + 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) != maxSSELineSize { - t.Fatalf("expected line length %d, got %d", maxSSELineSize, len(got)) + if len(got) != chatcompletions.MaxSSELineSize { + t.Fatalf("expected line length %d, got %d", chatcompletions.MaxSSELineSize, len(got)) } } @@ -218,8 +219,8 @@ func TestBoundedSSEReader_L1_NoNewlineEOFExceedsLimit(t *testing.T) { t.Parallel() // 不含 \n,长度超过 maxSSELineSize - longLine := strings.Repeat("c", maxSSELineSize+1) - r := newBoundedSSEReader(strings.NewReader(longLine)) + longLine := strings.Repeat("c", chatcompletions.MaxSSELineSize+1) + r := chatcompletions.NewBoundedSSEReader(strings.NewReader(longLine)) _, err := r.ReadLine() if err == nil { @@ -235,7 +236,7 @@ func TestBoundedSSEReader_L1_NoNewlineEOFExceedsLimit(t *testing.T) { func TestBoundedSSEReader_UnderlyingErrorPropagation(t *testing.T) { t.Parallel() - r := newBoundedSSEReader(&errReader{err: io.ErrClosedPipe}) + r := chatcompletions.NewBoundedSSEReader(&errReader{err: io.ErrClosedPipe}) _, err := r.ReadLine() if err == nil { t.Fatal("expected error from broken reader") @@ -251,8 +252,8 @@ func TestBoundedSSEReader_L1_ThenNormalRead(t *testing.T) { t.Parallel() // 第一行超长触发 L1,第二行正常 - input := strings.Repeat("x", maxSSELineSize+10) + "\nnormal line\n" - r := newBoundedSSEReader(strings.NewReader(input)) + input := strings.Repeat("x", chatcompletions.MaxSSELineSize+10) + "\nnormal line\n" + r := chatcompletions.NewBoundedSSEReader(strings.NewReader(input)) // 第一行应返回 ErrLineTooLong _, err := r.ReadLine() diff --git a/internal/provider/openaicompat/toolcall.go b/internal/provider/openaicompat/toolcall.go deleted file mode 100644 index e3b7a748..00000000 --- a/internal/provider/openaicompat/toolcall.go +++ /dev/null @@ -1,43 +0,0 @@ -package openaicompat - -import ( - "context" - "strings" - - providertypes "neo-code/internal/provider/types" -) - -// mergeToolCallDelta 将单个 tool call delta 累积到 toolCalls map 中。 -// 首次发现带名称的 delta 时发送 tool_call_start 事件; -// 每次收到 arguments 增量时发送 tool_call_delta 事件。 -func mergeToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEvent, toolCalls map[int]*providertypes.ToolCall, delta toolCallDelta) error { - call, exists := toolCalls[delta.Index] - if !exists { - call = &providertypes.ToolCall{} - toolCalls[delta.Index] = call - } - - hadName := strings.TrimSpace(call.Name) != "" - - if id := strings.TrimSpace(delta.ID); id != "" { - call.ID = id - } - if name := strings.TrimSpace(delta.Function.Name); name != "" { - call.Name = name - } - - if !hadName && strings.TrimSpace(call.Name) != "" { - if err := emitToolCallStart(ctx, events, delta.Index, call.ID, call.Name); err != nil { - return err - } - } - - // 发送参数增量事件(同一 chunk 可能同时携带 name 和 arguments) - if args := delta.Function.Arguments; args != "" { - call.Arguments += args - if err := emitToolCallDelta(ctx, events, delta.Index, call.ID, args); err != nil { - return err - } - } - return nil -} diff --git a/internal/provider/openaicompat/types.go b/internal/provider/openaicompat/types.go deleted file mode 100644 index 95dc24dc..00000000 --- a/internal/provider/openaicompat/types.go +++ /dev/null @@ -1,90 +0,0 @@ -package openaicompat - -// 以下类型定义了 OpenAI Chat Completions API 的请求和响应结构体, -// 仅在 openai 子包内部使用,不对外暴露。 - -// chatCompletionRequest 表示 /chat/completions 端点的请求体。 -type chatCompletionRequest struct { - Model string `json:"model"` - Messages []openAIMessage `json:"messages"` - Tools []openAIToolDefinition `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` - Stream bool `json:"stream"` -} - -// openAIMessage 表示 OpenAI 协议中的消息格式。 -type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` -} - -// openAIToolDefinition 表示工具定义的 OpenAI 格式。 -type openAIToolDefinition struct { - Type string `json:"type"` - Function openAIFunctionDefinition `json:"function"` -} - -// openAIFunctionDefinition 表示函数描述的 OpenAI 格式。 -type openAIFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters map[string]any `json:"parameters,omitempty"` -} - -// openAIToolCall 表示响应中工具调用的 OpenAI 格式。 -type openAIToolCall struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Function openAIFunctionCall `json:"function"` -} - -// openAIFunctionCall 表示函数调用参数的 OpenAI 格式。 -type openAIFunctionCall struct { - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` -} - -// chatCompletionChunk 表示 SSE 流式响应中的单个 chunk(内部使用,非导出)。 -type chatCompletionChunk struct { - Choices []struct { - Index int `json:"index"` - Delta chunkDelta `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *openAIUsage `json:"usage,omitempty"` - Error *struct { - Message string `json:"message"` - } `json:"error,omitempty"` -} - -// chunkDelta 表示流式 chunk 中的增量内容。 -type chunkDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ToolCalls []toolCallDelta `json:"tool_calls,omitempty"` -} - -// toolCallDelta 表示流式 tool call 增量。 -type toolCallDelta struct { - Index int `json:"index"` - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Function openAIFunctionCall `json:"function"` -} - -// openAIUsage 表示 token 使用统计。 -type openAIUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// openAIErrorResponse 表示 API 错误响应。 -type openAIErrorResponse struct { - Error struct { - Message string `json:"message"` - Code string `json:"code,omitempty"` - } `json:"error"` -} diff --git a/internal/provider/registry.go b/internal/provider/registry.go index e7fb32ba..f89eddc2 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -13,10 +13,11 @@ type Builder func(ctx context.Context, cfg RuntimeConfig) (Provider, error) type DiscoveryFunc func(ctx context.Context, cfg RuntimeConfig) ([]providertypes.ModelDescriptor, error) type DriverDefinition struct { - Name string - Build Builder - Discover DiscoveryFunc - Capabilities DriverTransportCapabilities + Name string + Build Builder + Discover DiscoveryFunc + ValidateCatalogIdentity func(identity ProviderIdentity) error + Capabilities DriverTransportCapabilities } type Registry struct { @@ -82,6 +83,22 @@ func (r *Registry) DriverTransportCapabilities(driverType string) (DriverTranspo return driver.Capabilities, nil } +// ValidateCatalogIdentity 在读取 catalog 快照或执行默认模型回退前执行无需密钥的静态校验,避免无效配置被误判为可用。 +func (r *Registry) ValidateCatalogIdentity(identity ProviderIdentity) error { + if r == nil { + return ErrDriverNotFound + } + + driver, err := r.driver(identity.Driver) + if err != nil { + return err + } + if driver.ValidateCatalogIdentity == nil { + return nil + } + return driver.ValidateCatalogIdentity(identity) +} + func (r *Registry) driver(driverType string) (DriverDefinition, error) { if r == nil { return DriverDefinition{}, ErrDriverNotFound diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 85b8b081..dc471b7a 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -200,6 +200,34 @@ func TestRegistryDriverTransportCapabilities(t *testing.T) { } } +func TestRegistryValidateCatalogIdentity(t *testing.T) { + t.Parallel() + + t.Run("openaicompat rejects unsupported api_style", func(t *testing.T) { + t.Parallel() + + registry := newTestRegistry(t) + err := registry.ValidateCatalogIdentity(provider.ProviderIdentity{ + Driver: "OPENAICOMPAT", + BaseURL: config.OpenAIDefaultBaseURL, + APIStyle: "responses", + }) + if !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected discovery config error, got %v", err) + } + }) + + t.Run("missing driver returns typed error", func(t *testing.T) { + t.Parallel() + + registry := provider.NewRegistry() + err := registry.ValidateCatalogIdentity(provider.ProviderIdentity{Driver: "missing"}) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) + } + }) +} + func TestRegistryRegisterErrors(t *testing.T) { t.Parallel()