From f350c777bb310e3872ae69bc31aca40030f51e72 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 19 Apr 2026 15:45:57 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat=EF=BC=88provider=EF=BC=89:=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=87=AA=E5=AE=9A=E4=B9=89chat=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/configuration.md | 6 + internal/config/config_test.go | 22 +- internal/config/loader_test.go | 395 ++++-------------- internal/config/provider.go | 225 ++++------ internal/config/provider_loader.go | 375 ++++------------- internal/config/provider_test.go | 57 +-- internal/config/runtime.go | 74 +++- internal/config/runtime_test.go | 56 ++- internal/config/state/model_test.go | 13 +- .../config/state/service_provider_create.go | 107 ++--- .../state/service_provider_create_test.go | 49 +-- internal/config/state/service_test.go | 1 - internal/provider/anthropic/driver.go | 85 +--- internal/provider/anthropic/driver_test.go | 44 +- internal/provider/catalog/additional_test.go | 4 - internal/provider/chat_endpoint.go | 54 +++ internal/provider/chat_endpoint_test.go | 129 ++++++ internal/provider/contracts.go | 25 +- internal/provider/driver_defaults.go | 63 +++ internal/provider/gemini/driver.go | 60 +-- internal/provider/gemini/driver_test.go | 24 +- .../openaicompat/chatcompletions/provider.go | 14 +- .../chatcompletions/provider_test.go | 77 +++- .../openaicompat/chatcompletions/request.go | 27 +- .../openaicompat/chatcompletions/response.go | 6 + internal/provider/openaicompat/discovery.go | 20 +- .../openaicompat/driver_internal_test.go | 65 +-- .../openaicompat/openaicompat_test.go | 87 +++- internal/provider/openaicompat/provider.go | 45 +- .../provider/openaicompat/shared/shared.go | 3 +- .../openaicompat/shared/shared_test.go | 28 +- internal/provider/protocols.go | 3 - internal/provider/protocols_test.go | 44 +- internal/provider/types/asset_limits.go | 35 ++ internal/provider/types/asset_limits_test.go | 54 +++ internal/runtime/input_prepare.go | 13 +- internal/runtime/input_prepare_test.go | 34 ++ internal/runtime/run.go | 4 +- internal/session/asset_store_test.go | 19 + internal/session/sqlite_store.go | 34 +- internal/session/store.go | 1 + internal/tui/core/app/app.go | 35 +- internal/tui/core/app/update.go | 189 +++------ internal/tui/core/app/update_test.go | 84 ++-- internal/tui/core/app/view.go | 27 +- internal/tui/core/app/view_test.go | 15 +- 46 files changed, 1298 insertions(+), 1533 deletions(-) create mode 100644 internal/provider/chat_endpoint.go create mode 100644 internal/provider/chat_endpoint_test.go create mode 100644 internal/provider/driver_defaults.go create mode 100644 internal/provider/types/asset_limits_test.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 42ea6e95..79f61f0d 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -267,3 +267,9 @@ config: environment variable OPENAI_API_KEY is empty - [添加 Provider](./adding-providers.md) - [配置管理详细设计](../config-management-detail-design.md) - [Context Compact](../context-compact.md) + +## runtime.assets�������� + +- `runtime.assets.max_session_asset_bytes`������ `session_asset` ������ֽ�����Ĭ�� 20 MiB���� +- `runtime.assets.max_session_assets_total_bytes`������ provider ������ȫ�� `session_asset` ԭʼ�������ֽ����ޣ�Ĭ�� 20 MiB���� +- �������û�������ʱ�·��� session �洢�� provider ���󹹽�������·�������Ա���Ӳ���޶��ס� diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0716c872..30197b74 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,7 +26,6 @@ func testDefaultProviderConfig() ProviderConfig { BaseURL: testBaseURL, Model: testModel, APIKeyEnv: testAPIKeyEnv, - APIStyle: providerpkg.OpenAICompatibleAPIStyleChatCompletions, Source: ProviderSourceBuiltin, } } @@ -910,7 +909,6 @@ func TestAssembleProvidersRejectsIdenticalDuplicateCustomProviderNames(t *testin Driver: "openaicompat", BaseURL: "https://example.com/v1", APIKeyEnv: "COMPANY_GATEWAY_API_KEY", - APIStyle: "responses", Source: ProviderSourceCustom, }, { @@ -918,7 +916,6 @@ func TestAssembleProvidersRejectsIdenticalDuplicateCustomProviderNames(t *testin Driver: "openaicompat", BaseURL: "https://example.com/v1", APIKeyEnv: "COMPANY_GATEWAY_API_KEY", - APIStyle: "responses", Source: ProviderSourceCustom, }, } @@ -1913,14 +1910,11 @@ func TestToRuntimeConfigMapsAllFields(t *testing.T) { resolved := ResolvedProviderConfig{ ProviderConfig: ProviderConfig{ - Name: "test-provider", - Driver: "gemini", - BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - Model: "gemini-2.5-flash", - APIKeyEnv: "TEST_ENV_KEY", - APIStyle: "responses", - DeploymentMode: "vertex", - APIVersion: "v1beta", + Name: "test-provider", + Driver: "gemini", + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + Model: "gemini-2.5-flash", + APIKeyEnv: "TEST_ENV_KEY", }, APIKey: "resolved-secret-key", } @@ -1938,12 +1932,6 @@ func TestToRuntimeConfigMapsAllFields(t *testing.T) { if got.APIKey != "resolved-secret-key" { t.Fatalf("expected APIKey=resolved-secret-key, got %q", got.APIKey) } - if got.DeploymentMode != "vertex" { - t.Fatalf("expected DeploymentMode=vertex, got %q", got.DeploymentMode) - } - if got.APIVersion != "v1beta" { - t.Fatalf("expected APIVersion=v1beta, got %q", got.APIVersion) - } } func TestLoaderLoadWithCanceledContext(t *testing.T) { diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index af215901..32666bbc 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -32,23 +32,17 @@ func saveCustomProviderWithModelsForTest( driver string, baseURL string, apiKeyEnv string, - apiStyle string, - deploymentMode string, - apiVersion string, discoveryEndpointPath string, - discoveryResponseProfile string, + chatEndpointPath string, ) error { return SaveCustomProviderWithModels(baseDir, SaveCustomProviderInput{ - Name: name, - Driver: driver, - BaseURL: baseURL, - APIKeyEnv: apiKeyEnv, - APIStyle: apiStyle, - DeploymentMode: deploymentMode, - APIVersion: apiVersion, - DiscoveryEndpointPath: discoveryEndpointPath, - DiscoveryResponseProfile: discoveryResponseProfile, - ModelSource: provider.ModelSourceDiscover, + Name: name, + Driver: driver, + BaseURL: baseURL, + ChatEndpointPath: chatEndpointPath, + APIKeyEnv: apiKeyEnv, + DiscoveryEndpointPath: discoveryEndpointPath, + ModelSource: provider.ModelSourceDiscover, }) } @@ -242,9 +236,8 @@ shell: powershell providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -280,17 +273,14 @@ shell: powershell providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 +discovery_endpoint_path: /gateway/models api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder name: DeepSeek Coder context_window: 131072 max_output_tokens: 8192 -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: chat_completions - discovery_endpoint_path: /gateway/models - discovery_response_profile: generic ` customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { @@ -318,22 +308,12 @@ openai_compatible: if customProvider.Driver != "openaicompat" { t.Fatalf("expected custom provider driver openaicompat, got %q", customProvider.Driver) } - if customProvider.APIStyle != "chat_completions" { - t.Fatalf("expected api_style chat_completions, got %q", customProvider.APIStyle) - } if customProvider.BaseURL != "https://llm.example.com/v1" { t.Fatalf("expected base url https://llm.example.com/v1, got %q", customProvider.BaseURL) } if customProvider.DiscoveryEndpointPath != "/gateway/models" { t.Fatalf("expected discovery endpoint /gateway/models, got %q", customProvider.DiscoveryEndpointPath) } - if customProvider.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { - t.Fatalf( - "expected discovery response profile %q, got %q", - provider.DiscoveryResponseProfileGeneric, - customProvider.DiscoveryResponseProfile, - ) - } if customProvider.Model != "" { t.Fatalf("expected custom provider default model to be empty, got %q", customProvider.Model) } @@ -360,10 +340,8 @@ func TestLoaderIgnoresDirectoriesWithoutProviderYAML(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: chat_completions ` if err := os.WriteFile(filepath.Join(validDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -413,10 +391,9 @@ func TestLoaderRejectsInvalidCustomProviderModelSource(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY model_source: manul -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -440,15 +417,13 @@ func TestLoaderLoadManualModelSourceIgnoresDiscoveryFields(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY model_source: manual models: - id: manual-model name: Manual Model -openai_compatible: - base_url: https://llm.example.com/v1 - discovery_endpoint_path: /models - discovery_response_profile: invalid-profile +discovery_endpoint_path: /models ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -468,12 +443,6 @@ openai_compatible: if loadedProvider.DiscoveryEndpointPath != "" { t.Fatalf("expected manual mode to clear discovery endpoint path, got %q", loadedProvider.DiscoveryEndpointPath) } - if loadedProvider.DiscoveryResponseProfile != "" { - t.Fatalf( - "expected manual mode to clear discovery response profile, got %q", - loadedProvider.DiscoveryResponseProfile, - ) - } } func TestLoaderDefaultsModelSourceToDiscoverForLegacyCustomProvider(t *testing.T) { @@ -488,12 +457,11 @@ func TestLoaderDefaultsModelSourceToDiscoverForLegacyCustomProvider(t *testing.T providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY models: - id: manual-model name: Manual Model -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -513,16 +481,9 @@ openai_compatible: if loadedProvider.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { t.Fatalf("expected discovery endpoint to use default /models, got %q", loadedProvider.DiscoveryEndpointPath) } - if loadedProvider.DiscoveryResponseProfile != provider.DiscoveryResponseProfileOpenAI { - t.Fatalf( - "expected discovery response profile %q, got %q", - provider.DiscoveryResponseProfileOpenAI, - loadedProvider.DiscoveryResponseProfile, - ) - } } -func TestLoaderRejectsTopLevelDiscoverySettingsForKnownDriver(t *testing.T) { +func TestLoaderAllowsTopLevelDiscoverySettingsForKnownDriver(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -534,21 +495,28 @@ func TestLoaderRejectsTopLevelDiscoverySettingsForKnownDriver(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY discovery_endpoint_path: /models -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) } - if _, err := loader.Load(context.Background()); err == nil || !strings.Contains(err.Error(), "openai_compatible") { - t.Fatalf("expected known driver top-level discovery placement error, got %v", err) + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("expected top-level discovery settings to load, got %v", err) + } + customProvider, err := cfg.ProviderByName("company-gateway") + if err != nil { + t.Fatalf("ProviderByName(company-gateway) error = %v", err) + } + if customProvider.DiscoveryEndpointPath != "/models" { + t.Fatalf("expected discovery endpoint /models, got %q", customProvider.DiscoveryEndpointPath) } } -func TestLoaderRejectsCustomProviderDefaultModel(t *testing.T) { +func TestLoaderIgnoresCustomProviderDefaultModelField(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -560,11 +528,9 @@ func TestLoaderRejectsCustomProviderDefaultModel(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 default_model: deepseek-coder api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: chat_completions ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -572,7 +538,7 @@ openai_compatible: _, err := loader.Load(context.Background()) if err == nil || !strings.Contains(err.Error(), "field default_model not found") { - t.Fatalf("expected unknown field rejection for default_model, got %v", err) + t.Fatalf("expected unknown default_model field rejection, got %v", err) } } @@ -588,10 +554,8 @@ func TestLoaderIgnoresCustomProviderModelsYAML(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: chat_completions ` modelsYAML := ` models: @@ -630,11 +594,10 @@ func TestLoaderRejectsCustomProviderModelWithoutID(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY models: - name: DeepSeek Coder -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -658,13 +621,12 @@ func TestLoaderRejectsCustomProviderModelWithInvalidContextWindow(t *testing.T) providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder name: DeepSeek Coder context_window: 0 -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -688,13 +650,12 @@ func TestLoaderRejectsCustomProviderModelWithInvalidMaxOutputTokens(t *testing.T providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder name: DeepSeek Coder max_output_tokens: 0 -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -718,14 +679,13 @@ func TestLoaderRejectsCustomProviderDuplicateModelID(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder name: DeepSeek Coder - id: DeepSeek-Coder name: DeepSeek Coder Duplicate -openai_compatible: - base_url: https://llm.example.com/v1 ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -811,10 +771,8 @@ func TestLoaderRejectsCustomProviderNameConflictingWithBuiltin(t *testing.T) { providerYAML := ` name: openai driver: openaicompat +base_url: https://api.example.com/v1 api_key_env: OPENAI_GATEWAY_API_KEY -openai_compatible: - base_url: https://api.example.com/v1 - api_style: chat_completions ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -841,18 +799,14 @@ func TestLoaderRejectsDuplicateCustomProviderEndpointIdentity(t *testing.T) { providerA := ` name: gateway-a driver: openaicompat +base_url: https://api.example.com/v1/ api_key_env: GATEWAY_A_API_KEY -openai_compatible: - base_url: https://api.example.com/v1/ - api_style: responses ` providerB := ` name: gateway-b driver: openaicompat +base_url: https://API.EXAMPLE.COM/v1 api_key_env: GATEWAY_B_API_KEY -openai_compatible: - base_url: https://API.EXAMPLE.COM/v1 - api_style: Responses ` if err := os.WriteFile(filepath.Join(customA, customProviderConfigName), []byte(strings.TrimSpace(providerA)+"\n"), 0o644); err != nil { t.Fatalf("write provider a: %v", err) @@ -886,10 +840,8 @@ shell: powershell providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: responses gemini: base_url: https://gemini.example.com/v1beta deployment_mode: vertex @@ -901,26 +853,9 @@ anthropic: t.Fatalf("write provider.yaml: %v", err) } - cfg, err := loader.Load(context.Background()) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - - customProvider, err := cfg.ProviderByName("company-gateway") - if err != nil { - t.Fatalf("ProviderByName(company-gateway) error = %v", err) - } - if customProvider.BaseURL != "https://llm.example.com/v1" { - t.Fatalf("expected openai-compatible base url, got %q", customProvider.BaseURL) - } - if customProvider.APIStyle != "responses" { - t.Fatalf("expected openai-compatible api_style, got %q", customProvider.APIStyle) - } - if customProvider.DeploymentMode != "" { - t.Fatalf("expected gemini deployment_mode to be ignored, got %q", customProvider.DeploymentMode) - } - if customProvider.APIVersion != "" { - t.Fatalf("expected anthropic api_version to be ignored, got %q", customProvider.APIVersion) + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "field gemini not found") { + t.Fatalf("expected strict field rejection for legacy driver blocks, got %v", err) } } @@ -944,9 +879,6 @@ shell: powershell name: company-gateway driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY -gemini: - base_url: https://gemini.example.com/v1beta - deployment_mode: vertex ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) @@ -958,209 +890,58 @@ gemini: } } -func TestResolveCustomProviderSettingsByDriver(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - file customProviderFile - want customProviderSettings - }{ - { - name: "openaicompat prefers protocol block fields", - file: customProviderFile{ - Driver: "openaicompat", - OpenAICompatible: customOpenAICompatibleFile{ - BaseURL: " https://llm.example.com/v1 ", - APIStyle: " responses ", - }, - Gemini: customGeminiProviderFile{ - BaseURL: "https://gemini.example.com", - DeploymentMode: "vertex", - }, - }, - want: customProviderSettings{ - BaseURL: "https://llm.example.com/v1", - APIStyle: "responses", - }, - }, - { - name: "gemini uses base_url only from gemini block", - file: customProviderFile{ - Driver: "gemini", - BaseURL: " https://gateway.example.com ", - Gemini: customGeminiProviderFile{ - BaseURL: "https://gemini.example.com", - DeploymentMode: " vertex ", - }, - Anthropic: customAnthropicProviderFile{ - APIVersion: "2023-06-01", - }, - }, - want: customProviderSettings{ - BaseURL: "https://gemini.example.com", - DeploymentMode: "vertex", - }, - }, - { - name: "anthropic uses api version only from anthropic block", - file: customProviderFile{ - Driver: "anthropic", - Anthropic: customAnthropicProviderFile{ - BaseURL: " https://anthropic.example.com/v1 ", - APIVersion: " 2023-06-01 ", - }, - }, - want: customProviderSettings{ - BaseURL: "https://anthropic.example.com/v1", - APIVersion: "2023-06-01", - }, - }, - { - name: "unknown driver ignores protocol blocks", - file: customProviderFile{ - Driver: "custom-driver", - BaseURL: " https://custom.example.com/v1 ", - Gemini: customGeminiProviderFile{ - BaseURL: " https://gemini.example.com/v1beta ", - }, - Anthropic: customAnthropicProviderFile{ - BaseURL: "https://anthropic.example.com/v1", - }, - }, - want: customProviderSettings{ - BaseURL: "https://custom.example.com/v1", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := resolveCustomProviderSettings(tt.file) - expected := tt.want - if expected.ModelSource == "" { - expected.ModelSource = provider.ModelSourceDiscover - } - if got != expected { - t.Fatalf("resolveCustomProviderSettings() = %+v, want %+v", got, expected) - } - }) - } -} - func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { t.Parallel() baseDir := t.TempDir() tests := []struct { - name string - driver string - baseURL string - apiStyle string - deploymentMode string - apiVersion string - discoveryEndpointPath string - discoveryResponseProfile string - assert func(t *testing.T, cfg ProviderConfig) + name string + driver string + baseURL string + discoveryEndpointPath string + assert func(t *testing.T, cfg ProviderConfig) }{ { - name: "openaicompat settings", - driver: provider.DriverOpenAICompat, - baseURL: "https://llm.example.com/v1", - apiStyle: provider.OpenAICompatibleAPIStyleResponses, - deploymentMode: "ignored", - apiVersion: "ignored", - discoveryEndpointPath: "/gateway/models", - discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, + name: "openaicompat settings", + driver: provider.DriverOpenAICompat, + baseURL: "https://llm.example.com/v1", + discoveryEndpointPath: "/gateway/models", assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() - if cfg.APIStyle != provider.OpenAICompatibleAPIStyleResponses { - t.Fatalf("expected APIStyle=%q, got %q", provider.OpenAICompatibleAPIStyleResponses, cfg.APIStyle) - } if cfg.DiscoveryEndpointPath != "/gateway/models" { t.Fatalf("expected DiscoveryEndpointPath=/gateway/models, got %q", cfg.DiscoveryEndpointPath) } - if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { - t.Fatalf( - "expected DiscoveryResponseProfile=%q, got %q", - provider.DiscoveryResponseProfileGeneric, - cfg.DiscoveryResponseProfile, - ) - } - if cfg.DeploymentMode != "" || cfg.APIVersion != "" { - t.Fatalf("expected non-openai specific settings to be empty, got %+v", cfg) - } }, }, { - name: "gemini settings", - driver: provider.DriverGemini, - baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - apiStyle: "ignored", - deploymentMode: "vertex", - apiVersion: "ignored", - discoveryEndpointPath: "/models", - discoveryResponseProfile: provider.DiscoveryResponseProfileGemini, + name: "gemini settings", + driver: provider.DriverGemini, + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + discoveryEndpointPath: "/models", assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() - if cfg.DeploymentMode != "vertex" { - t.Fatalf("expected DeploymentMode=vertex, got %q", cfg.DeploymentMode) - } if cfg.DiscoveryEndpointPath != "/models" { t.Fatalf("expected DiscoveryEndpointPath=/models, got %q", cfg.DiscoveryEndpointPath) } - if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGemini { - t.Fatalf( - "expected DiscoveryResponseProfile=%q, got %q", - provider.DiscoveryResponseProfileGemini, - cfg.DiscoveryResponseProfile, - ) - } - if cfg.APIStyle != provider.OpenAICompatibleAPIStyleChatCompletions || cfg.APIVersion != "" { - t.Fatalf("expected gemini legacy api_style and empty api_version, got %+v", cfg) - } }, }, { - name: "anthropic settings", - driver: provider.DriverAnthropic, - baseURL: "https://api.anthropic.com/v1", - apiStyle: "ignored", - deploymentMode: "ignored", - apiVersion: "2023-06-01", - discoveryEndpointPath: "/models", - discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, + name: "anthropic settings", + driver: provider.DriverAnthropic, + baseURL: "https://api.anthropic.com/v1", + discoveryEndpointPath: "/models", assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() - if cfg.APIVersion != "2023-06-01" { - t.Fatalf("expected APIVersion=2023-06-01, got %q", cfg.APIVersion) - } if cfg.DiscoveryEndpointPath != "/models" { t.Fatalf("expected DiscoveryEndpointPath=/models, got %q", cfg.DiscoveryEndpointPath) } - if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { - t.Fatalf( - "expected DiscoveryResponseProfile=%q, got %q", - provider.DiscoveryResponseProfileGeneric, - cfg.DiscoveryResponseProfile, - ) - } - if cfg.APIStyle != "" || cfg.DeploymentMode != "" { - t.Fatalf("expected non-anthropic specific settings to be empty, got %+v", cfg) - } }, }, { - name: "unknown driver keeps top-level base url", - driver: "custom-driver", - baseURL: "https://custom.example.com/v1", - apiStyle: "responses", - deploymentMode: "vertex", - apiVersion: "2023-06-01", - discoveryEndpointPath: "/catalog/models", - discoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, + name: "unknown driver keeps top-level base url", + driver: "custom-driver", + baseURL: "https://custom.example.com/v1", + discoveryEndpointPath: "/catalog/models", assert: func(t *testing.T, cfg ProviderConfig) { t.Helper() if cfg.BaseURL != "https://custom.example.com/v1" { @@ -1169,16 +950,6 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { if cfg.DiscoveryEndpointPath != "/catalog/models" { t.Fatalf("expected DiscoveryEndpointPath=/catalog/models, got %q", cfg.DiscoveryEndpointPath) } - if cfg.DiscoveryResponseProfile != provider.DiscoveryResponseProfileGeneric { - t.Fatalf( - "expected DiscoveryResponseProfile=%q, got %q", - provider.DiscoveryResponseProfileGeneric, - cfg.DiscoveryResponseProfile, - ) - } - if cfg.APIStyle != "" || cfg.DeploymentMode != "" || cfg.APIVersion != "" { - t.Fatalf("expected unknown driver protocol settings to be empty, got %+v", cfg) - } }, }, } @@ -1196,11 +967,8 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { tt.driver, tt.baseURL, apiKeyEnv, - tt.apiStyle, - tt.deploymentMode, - tt.apiVersion, tt.discoveryEndpointPath, - tt.discoveryResponseProfile, + "/chat/completions", ); err != nil { t.Fatalf("SaveCustomProviderWithModels() error = %v", err) } @@ -1244,11 +1012,8 @@ func TestSaveCustomProviderRejectsUnsafeProviderName(t *testing.T) { provider.DriverOpenAICompat, "https://llm.example.com/v1", "CUSTOM_API_KEY", - provider.OpenAICompatibleAPIStyleChatCompletions, - "", - "", - "", "", + "/chat/completions", ) if err == nil { t.Fatalf("expected SaveCustomProviderWithModels to reject %q", name) @@ -1285,8 +1050,8 @@ func TestSaveCustomProviderRejectsModelWithoutName(t *testing.T) { {ID: "manual-model-1"}, }, }) - if err == nil || !strings.Contains(err.Error(), "models[0].name is empty") { - t.Fatalf("expected empty model name validation error, got %v", err) + if err != nil { + t.Fatalf("expected model name optional in SaveCustomProviderWithModels, got %v", err) } } @@ -1344,7 +1109,7 @@ func TestSaveCustomProviderManualModelsPersistOptionalFields(t *testing.T) { if cfg.ModelSource != provider.ModelSourceManual { t.Fatalf("expected model source manual, got %q", cfg.ModelSource) } - if cfg.DiscoveryEndpointPath != "" || cfg.DiscoveryResponseProfile != "" { + if cfg.DiscoveryEndpointPath != "" { t.Fatalf("expected discovery settings to be empty in manual mode, got %+v", cfg) } if len(cfg.Models) != 2 { @@ -1548,11 +1313,8 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { provider.DriverOpenAICompat, "https://llm.example.com/v1", "TEAM_GATEWAY_API_KEY", - provider.OpenAICompatibleAPIStyleChatCompletions, - "", - "", - "", "", + "/chat/completions", ) if err == nil { t.Fatal("expected create provider dir error") @@ -1572,11 +1334,8 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { provider.DriverOpenAICompat, "https://llm.example.com/v1", "TEAM_GATEWAY_API_KEY", - provider.OpenAICompatibleAPIStyleChatCompletions, - "", - "", - "", "", + "/chat/completions", ) if err == nil { t.Fatal("expected write provider error") @@ -1598,8 +1357,6 @@ 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) @@ -1620,12 +1377,9 @@ gemini: 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) { +func TestLoaderIgnoresUnknownCustomProviderField(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -1637,10 +1391,9 @@ func TestLoaderRejectsUnknownCustomProviderField(t *testing.T) { providerYAML := ` name: company-gateway driver: openaicompat +base_url: https://llm.example.com/v1 api_key_env: COMPANY_GATEWAY_API_KEY -openai_compatible: - profile: generic - base_url: https://llm.example.com/v1 +profile: generic ` if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { t.Fatalf("write provider.yaml: %v", err) diff --git a/internal/config/provider.go b/internal/config/provider.go index 6a91f9fd..b07c0c3f 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -19,25 +19,17 @@ const ( ) type ProviderConfig struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` - ModelSource string `yaml:"-"` - ChatProtocol string `yaml:"-"` - ChatEndpointPath string `yaml:"-"` - DiscoveryProtocol string `yaml:"-"` - AuthStrategy string `yaml:"-"` - ResponseProfile string `yaml:"-"` - APIStyle string `yaml:"-"` - DeploymentMode string `yaml:"-"` - APIVersion string `yaml:"-"` - DiscoveryEndpointPath string `yaml:"-"` - DiscoveryResponseProfile string `yaml:"-"` - ModelFieldAliases string `yaml:"-"` - Models []providertypes.ModelDescriptor `yaml:"-"` - Source ProviderSource `yaml:"-"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` + ModelSource string `yaml:"-"` + ChatEndpointPath string `yaml:"-"` + DiscoveryEndpointPath string `yaml:"-"` + ModelFieldAliases string `yaml:"-"` + Models []providertypes.ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` } type ResolvedProviderConfig struct { @@ -87,23 +79,11 @@ func (p ProviderConfig) Validate() error { return fmt.Errorf("provider %q manual model source requires non-empty models", p.Name) } - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( - p.Driver, - p.ChatProtocol, - p.ChatEndpointPath, - p.DiscoveryProtocol, - p.DiscoveryEndpointPath, - p.AuthStrategy, - p.ResponseProfile, - p.APIStyle, - p.DiscoveryResponseProfile, - ) + normalizedProtocols, err := normalizeProviderProtocolSettingsFromConfig(p) if err != nil { return fmt.Errorf("provider %q: %w", p.Name, err) } - if _, err := provider.NormalizeProviderDiscoveryResponseProfile(normalizedProtocols.ResponseProfile); err != nil { - return fmt.Errorf("provider %q: %w", p.Name, err) - } + _ = normalizedProtocols if _, err := p.Identity(); err != nil { return fmt.Errorf("provider %q: %w", p.Name, err) } @@ -203,71 +183,54 @@ func normalizeProviderDriver(driver string) string { // providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( - cfg.Driver, - cfg.ChatProtocol, - cfg.ChatEndpointPath, - cfg.DiscoveryProtocol, - cfg.DiscoveryEndpointPath, - cfg.AuthStrategy, - cfg.ResponseProfile, - cfg.APIStyle, - cfg.DiscoveryResponseProfile, - ) + normalizedProtocols, err := normalizeProviderProtocolSettingsFromConfig(cfg) if err != nil { return provider.ProviderIdentity{}, err } return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ - Driver: cfg.Driver, - BaseURL: cfg.BaseURL, - ChatProtocol: normalizedProtocols.ChatProtocol, - ChatEndpointPath: normalizedProtocols.ChatEndpointPath, - DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, - AuthStrategy: normalizedProtocols.AuthStrategy, - ResponseProfile: normalizedProtocols.ResponseProfile, - APIStyle: normalizedProtocols.LegacyAPIStyle, - DeploymentMode: cfg.DeploymentMode, - APIVersion: cfg.APIVersion, - DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, - DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, + Driver: cfg.Driver, + BaseURL: cfg.BaseURL, + ChatProtocol: normalizedProtocols.ChatProtocol, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, + AuthStrategy: normalizedProtocols.AuthStrategy, + ResponseProfile: normalizedProtocols.ResponseProfile, + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, }) } // ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { - normalizedProtocols, _ := provider.NormalizeProviderProtocolSettings( - p.Driver, - p.ChatProtocol, - p.ChatEndpointPath, - p.DiscoveryProtocol, - p.DiscoveryEndpointPath, - p.AuthStrategy, - p.ResponseProfile, - p.APIStyle, - p.DiscoveryResponseProfile, - ) + normalizedProtocols, _ := normalizeProviderProtocolSettingsFromConfig(p.ProviderConfig) baseURL := sanitizeRuntimeBaseURL(p.BaseURL) return provider.RuntimeConfig{ - Name: p.Name, - Driver: p.Driver, - BaseURL: baseURL, - DefaultModel: p.Model, - APIKey: p.APIKey, - ChatProtocol: normalizedProtocols.ChatProtocol, - ChatEndpointPath: normalizedProtocols.ChatEndpointPath, - DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, - AuthStrategy: normalizedProtocols.AuthStrategy, - ResponseProfile: normalizedProtocols.ResponseProfile, - APIStyle: normalizedProtocols.LegacyAPIStyle, - DeploymentMode: p.DeploymentMode, - APIVersion: p.APIVersion, - DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, - DiscoveryResponseProfile: normalizedProtocols.ResponseProfile, - ModelFieldAliases: p.ModelFieldAliases, + Name: p.Name, + Driver: p.Driver, + BaseURL: baseURL, + DefaultModel: p.Model, + APIKey: p.APIKey, + ChatEndpointPath: normalizedProtocols.ChatEndpointPath, + DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, + ModelFieldAliases: p.ModelFieldAliases, } } +// normalizeProviderProtocolSettingsFromConfig 根据最小外部字段推导 provider 内部协议配置。 +func normalizeProviderProtocolSettingsFromConfig(cfg ProviderConfig) (provider.NormalizedProtocolSettings, error) { + return provider.NormalizeProviderProtocolSettings( + cfg.Driver, + "", + cfg.ChatEndpointPath, + "", + cfg.DiscoveryEndpointPath, + "", + "", + "", + "", + ) +} + // sanitizeRuntimeBaseURL 对运行时 base_url 做最小安全规整,确保不会透传 userinfo 等敏感片段。 func sanitizeRuntimeBaseURL(raw string) string { normalized, err := provider.NormalizeProviderBaseURL(raw) @@ -310,84 +273,60 @@ const ( // OpenAIProvider returns the builtin OpenAI provider definition. func OpenAIProvider() ProviderConfig { return ProviderConfig{ - Name: OpenAIName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenAIDefaultBaseURL, - Model: OpenAIDefaultModel, - APIKeyEnv: OpenAIDefaultAPIKeyEnv, - ModelSource: provider.ModelSourceDiscover, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", - DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileOpenAI, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, - Source: ProviderSourceBuiltin, + Name: OpenAIName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, + ChatEndpointPath: "/chat/completions", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + Source: ProviderSourceBuiltin, } } // GeminiProvider returns the builtin Gemini provider definition. func GeminiProvider() ProviderConfig { return ProviderConfig{ - Name: GeminiName, - Driver: provider.DriverGemini, - BaseURL: GeminiDefaultBaseURL, - Model: GeminiDefaultModel, - APIKeyEnv: GeminiDefaultAPIKeyEnv, - ModelSource: provider.ModelSourceDiscover, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", - DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileGemini, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileGemini, - Source: ProviderSourceBuiltin, + Name: GeminiName, + Driver: provider.DriverGemini, + BaseURL: GeminiDefaultBaseURL, + Model: GeminiDefaultModel, + APIKeyEnv: GeminiDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, + ChatEndpointPath: "/chat/completions", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + Source: ProviderSourceBuiltin, } } // OpenLLProvider returns the builtin OpenLL provider definition. func OpenLLProvider() ProviderConfig { return ProviderConfig{ - Name: OpenLLName, - Driver: provider.DriverOpenAICompat, - BaseURL: OpenLLDefaultBaseURL, - Model: OpenLLDefaultModel, - APIKeyEnv: OpenLLDefaultAPIKeyEnv, - ModelSource: provider.ModelSourceDiscover, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", - DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileOpenAI, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, - Source: ProviderSourceBuiltin, + Name: OpenLLName, + Driver: provider.DriverOpenAICompat, + BaseURL: OpenLLDefaultBaseURL, + Model: OpenLLDefaultModel, + APIKeyEnv: OpenLLDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, + ChatEndpointPath: "/chat/completions", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + Source: ProviderSourceBuiltin, } } // QiniuProvider returns the builtin Qiniu provider definition. func QiniuProvider() ProviderConfig { return ProviderConfig{ - Name: QiniuName, - Driver: provider.DriverOpenAICompat, - BaseURL: QiniuDefaultBaseURL, - Model: QiniuDefaultModel, - APIKeyEnv: QiniuDefaultAPIKeyEnv, - ModelSource: provider.ModelSourceDiscover, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", - DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileOpenAI, - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, - Source: ProviderSourceBuiltin, + Name: QiniuName, + Driver: provider.DriverOpenAICompat, + BaseURL: QiniuDefaultBaseURL, + Model: QiniuDefaultModel, + APIKeyEnv: QiniuDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, + ChatEndpointPath: "/chat/completions", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + Source: ProviderSourceBuiltin, } } diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 77b71377..2b2d3f06 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -2,7 +2,6 @@ package config import ( "bytes" - "encoding/json" "errors" "fmt" "os" @@ -10,9 +9,10 @@ import ( "sort" "strings" - "gopkg.in/yaml.v3" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" + + "gopkg.in/yaml.v3" ) const ( @@ -21,23 +21,14 @@ const ( ) type customProviderFile struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - APIKeyEnv string `yaml:"api_key_env"` - ModelSource string `yaml:"model_source,omitempty"` - BaseURL string `yaml:"base_url,omitempty"` - ChatProtocol string `yaml:"chat_protocol,omitempty"` - ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` - DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` - AuthStrategy string `yaml:"auth_strategy,omitempty"` - ResponseProfile string `yaml:"response_profile,omitempty"` - DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` - DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` - ModelFieldAliases map[string][]string `yaml:"model_field_aliases,omitempty"` - Models []customProviderModelFile `yaml:"models,omitempty"` - OpenAICompatible customOpenAICompatibleFile `yaml:"openai_compatible,omitempty"` - Gemini customGeminiProviderFile `yaml:"gemini,omitempty"` - Anthropic customAnthropicProviderFile `yaml:"anthropic,omitempty"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + APIKeyEnv string `yaml:"api_key_env"` + ModelSource string `yaml:"model_source,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + Models []customProviderModelFile `yaml:"models,omitempty"` } type customProviderModelFile struct { @@ -47,58 +38,6 @@ type customProviderModelFile struct { MaxOutputTokens *int `yaml:"max_output_tokens,omitempty"` } -type customOpenAICompatibleFile struct { - BaseURL string `yaml:"base_url"` - ChatProtocol string `yaml:"chat_protocol,omitempty"` - ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` - DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` - AuthStrategy string `yaml:"auth_strategy,omitempty"` - ResponseProfile string `yaml:"response_profile,omitempty"` - APIStyle string `yaml:"api_style,omitempty"` - DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` - DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` -} - -type customGeminiProviderFile struct { - BaseURL string `yaml:"base_url,omitempty"` - ChatProtocol string `yaml:"chat_protocol,omitempty"` - ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` - DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` - AuthStrategy string `yaml:"auth_strategy,omitempty"` - ResponseProfile string `yaml:"response_profile,omitempty"` - DeploymentMode string `yaml:"deployment_mode,omitempty"` - DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` - DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` -} - -type customAnthropicProviderFile struct { - BaseURL string `yaml:"base_url,omitempty"` - ChatProtocol string `yaml:"chat_protocol,omitempty"` - ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` - DiscoveryProtocol string `yaml:"discovery_protocol,omitempty"` - AuthStrategy string `yaml:"auth_strategy,omitempty"` - ResponseProfile string `yaml:"response_profile,omitempty"` - APIVersion string `yaml:"api_version,omitempty"` - DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` - DiscoveryResponseProfile string `yaml:"discovery_response_profile,omitempty"` -} - -type customProviderSettings struct { - ModelSource string - BaseURL string - ChatProtocol string - ChatEndpointPath string - DiscoveryProtocol string - AuthStrategy string - ResponseProfile string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string - ModelFieldAliases string -} - // loadCustomProviders 扫描 baseDir/providers 下的一层子目录,并将其中的 provider.yaml 解析为运行时配置。 func loadCustomProviders(baseDir string) ([]ProviderConfig, error) { providersDir := filepath.Join(strings.TrimSpace(baseDir), providersDirName) @@ -159,73 +98,57 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { if err := decoder.Decode(&file); err != nil { return ProviderConfig{}, fmt.Errorf("config: parse %s: %w", providerPath, err) } - if err := validateCustomProviderDiscoveryFieldPlacement(file); err != nil { - return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) - } - if err := validateCustomProviderModelSource(file.ModelSource); err != nil { - return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) - } - settings := resolveCustomProviderSettings(file) models, err := customProviderModels(file.Models) if err != nil { return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) } cfg := ProviderConfig{ - Name: strings.TrimSpace(file.Name), - Driver: strings.TrimSpace(file.Driver), - BaseURL: settings.BaseURL, - APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), - ModelSource: settings.ModelSource, - ChatProtocol: settings.ChatProtocol, - ChatEndpointPath: settings.ChatEndpointPath, - DiscoveryProtocol: settings.DiscoveryProtocol, - AuthStrategy: settings.AuthStrategy, - ResponseProfile: settings.ResponseProfile, - APIStyle: settings.APIStyle, - DeploymentMode: settings.DeploymentMode, - APIVersion: settings.APIVersion, - DiscoveryEndpointPath: settings.DiscoveryEndpointPath, - DiscoveryResponseProfile: settings.DiscoveryResponseProfile, - ModelFieldAliases: settings.ModelFieldAliases, - Models: models, - Source: ProviderSourceCustom, + Name: strings.TrimSpace(file.Name), + Driver: strings.TrimSpace(file.Driver), + BaseURL: strings.TrimSpace(file.BaseURL), + APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), + ModelSource: provider.NormalizeModelSource(strings.TrimSpace(file.ModelSource)), + ChatEndpointPath: strings.TrimSpace(file.ChatEndpointPath), + DiscoveryEndpointPath: strings.TrimSpace(file.DiscoveryEndpointPath), + Models: models, + Source: ProviderSourceCustom, + } + if rawModelSource := strings.TrimSpace(file.ModelSource); rawModelSource != "" && cfg.ModelSource == "" { + return ProviderConfig{}, fmt.Errorf( + "config: custom provider %q: unsupported model_source %q", + filepath.Base(providerDir), + rawModelSource, + ) + } + if cfg.ModelSource == "" { + cfg.ModelSource = provider.ModelSourceDiscover } normalizedDiscoveryEndpointPath := cfg.DiscoveryEndpointPath - normalizedDiscoveryResponseProfile := cfg.DiscoveryResponseProfile - if provider.NormalizeModelSource(cfg.ModelSource) == provider.ModelSourceManual { + if cfg.ModelSource == provider.ModelSourceManual { normalizedDiscoveryEndpointPath = "" - normalizedDiscoveryResponseProfile = "" } - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( cfg.Driver, - cfg.ChatProtocol, + "", cfg.ChatEndpointPath, - cfg.DiscoveryProtocol, + "", normalizedDiscoveryEndpointPath, - cfg.AuthStrategy, - cfg.ResponseProfile, - cfg.APIStyle, - normalizedDiscoveryResponseProfile, + "", + "", + "", + "", ) if err != nil { return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) } - cfg.ChatProtocol = normalizedProtocols.ChatProtocol cfg.ChatEndpointPath = normalizedProtocols.ChatEndpointPath - cfg.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol - cfg.AuthStrategy = normalizedProtocols.AuthStrategy - cfg.ResponseProfile = normalizedProtocols.ResponseProfile - cfg.APIStyle = normalizedProtocols.LegacyAPIStyle - if provider.NormalizeModelSource(cfg.ModelSource) == provider.ModelSourceManual { + if cfg.ModelSource == provider.ModelSourceManual { cfg.DiscoveryEndpointPath = "" - cfg.DiscoveryResponseProfile = "" } else { cfg.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - cfg.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile } if err := cfg.Validate(); err != nil { @@ -281,118 +204,16 @@ func customProviderModels(models []customProviderModelFile) ([]providertypes.Mod return providertypes.MergeModelDescriptors(descriptors), nil } -// resolveCustomProviderSettings 根据 driver 只提取当前协议真正生效的配置字段,避免误吃其他协议块的值。 -// 已知 driver 仅从协议块读取 base_url;未知 driver 使用顶层 base_url 作为唯一入口。 -func resolveCustomProviderSettings(file customProviderFile) customProviderSettings { - settings := customProviderSettings{ - ModelSource: provider.NormalizeModelSource(strings.TrimSpace(file.ModelSource)), - } - - switch normalizeProviderDriver(file.Driver) { - case provider.DriverOpenAICompat: - settings.BaseURL = strings.TrimSpace(file.OpenAICompatible.BaseURL) - settings.ChatProtocol = strings.TrimSpace(file.OpenAICompatible.ChatProtocol) - settings.ChatEndpointPath = strings.TrimSpace(file.OpenAICompatible.ChatEndpointPath) - settings.DiscoveryProtocol = strings.TrimSpace(file.OpenAICompatible.DiscoveryProtocol) - settings.AuthStrategy = strings.TrimSpace(file.OpenAICompatible.AuthStrategy) - settings.ResponseProfile = strings.TrimSpace(file.OpenAICompatible.ResponseProfile) - settings.APIStyle = strings.TrimSpace(file.OpenAICompatible.APIStyle) - settings.DiscoveryEndpointPath = strings.TrimSpace(file.OpenAICompatible.DiscoveryEndpointPath) - settings.DiscoveryResponseProfile = strings.TrimSpace(file.OpenAICompatible.DiscoveryResponseProfile) - case provider.DriverGemini: - settings.BaseURL = strings.TrimSpace(file.Gemini.BaseURL) - settings.ChatProtocol = strings.TrimSpace(file.Gemini.ChatProtocol) - settings.ChatEndpointPath = strings.TrimSpace(file.Gemini.ChatEndpointPath) - settings.DiscoveryProtocol = strings.TrimSpace(file.Gemini.DiscoveryProtocol) - settings.AuthStrategy = strings.TrimSpace(file.Gemini.AuthStrategy) - settings.ResponseProfile = strings.TrimSpace(file.Gemini.ResponseProfile) - settings.DeploymentMode = strings.TrimSpace(file.Gemini.DeploymentMode) - settings.DiscoveryEndpointPath = strings.TrimSpace(file.Gemini.DiscoveryEndpointPath) - settings.DiscoveryResponseProfile = strings.TrimSpace(file.Gemini.DiscoveryResponseProfile) - case provider.DriverAnthropic: - settings.BaseURL = strings.TrimSpace(file.Anthropic.BaseURL) - settings.ChatProtocol = strings.TrimSpace(file.Anthropic.ChatProtocol) - settings.ChatEndpointPath = strings.TrimSpace(file.Anthropic.ChatEndpointPath) - settings.DiscoveryProtocol = strings.TrimSpace(file.Anthropic.DiscoveryProtocol) - settings.AuthStrategy = strings.TrimSpace(file.Anthropic.AuthStrategy) - settings.ResponseProfile = strings.TrimSpace(file.Anthropic.ResponseProfile) - settings.APIVersion = strings.TrimSpace(file.Anthropic.APIVersion) - settings.DiscoveryEndpointPath = strings.TrimSpace(file.Anthropic.DiscoveryEndpointPath) - settings.DiscoveryResponseProfile = strings.TrimSpace(file.Anthropic.DiscoveryResponseProfile) - default: - settings.BaseURL = strings.TrimSpace(file.BaseURL) - settings.ChatProtocol = strings.TrimSpace(file.ChatProtocol) - settings.ChatEndpointPath = strings.TrimSpace(file.ChatEndpointPath) - settings.DiscoveryProtocol = strings.TrimSpace(file.DiscoveryProtocol) - settings.AuthStrategy = strings.TrimSpace(file.AuthStrategy) - settings.ResponseProfile = strings.TrimSpace(file.ResponseProfile) - settings.DiscoveryEndpointPath = strings.TrimSpace(file.DiscoveryEndpointPath) - settings.DiscoveryResponseProfile = strings.TrimSpace(file.DiscoveryResponseProfile) - } - settings.ModelFieldAliases = encodeModelFieldAliases(file.ModelFieldAliases) - if settings.ModelSource == "" { - settings.ModelSource = provider.ModelSourceDiscover - } - - return settings -} - -// validateCustomProviderModelSource 校验 model_source 字段;仅允许缺省或合法枚举值,拒绝静默回退。 -func validateCustomProviderModelSource(raw string) error { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - if provider.NormalizeModelSource(trimmed) == "" { - return fmt.Errorf("unsupported model_source %q", raw) - } - return nil -} - -// validateCustomProviderDiscoveryFieldPlacement 校验 discovery 字段写在与 driver 对应的协议块中,避免静默忽略。 -func validateCustomProviderDiscoveryFieldPlacement(file customProviderFile) error { - topLevelChatProtocol := strings.TrimSpace(file.ChatProtocol) - topLevelChatEndpointPath := strings.TrimSpace(file.ChatEndpointPath) - topLevelDiscoveryProtocol := strings.TrimSpace(file.DiscoveryProtocol) - topLevelAuthStrategy := strings.TrimSpace(file.AuthStrategy) - topLevelResponseProfile := strings.TrimSpace(file.ResponseProfile) - topLevelDiscoveryEndpointPath := strings.TrimSpace(file.DiscoveryEndpointPath) - topLevelDiscoveryResponseProfile := strings.TrimSpace(file.DiscoveryResponseProfile) - if topLevelChatProtocol == "" && - topLevelChatEndpointPath == "" && - topLevelDiscoveryProtocol == "" && - topLevelAuthStrategy == "" && - topLevelResponseProfile == "" && - topLevelDiscoveryEndpointPath == "" && - topLevelDiscoveryResponseProfile == "" { - return nil - } - - switch normalizeProviderDriver(file.Driver) { - case provider.DriverOpenAICompat: - return errors.New("openaicompat discovery settings must be configured under openai_compatible") - case provider.DriverGemini: - return errors.New("gemini discovery settings must be configured under gemini") - case provider.DriverAnthropic: - return errors.New("anthropic discovery settings must be configured under anthropic") - default: - return nil - } -} - // SaveCustomProviderInput 定义自定义 Provider 的持久化字段。 type SaveCustomProviderInput struct { - Name string - Driver string - BaseURL string - APIKeyEnv string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string - ModelSource string - Models []providertypes.ModelDescriptor + Name string + Driver string + BaseURL string + ChatEndpointPath string + APIKeyEnv string + DiscoveryEndpointPath string + ModelSource string + Models []providertypes.ModelDescriptor } // SaveCustomProviderWithModels 保存自定义 provider,并可在 manual 模式下写入手工模型列表。 @@ -400,28 +221,20 @@ func SaveCustomProviderWithModels(baseDir string, input SaveCustomProviderInput) name := input.Name driver := input.Driver baseURL := input.BaseURL + chatEndpointPath := strings.TrimSpace(input.ChatEndpointPath) apiKeyEnv := input.APIKeyEnv - apiStyle := input.APIStyle - deploymentMode := input.DeploymentMode - apiVersion := input.APIVersion discoveryEndpointPath := strings.TrimSpace(input.DiscoveryEndpointPath) - discoveryResponseProfile := strings.TrimSpace(input.DiscoveryResponseProfile) - modelSource := provider.NormalizeModelSource(input.ModelSource) - if strings.TrimSpace(input.ModelSource) != "" && modelSource == "" { - return fmt.Errorf("config: unsupported model_source %q", input.ModelSource) + rawModelSource := strings.TrimSpace(input.ModelSource) + modelSource := provider.NormalizeModelSource(rawModelSource) + if rawModelSource != "" && modelSource == "" { + return fmt.Errorf("config: provider %q unsupported model_source %q", strings.TrimSpace(name), rawModelSource) } if modelSource == "" { modelSource = provider.ModelSourceDiscover } - normalizedInputModels, err := validateModelDescriptorsRequireName(input.Models) - if err != nil { - return err - } - normalizedModels := providertypes.MergeModelDescriptors(normalizedInputModels) if modelSource == provider.ModelSourceManual { discoveryEndpointPath = "" - discoveryResponseProfile = "" - if len(normalizedModels) == 0 { + if len(providertypes.MergeModelDescriptors(input.Models)) == 0 { return fmt.Errorf("config: provider %q manual model source requires non-empty models", strings.TrimSpace(name)) } } @@ -430,12 +243,31 @@ func SaveCustomProviderWithModels(baseDir string, input SaveCustomProviderInput) return err } + normalizedDriver := normalizeProviderDriver(driver) + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + normalizedDriver, + "", + chatEndpointPath, + "", + discoveryEndpointPath, + "", + "", + "", + "", + ) + if err != nil { + return fmt.Errorf("config: normalize provider protocol settings: %w", err) + } + chatEndpointPath = normalizedProtocols.ChatEndpointPath + if modelSource == provider.ModelSourceDiscover { + discoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + } + providersDir := filepath.Join(baseDir, providersDirName, name) if err := os.MkdirAll(providersDir, 0o755); err != nil { return fmt.Errorf("config: create provider dir: %w", err) } - normalizedDriver := normalizeProviderDriver(driver) cfg := customProviderFile{ Name: name, Driver: normalizedDriver, @@ -443,54 +275,11 @@ func SaveCustomProviderWithModels(baseDir string, input SaveCustomProviderInput) ModelSource: modelSource, } - switch normalizedDriver { - case provider.DriverOpenAICompat: - chatEndpointPath := "/chat/completions" - if provider.NormalizeProviderAPIStyle(apiStyle) == provider.OpenAICompatibleAPIStyleResponses { - chatEndpointPath = "/responses" - } - cfg.OpenAICompatible = customOpenAICompatibleFile{ - BaseURL: baseURL, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: chatEndpointPath, - DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: discoveryResponseProfile, - APIStyle: strings.TrimSpace(apiStyle), - DiscoveryEndpointPath: discoveryEndpointPath, - DiscoveryResponseProfile: discoveryResponseProfile, - } - case provider.DriverGemini: - cfg.Gemini = customGeminiProviderFile{ - BaseURL: baseURL, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", - DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: discoveryResponseProfile, - DeploymentMode: strings.TrimSpace(deploymentMode), - DiscoveryEndpointPath: discoveryEndpointPath, - DiscoveryResponseProfile: discoveryResponseProfile, - } - case provider.DriverAnthropic: - cfg.Anthropic = customAnthropicProviderFile{ - BaseURL: baseURL, - ChatProtocol: provider.ChatProtocolAnthropicMessages, - ChatEndpointPath: "/messages", - DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, - AuthStrategy: provider.AuthStrategyAnthropic, - ResponseProfile: discoveryResponseProfile, - APIVersion: strings.TrimSpace(apiVersion), - DiscoveryEndpointPath: discoveryEndpointPath, - DiscoveryResponseProfile: discoveryResponseProfile, - } - default: - cfg.BaseURL = baseURL - cfg.DiscoveryEndpointPath = discoveryEndpointPath - cfg.DiscoveryResponseProfile = discoveryResponseProfile - } + cfg.BaseURL = baseURL + cfg.ChatEndpointPath = chatEndpointPath + cfg.DiscoveryEndpointPath = discoveryEndpointPath if modelSource == provider.ModelSourceManual { - cfg.Models = toCustomProviderModelFiles(normalizedModels) + cfg.Models = toCustomProviderModelFiles(input.Models) } data, err := yaml.Marshal(cfg) @@ -559,18 +348,6 @@ func toCustomProviderModelFiles(models []providertypes.ModelDescriptor) []custom return items } -// encodeModelFieldAliases 将 model field aliases 序列化为稳定字符串,供运行时跨层传递。 -func encodeModelFieldAliases(aliases map[string][]string) string { - if len(aliases) == 0 { - return "" - } - encoded, err := json.Marshal(aliases) - if err != nil { - return "" - } - return string(encoded) -} - // DeleteCustomProvider 删除自定义 provider。 func DeleteCustomProvider(baseDir string, name string) error { if err := validateCustomProviderName(name); err != nil { @@ -594,7 +371,7 @@ func validateCustomProviderName(name string) error { if trimmed == "." || trimmed == ".." { return fmt.Errorf("config: provider name %q is invalid", name) } - if strings.ContainsAny(trimmed, `/\`) { + if strings.ContainsAny(trimmed, `/\\`) { return fmt.Errorf("config: provider name %q is invalid", name) } if filepath.IsAbs(trimmed) { diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 407cdd11..ceaefd72 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -66,14 +66,12 @@ func TestProviderConfigIdentity(t *testing.T) { t.Parallel() cfg := ProviderConfig{ - Name: "test-openai", - Driver: "openaicompat", - BaseURL: "https://api.openai.com/v1", - Model: "gpt-4o", - APIKeyEnv: "TEST_KEY", - APIStyle: "chat_completions", - DiscoveryEndpointPath: "models", - DiscoveryResponseProfile: "openai", + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o", + APIKeyEnv: "TEST_KEY", + DiscoveryEndpointPath: "models", } identity, err := cfg.Identity() @@ -161,9 +159,6 @@ func TestQiniuProviderConfig(t *testing.T) { if provider.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { t.Fatalf("expected discovery endpoint %q, got %q", providerpkg.DiscoveryEndpointPathModels, provider.DiscoveryEndpointPath) } - if provider.DiscoveryResponseProfile != providerpkg.DiscoveryResponseProfileOpenAI { - t.Fatalf("expected discovery profile openai, got %q", provider.DiscoveryResponseProfile) - } } func TestNormalizeConfigKey(t *testing.T) { @@ -221,9 +216,10 @@ func TestProviderConfigValidateRejectsInvalidDiscoverySettings(t *testing.T) { } cfg.DiscoveryEndpointPath = "/models" - cfg.DiscoveryResponseProfile = "not-supported" - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "discovery response profile") { - t.Fatalf("expected invalid discovery response profile error, got %v", err) + cfg.ChatEndpointPath = "https://api.openai.com/chat/completions" + cfg.Model = "gpt-4.1" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "must be a relative path") { + t.Fatalf("expected invalid chat endpoint path error, got %v", err) } } @@ -512,34 +508,23 @@ func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { resolved := ResolvedProviderConfig{ ProviderConfig: ProviderConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - Model: "server-default", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + Model: "server-default", }, APIKey: "secret-key", } got := resolved.ToRuntimeConfig() want := providerpkg.RuntimeConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - DefaultModel: "server-default", - APIKey: "secret-key", - ChatProtocol: providerpkg.ChatProtocolOpenAIResponses, - ChatEndpointPath: "/responses", - DiscoveryProtocol: providerpkg.DiscoveryProtocolOpenAIModels, - AuthStrategy: providerpkg.AuthStrategyBearer, - ResponseProfile: providerpkg.DiscoveryResponseProfileOpenAI, - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", - DiscoveryEndpointPath: providerpkg.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: providerpkg.DiscoveryResponseProfileOpenAI, + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + DefaultModel: "server-default", + APIKey: "secret-key", + ChatEndpointPath: "", + DiscoveryEndpointPath: providerpkg.DiscoveryEndpointPathModels, } if got != want { diff --git a/internal/config/runtime.go b/internal/config/runtime.go index a71b6de6..9835fb5d 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -2,6 +2,8 @@ package config import ( "errors" + + providertypes "neo-code/internal/provider/types" ) const ( @@ -11,8 +13,15 @@ const ( // RuntimeConfig 定义 runtime 层的可调参数。 type RuntimeConfig struct { - MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` - MaxRepeatCycleStreak int `yaml:"max_repeat_cycle_streak,omitempty"` + MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` + MaxRepeatCycleStreak int `yaml:"max_repeat_cycle_streak,omitempty"` + Assets RuntimeAssetsConfig `yaml:"assets,omitempty"` +} + +// RuntimeAssetsConfig 定义运行时对 session_asset 的大小限制。 +type RuntimeAssetsConfig struct { + MaxSessionAssetBytes int64 `yaml:"max_session_asset_bytes,omitempty"` + MaxSessionAssetsTotalBytes int64 `yaml:"max_session_assets_total_bytes,omitempty"` } // defaultRuntimeConfig 返回 runtime 配置的静态默认值。 @@ -20,12 +29,25 @@ func defaultRuntimeConfig() RuntimeConfig { return RuntimeConfig{ MaxNoProgressStreak: DefaultMaxNoProgressStreak, MaxRepeatCycleStreak: DefaultMaxRepeatCycleStreak, + Assets: defaultRuntimeAssetsConfig(), + } +} + +// defaultRuntimeAssetsConfig 返回 runtime 附件限制配置默认值。 +func defaultRuntimeAssetsConfig() RuntimeAssetsConfig { + return RuntimeAssetsConfig{ + MaxSessionAssetBytes: providertypes.MaxSessionAssetBytes, + MaxSessionAssetsTotalBytes: providertypes.MaxSessionAssetsTotalBytes, } } // Clone 复制 runtime 配置,避免调用方共享可变状态。 func (c RuntimeConfig) Clone() RuntimeConfig { - return c + return RuntimeConfig{ + MaxNoProgressStreak: c.MaxNoProgressStreak, + MaxRepeatCycleStreak: c.MaxRepeatCycleStreak, + Assets: c.Assets.Clone(), + } } // ApplyDefaults 在配置缺失或非法时回填默认阈值。 @@ -39,6 +61,7 @@ func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { if c.MaxRepeatCycleStreak <= 0 { c.MaxRepeatCycleStreak = defaults.MaxRepeatCycleStreak } + c.Assets.ApplyDefaults(defaults.Assets) } // Validate 校验 runtime 配置是否满足最小约束。 @@ -49,5 +72,50 @@ func (c RuntimeConfig) Validate() error { if c.MaxRepeatCycleStreak <= 0 { return errors.New("max_repeat_cycle_streak must be greater than 0") } + if err := c.Assets.Validate(); err != nil { + return err + } return nil } + +// ResolveSessionAssetLimits 归一化 runtime 附件限制并施加代码硬上限兜底。 +func (c RuntimeConfig) ResolveSessionAssetLimits() providertypes.SessionAssetLimits { + return c.Assets.ResolveSessionAssetLimits() +} + +// Clone 复制附件限制配置,避免调用方共享可变状态。 +func (c RuntimeAssetsConfig) Clone() RuntimeAssetsConfig { + return c +} + +// ApplyDefaults 在配置缺失或非法时回填附件限制默认值。 +func (c *RuntimeAssetsConfig) ApplyDefaults(defaults RuntimeAssetsConfig) { + if c == nil { + return + } + if c.MaxSessionAssetBytes <= 0 { + c.MaxSessionAssetBytes = defaults.MaxSessionAssetBytes + } + if c.MaxSessionAssetsTotalBytes <= 0 { + c.MaxSessionAssetsTotalBytes = defaults.MaxSessionAssetsTotalBytes + } +} + +// Validate 校验附件限制配置是否满足最小约束。 +func (c RuntimeAssetsConfig) Validate() error { + if c.MaxSessionAssetBytes < 0 { + return errors.New("runtime.assets.max_session_asset_bytes must be greater than or equal to 0") + } + if c.MaxSessionAssetsTotalBytes < 0 { + return errors.New("runtime.assets.max_session_assets_total_bytes must be greater than or equal to 0") + } + return nil +} + +// ResolveSessionAssetLimits 归一化附件限制并应用代码硬上限。 +func (c RuntimeAssetsConfig) ResolveSessionAssetLimits() providertypes.SessionAssetLimits { + return providertypes.NormalizeSessionAssetLimits(providertypes.SessionAssetLimits{ + MaxSessionAssetBytes: c.MaxSessionAssetBytes, + MaxSessionAssetsTotalBytes: c.MaxSessionAssetsTotalBytes, + }) +} diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index bd493427..23f0b711 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -13,12 +13,29 @@ func TestRuntimeConfigClone(t *testing.T) { if cloned.MaxRepeatCycleStreak != 4 { t.Fatalf("expected cloned MaxRepeatCycleStreak=4, got %d", cloned.MaxRepeatCycleStreak) } + if cloned.Assets.MaxSessionAssetBytes != cfg.Assets.MaxSessionAssetBytes { + t.Fatalf("expected cloned MaxSessionAssetBytes=%d, got %d", cfg.Assets.MaxSessionAssetBytes, cloned.Assets.MaxSessionAssetBytes) + } + if cloned.Assets.MaxSessionAssetsTotalBytes != cfg.Assets.MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected cloned MaxSessionAssetsTotalBytes=%d, got %d", + cfg.Assets.MaxSessionAssetsTotalBytes, + cloned.Assets.MaxSessionAssetsTotalBytes, + ) + } } func TestRuntimeConfigApplyDefaults(t *testing.T) { t.Parallel() - defaults := RuntimeConfig{MaxNoProgressStreak: 3, MaxRepeatCycleStreak: 5} + defaults := RuntimeConfig{ + MaxNoProgressStreak: 3, + MaxRepeatCycleStreak: 5, + Assets: RuntimeAssetsConfig{ + MaxSessionAssetBytes: 11, + MaxSessionAssetsTotalBytes: 22, + }, + } cfg := RuntimeConfig{MaxNoProgressStreak: 0, MaxRepeatCycleStreak: 0} cfg.ApplyDefaults(defaults) @@ -28,6 +45,12 @@ func TestRuntimeConfigApplyDefaults(t *testing.T) { if cfg.MaxRepeatCycleStreak != 5 { t.Fatalf("expected defaulted MaxRepeatCycleStreak=5, got %d", cfg.MaxRepeatCycleStreak) } + if cfg.Assets.MaxSessionAssetBytes != 11 { + t.Fatalf("expected defaulted MaxSessionAssetBytes=11, got %d", cfg.Assets.MaxSessionAssetBytes) + } + if cfg.Assets.MaxSessionAssetsTotalBytes != 22 { + t.Fatalf("expected defaulted MaxSessionAssetsTotalBytes=22, got %d", cfg.Assets.MaxSessionAssetsTotalBytes) + } cfg = RuntimeConfig{MaxNoProgressStreak: 5, MaxRepeatCycleStreak: 8} cfg.ApplyDefaults(defaults) @@ -66,4 +89,35 @@ func TestRuntimeConfigValidate(t *testing.T) { t.Fatalf("expected validation error for MaxRepeatCycleStreak=%d", bad) } } + + if err := (RuntimeConfig{ + MaxNoProgressStreak: 1, + MaxRepeatCycleStreak: 1, + Assets: RuntimeAssetsConfig{ + MaxSessionAssetBytes: 1, + MaxSessionAssetsTotalBytes: 1, + }, + }).Validate(); err != nil { + t.Fatalf("expected valid assets config, got %v", err) + } + if err := (RuntimeConfig{ + MaxNoProgressStreak: 1, + MaxRepeatCycleStreak: 1, + Assets: RuntimeAssetsConfig{ + MaxSessionAssetBytes: -1, + MaxSessionAssetsTotalBytes: 1, + }, + }).Validate(); err == nil { + t.Fatal("expected validation error for assets.max_session_asset_bytes=-1") + } + if err := (RuntimeConfig{ + MaxNoProgressStreak: 1, + MaxRepeatCycleStreak: 1, + Assets: RuntimeAssetsConfig{ + MaxSessionAssetBytes: 1, + MaxSessionAssetsTotalBytes: -1, + }, + }).Validate(); err == nil { + t.Fatal("expected validation error for assets.max_session_assets_total_bytes=-1") + } } diff --git a/internal/config/state/model_test.go b/internal/config/state/model_test.go index b2a641f8..c31de2c7 100644 --- a/internal/config/state/model_test.go +++ b/internal/config/state/model_test.go @@ -18,7 +18,6 @@ func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *test BaseURL: "https://API.EXAMPLE.COM/v1/", Model: "server-default", APIKeyEnv: "CATALOG_PROVIDER_API_KEY", - APIStyle: " Responses ", Models: []providertypes.ModelDescriptor{ {ID: " model-a ", Name: " Model A "}, }, @@ -36,7 +35,7 @@ func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *test if input.Identity.BaseURL != "https://api.example.com/v1" { t.Fatalf("expected normalized base URL, got %+v", input.Identity) } - if input.Identity.APIStyle != "responses" { + if input.Identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { t.Fatalf("expected normalized api_style, got %+v", input.Identity) } if input.Identity.DiscoveryEndpointPath != providerpkg.DiscoveryEndpointPathModels { @@ -64,9 +63,6 @@ func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *test if runtimeConfig.DefaultModel != "server-default" || runtimeConfig.APIKey != "secret-key" { t.Fatalf("expected runtime config to resolve model and api key, got %+v", runtimeConfig) } - if runtimeConfig.APIStyle != providerpkg.OpenAICompatibleAPIStyleResponses { - t.Fatalf("expected runtime config api_style to be normalized, got %+v", runtimeConfig) - } } func TestCatalogInputFromProviderDefaultsOpenAICompatibleIdentityAPIStyle(t *testing.T) { @@ -106,13 +102,6 @@ func TestCatalogInputFromProviderDefaultsOpenAICompatibleIdentityAPIStyle(t *tes ) } - runtimeConfig, err := input.ResolveDiscoveryConfig() - if err != nil { - t.Fatalf("ResolveDiscoveryConfig() error = %v", err) - } - if runtimeConfig.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { - t.Fatalf("expected runtime config to inherit default api_style, got %+v", runtimeConfig) - } } func TestCatalogInputFromProviderCustomOmitsDefaultModels(t *testing.T) { diff --git a/internal/config/state/service_provider_create.go b/internal/config/state/service_provider_create.go index 6b412a01..aeaf084f 100644 --- a/internal/config/state/service_provider_create.go +++ b/internal/config/state/service_provider_create.go @@ -31,33 +31,27 @@ const providerCreateCrossProcessLockHeartbeatInterval = 2 * time.Second // CreateCustomProviderInput 定义新增自定义 Provider 所需的输入参数。 type CreateCustomProviderInput struct { - Name string - Driver string - BaseURL string - APIKeyEnv string - APIKey string - ModelSource string - ManualModelsJSON string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string + Name string + Driver string + BaseURL string + ChatEndpointPath string + APIKeyEnv string + APIKey string + ModelSource string + ManualModelsJSON string + DiscoveryEndpointPath string } type createCustomProviderNormalizedInput struct { - Name string - Driver string - BaseURL string - APIKeyEnv string - APIKey string - ModelSource string - ManualModels []providertypes.ModelDescriptor - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string + Name string + Driver string + BaseURL string + ChatEndpointPath string + APIKeyEnv string + APIKey string + ModelSource string + ManualModels []providertypes.ModelDescriptor + DiscoveryEndpointPath string } type providerConfigSnapshot struct { @@ -136,17 +130,14 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr providerSaveAttempted = true if err := saveCustomProviderWithModelsForCreate(s.manager.BaseDir(), config.SaveCustomProviderInput{ - Name: normalized.Name, - Driver: normalized.Driver, - BaseURL: normalized.BaseURL, - APIKeyEnv: normalized.APIKeyEnv, - ModelSource: normalized.ModelSource, - Models: normalized.ManualModels, - APIStyle: normalized.APIStyle, - DeploymentMode: normalized.DeploymentMode, - APIVersion: normalized.APIVersion, - DiscoveryEndpointPath: normalized.DiscoveryEndpointPath, - DiscoveryResponseProfile: normalized.DiscoveryResponseProfile, + Name: normalized.Name, + Driver: normalized.Driver, + BaseURL: normalized.BaseURL, + ChatEndpointPath: normalized.ChatEndpointPath, + APIKeyEnv: normalized.APIKeyEnv, + ModelSource: normalized.ModelSource, + Models: normalized.ManualModels, + DiscoveryEndpointPath: normalized.DiscoveryEndpointPath, }); err != nil { return Selection{}, rollback(fmt.Errorf("selection: save provider config: %w", err)) } @@ -176,19 +167,18 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr // normalizeCreateCustomProviderInput 统一裁剪新增 Provider 输入并执行基础字段校验。 func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (createCustomProviderNormalizedInput, error) { - rawModelSource := strings.TrimSpace(input.ModelSource) normalized := createCustomProviderNormalizedInput{ - Name: strings.TrimSpace(input.Name), - Driver: strings.TrimSpace(input.Driver), - BaseURL: strings.TrimSpace(input.BaseURL), - APIKeyEnv: strings.TrimSpace(input.APIKeyEnv), - APIKey: strings.TrimSpace(input.APIKey), - ModelSource: provider.NormalizeModelSource(rawModelSource), - APIStyle: strings.TrimSpace(input.APIStyle), - DeploymentMode: strings.TrimSpace(input.DeploymentMode), - APIVersion: strings.TrimSpace(input.APIVersion), - DiscoveryEndpointPath: strings.TrimSpace(input.DiscoveryEndpointPath), - DiscoveryResponseProfile: strings.TrimSpace(input.DiscoveryResponseProfile), + Name: strings.TrimSpace(input.Name), + Driver: strings.TrimSpace(input.Driver), + BaseURL: strings.TrimSpace(input.BaseURL), + ChatEndpointPath: strings.TrimSpace(input.ChatEndpointPath), + APIKeyEnv: strings.TrimSpace(input.APIKeyEnv), + APIKey: strings.TrimSpace(input.APIKey), + ModelSource: provider.NormalizeModelSource(strings.TrimSpace(input.ModelSource)), + DiscoveryEndpointPath: strings.TrimSpace(input.DiscoveryEndpointPath), + } + if rawModelSource := strings.TrimSpace(input.ModelSource); rawModelSource != "" && normalized.ModelSource == "" { + return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: unsupported model source %q", input.ModelSource) } if err := config.ValidateCustomProviderName(normalized.Name); err != nil { @@ -206,37 +196,24 @@ func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (create if config.IsProtectedEnvVarName(normalized.APIKeyEnv) { return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: env key %q is protected", normalized.APIKeyEnv) } - if rawModelSource != "" && normalized.ModelSource == "" { - return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: unsupported model source %q", input.ModelSource) - } if normalized.ModelSource == "" { normalized.ModelSource = provider.ModelSourceDiscover } - normalizedDiscoveryEndpointPath := normalized.DiscoveryEndpointPath - normalizedDiscoveryResponseProfile := normalized.DiscoveryResponseProfile - if provider.NormalizeModelSource(normalized.ModelSource) == provider.ModelSourceManual { - normalizedDiscoveryEndpointPath = "" - normalizedDiscoveryResponseProfile = "" - } normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( normalized.Driver, "", + normalized.ChatEndpointPath, + "", + normalized.DiscoveryEndpointPath, "", "", - normalizedDiscoveryEndpointPath, "", "", - normalized.APIStyle, - normalizedDiscoveryResponseProfile, ) if err != nil { return createCustomProviderNormalizedInput{}, err } - if normalized.Driver == provider.DriverOpenAICompat { - normalized.APIStyle = normalizedProtocols.LegacyAPIStyle - } else { - normalized.APIStyle = "" - } + normalized.ChatEndpointPath = normalizedProtocols.ChatEndpointPath switch provider.NormalizeModelSource(normalized.ModelSource) { case provider.ModelSourceManual: manualModels, parseErr := parseManualModelsJSON(input.ManualModelsJSON) @@ -245,10 +222,8 @@ func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (create } normalized.ManualModels = manualModels normalized.DiscoveryEndpointPath = "" - normalized.DiscoveryResponseProfile = "" case provider.ModelSourceDiscover: normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile default: return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: unsupported model source %q", input.ModelSource) } diff --git a/internal/config/state/service_provider_create_test.go b/internal/config/state/service_provider_create_test.go index b33a6294..50c8e977 100644 --- a/internal/config/state/service_provider_create_test.go +++ b/internal/config/state/service_provider_create_test.go @@ -35,7 +35,6 @@ func TestCreateCustomProviderSuccess(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "COMPANY_GATEWAY_API_KEY", APIKey: "test-key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } restore := captureEnvForCreateProvider(t, input.APIKeyEnv) @@ -192,14 +191,13 @@ func TestNormalizeCreateCustomProviderInputRejectsInvalidModelSource(t *testing. func TestNormalizeCreateCustomProviderInputManualSkipsDiscoveryFieldValidation(t *testing.T) { normalized, err := normalizeCreateCustomProviderInput(CreateCustomProviderInput{ - Name: "manual-no-discovery-validation", - Driver: provider.DriverOpenAICompat, - BaseURL: "https://llm.example.com/v1", - APIKeyEnv: "MANUAL_NO_DISCOVERY_VALIDATION_API_KEY", - APIKey: "test-key", - ModelSource: provider.ModelSourceManual, - DiscoveryResponseProfile: "invalid-profile", - ManualModelsJSON: `[{"id":"manual-model","name":"Manual Model"}]`, + Name: "manual-no-discovery-validation", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_NO_DISCOVERY_VALIDATION_API_KEY", + APIKey: "test-key", + ModelSource: provider.ModelSourceManual, + ManualModelsJSON: `[{"id":"manual-model","name":"Manual Model"}]`, }) if err != nil { t.Fatalf("normalizeCreateCustomProviderInput() error = %v", err) @@ -207,12 +205,6 @@ func TestNormalizeCreateCustomProviderInputManualSkipsDiscoveryFieldValidation(t if normalized.DiscoveryEndpointPath != "" { t.Fatalf("expected manual mode to clear discovery endpoint path, got %q", normalized.DiscoveryEndpointPath) } - if normalized.DiscoveryResponseProfile != "" { - t.Fatalf( - "expected manual mode to clear discovery response profile, got %q", - normalized.DiscoveryResponseProfile, - ) - } if len(normalized.ManualModels) != 1 { t.Fatalf("expected one manual model, got %d", len(normalized.ManualModels)) } @@ -234,7 +226,6 @@ func TestCreateCustomProviderRollbackOnSelectFailure(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "ROLLBACK_GATEWAY_API_KEY", APIKey: "new-key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } restore := captureEnvForCreateProvider(t, input.APIKeyEnv) @@ -278,7 +269,6 @@ func TestCreateCustomProviderRejectsEnvConflicts(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: configpkg.OpenAIDefaultAPIKeyEnv, APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, }) if err == nil || !strings.Contains(err.Error(), "duplicates provider") { t.Fatalf("expected duplicate env error, got %v", err) @@ -303,7 +293,6 @@ func TestCreateCustomProviderRejectsDuplicateCustomProviderName(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "DUPLICATE_CUSTOM_PROVIDER_A_API_KEY", APIKey: "key-a", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } restoreA := captureEnvForCreateProvider(t, firstInput.APIKeyEnv) defer restoreA() @@ -341,7 +330,6 @@ func TestCreateCustomProviderRejectsProtectedEnvName(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "PATH", APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, }) if err == nil || !strings.Contains(err.Error(), "protected") { t.Fatalf("expected protected env error, got %v", err) @@ -366,7 +354,6 @@ func TestCreateCustomProviderRejectsInvalidProviderName(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "INVALID_PROVIDER_NAME_API_KEY", APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, }) if err == nil || !strings.Contains(err.Error(), "provider name") { t.Fatalf("expected invalid provider name error, got %v", err) @@ -405,7 +392,6 @@ func TestCreateCustomProviderSerializesAcrossServicesSharingManager(t *testing.T BaseURL: "https://shared.example.com/v1", APIKeyEnv: "SHARED_GATEWAY_API_KEY", APIKey: "key-a", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } inputB := inputA inputB.APIKey = "key-b" @@ -484,7 +470,6 @@ func TestCreateCustomProviderRollbackOnSaveProviderFailure(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "SAVE_FAILED_PROVIDER_API_KEY", APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } saveCustomProviderWithModelsForCreate = func(baseDir string, input configpkg.SaveCustomProviderInput) error { @@ -553,7 +538,6 @@ func TestCreateCustomProviderSerializesAcrossManagersSharingBaseDir(t *testing.T BaseURL: "https://shared.example.com/v1", APIKeyEnv: "SHARED_BY_MANAGERS_API_KEY", APIKey: "key-a", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } inputB := inputA inputB.APIKey = "key-b" @@ -661,7 +645,6 @@ func TestCreateCustomProviderRejectsInvalidDiscoverySettings(t *testing.T) { BaseURL: "https://llm.example.com/v1", APIKeyEnv: "INVALID_DISCOVERY_PROVIDER_API_KEY", APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, DiscoveryEndpointPath: "https://llm.example.com/models", }) if err == nil || !strings.Contains(err.Error(), "discovery endpoint path") { @@ -669,17 +652,15 @@ func TestCreateCustomProviderRejectsInvalidDiscoverySettings(t *testing.T) { } _, err = service.CreateCustomProvider(context.Background(), CreateCustomProviderInput{ - Name: "invalid-discovery-profile-provider", - Driver: provider.DriverOpenAICompat, - BaseURL: "https://llm.example.com/v1", - APIKeyEnv: "INVALID_DISCOVERY_PROFILE_PROVIDER_API_KEY", - APIKey: "key", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DiscoveryEndpointPath: "/models", - DiscoveryResponseProfile: "unsupported", + Name: "invalid-chat-endpoint-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "INVALID_CHAT_ENDPOINT_PROVIDER_API_KEY", + APIKey: "key", + ChatEndpointPath: "https://llm.example.com/chat/completions", }) - if err == nil || !strings.Contains(err.Error(), "discovery response profile") { - t.Fatalf("expected invalid discovery response profile error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "must be a relative path") { + t.Fatalf("expected invalid chat endpoint path error, got %v", err) } } diff --git a/internal/config/state/service_test.go b/internal/config/state/service_test.go index 0ef876cf..96fa4014 100644 --- a/internal/config/state/service_test.go +++ b/internal/config/state/service_test.go @@ -81,7 +81,6 @@ func TestSelectionServiceBuiltinUnsupportedAPIStyleNoLongerFailsAcrossSnapshotPa t.Parallel() providerCfg := openAIProviderForTest() - providerCfg.APIStyle = "responses" defaults := testDefaultConfig() defaults.Providers = []configpkg.ProviderConfig{providerCfg} diff --git a/internal/provider/anthropic/driver.go b/internal/provider/anthropic/driver.go index 7e55ab96..45771d2d 100644 --- a/internal/provider/anthropic/driver.go +++ b/internal/provider/anthropic/driver.go @@ -2,12 +2,12 @@ package anthropic import ( "context" + "fmt" "net/http" "time" "neo-code/internal/provider" httpdiscovery "neo-code/internal/provider/discovery/http" - "neo-code/internal/provider/openaicompat" providertypes "neo-code/internal/provider/types" ) @@ -16,35 +16,31 @@ const DriverName = provider.DriverAnthropic // Driver 返回 Anthropic 协议驱动定义。 func Driver() provider.DriverDefinition { - compatDriver := openaicompat.Driver() - return provider.DriverDefinition{ Name: DriverName, Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { - normalized, err := normalizeRuntimeConfig(cfg) - if err != nil { - return nil, err - } - if normalized.ChatProtocol == provider.ChatProtocolAnthropicMessages { - return nil, provider.NewDiscoveryConfigError("anthropic driver: chat_protocol anthropic_messages is not supported yet") - } - normalized.Driver = provider.DriverOpenAICompat - return compatDriver.Build(ctx, normalized) + return nil, provider.NewDiscoveryConfigError( + fmt.Sprintf("anthropic driver: chat protocol %q is not supported yet", provider.ResolveDriverProtocolDefaults(cfg.Driver).ChatProtocol), + ) }, Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { - normalized, err := normalizeRuntimeConfig(cfg) + discoveryProtocol, discoveryEndpointPath, responseProfile, err := provider.ResolveDriverDiscoveryConfig( + cfg.Driver, + cfg.DiscoveryEndpointPath, + ) if err != nil { - return nil, err + return nil, provider.NewDiscoveryConfigError(err.Error()) } + authStrategy, apiVersion := provider.ResolveDriverAuthConfig(cfg.Driver) rawModels, err := httpdiscovery.DiscoverRawModels(ctx, &http.Client{Timeout: 90 * time.Second}, httpdiscovery.RequestConfig{ - BaseURL: normalized.BaseURL, - EndpointPath: normalized.DiscoveryEndpointPath, - DiscoveryProtocol: normalized.DiscoveryProtocol, - ResponseProfile: normalized.ResponseProfile, - AuthStrategy: normalized.AuthStrategy, - APIKey: normalized.APIKey, - APIVersion: normalized.APIVersion, + BaseURL: cfg.BaseURL, + EndpointPath: discoveryEndpointPath, + DiscoveryProtocol: discoveryProtocol, + ResponseProfile: responseProfile, + AuthStrategy: authStrategy, + APIKey: cfg.APIKey, + APIVersion: apiVersion, }) if err != nil { return nil, err @@ -64,49 +60,12 @@ func Driver() provider.DriverDefinition { } } -// normalizeRuntimeConfig 统一收敛 Anthropic runtime 配置并执行组合校验。 -func normalizeRuntimeConfig(cfg provider.RuntimeConfig) (provider.RuntimeConfig, error) { - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( - cfg.Driver, - cfg.ChatProtocol, - cfg.ChatEndpointPath, - cfg.DiscoveryProtocol, - cfg.DiscoveryEndpointPath, - cfg.AuthStrategy, - cfg.ResponseProfile, - cfg.APIStyle, - cfg.DiscoveryResponseProfile, - ) - if err != nil { - return provider.RuntimeConfig{}, provider.NewDiscoveryConfigError(err.Error()) - } - - normalized := cfg - normalized.ChatProtocol = normalizedProtocols.ChatProtocol - normalized.ChatEndpointPath = normalizedProtocols.ChatEndpointPath - normalized.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol - normalized.AuthStrategy = normalizedProtocols.AuthStrategy - normalized.ResponseProfile = normalizedProtocols.ResponseProfile - normalized.APIStyle = normalizedProtocols.LegacyAPIStyle - normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile - return normalized, nil -} - -// validateCatalogIdentity 在 catalog 路径上执行 Anthropic 协议静态校验。 +// validateCatalogIdentity 在 catalog 路径上执行 Anthropic 静态校验。 func validateCatalogIdentity(identity provider.ProviderIdentity) error { - _, err := provider.NormalizeProviderProtocolSettings( - identity.Driver, - identity.ChatProtocol, - identity.ChatEndpointPath, - identity.DiscoveryProtocol, - identity.DiscoveryEndpointPath, - identity.AuthStrategy, - identity.ResponseProfile, - identity.APIStyle, - identity.DiscoveryResponseProfile, - ) - if err != nil { + if _, err := provider.NormalizeProviderChatEndpointPath(identity.ChatEndpointPath); err != nil { + return provider.NewDiscoveryConfigError(err.Error()) + } + if _, _, _, err := provider.ResolveDriverDiscoveryConfig(identity.Driver, identity.DiscoveryEndpointPath); err != nil { return provider.NewDiscoveryConfigError(err.Error()) } return nil diff --git a/internal/provider/anthropic/driver_test.go b/internal/provider/anthropic/driver_test.go index a574128a..dd13225d 100644 --- a/internal/provider/anthropic/driver_test.go +++ b/internal/provider/anthropic/driver_test.go @@ -16,36 +16,15 @@ func TestDriverBuildRejectsUnsupportedAnthropicMessages(t *testing.T) { driver := Driver() _, err := driver.Build(context.Background(), provider.RuntimeConfig{ - Driver: DriverName, - BaseURL: "https://api.anthropic.com/v1", - APIKey: "test-key", - ChatProtocol: provider.ChatProtocolAnthropicMessages, - AuthStrategy: provider.AuthStrategyAnthropic, + Driver: DriverName, + BaseURL: "https://api.anthropic.com/v1", + APIKey: "test-key", }) if err == nil { t.Fatal("expected unsupported anthropic messages error") } } -func TestDriverBuildSuccessWithOpenAICompatPath(t *testing.T) { - t.Parallel() - - driver := Driver() - p, err := driver.Build(context.Background(), provider.RuntimeConfig{ - Driver: DriverName, - BaseURL: "https://api.anthropic.com/v1", - APIKey: "test-key", - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - AuthStrategy: provider.AuthStrategyAnthropic, - }) - if err != nil { - t.Fatalf("expected build success, got %v", err) - } - if p == nil { - t.Fatal("expected non-nil provider") - } -} - func TestDriverDiscover(t *testing.T) { t.Parallel() @@ -74,10 +53,7 @@ func TestDriverDiscover(t *testing.T) { Driver: DriverName, BaseURL: server.URL, APIKey: "test-key", - DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, - ResponseProfile: provider.DiscoveryResponseProfileGeneric, DiscoveryEndpointPath: "/models", - AuthStrategy: provider.AuthStrategyAnthropic, }) if err != nil { t.Fatalf("Discover() error = %v", err) @@ -97,27 +73,19 @@ func TestDriverValidateCatalogIdentity(t *testing.T) { err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ Driver: DriverName, - ChatProtocol: provider.ChatProtocolAnthropicMessages, - DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, DiscoveryEndpointPath: "/models", - AuthStrategy: provider.AuthStrategyAnthropic, - ResponseProfile: provider.DiscoveryResponseProfileGeneric, }) if err != nil { t.Fatalf("expected valid identity, got %v", err) } }) - t.Run("invalid auth strategy for anthropic chat protocol", func(t *testing.T) { + t.Run("invalid discovery endpoint path", func(t *testing.T) { t.Parallel() err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ Driver: DriverName, - ChatProtocol: provider.ChatProtocolAnthropicMessages, - DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, - DiscoveryEndpointPath: "/models", - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileGeneric, + DiscoveryEndpointPath: "https://api.example.com/models", }) if err == nil { t.Fatal("expected discovery config error") @@ -125,7 +93,7 @@ func TestDriverValidateCatalogIdentity(t *testing.T) { if !provider.IsDiscoveryConfigError(err) { t.Fatalf("expected discovery config error, got %v", err) } - if !strings.Contains(err.Error(), "does not allow auth strategy") { + if !strings.Contains(err.Error(), "must be a relative path") { t.Fatalf("unexpected error message: %v", err) } }) diff --git a/internal/provider/catalog/additional_test.go b/internal/provider/catalog/additional_test.go index 75de6a49..ae01d4ec 100644 --- a/internal/provider/catalog/additional_test.go +++ b/internal/provider/catalog/additional_test.go @@ -151,7 +151,6 @@ func TestListProviderModelsAllowsUnsupportedOpenAICompatibleAPIStyleWhenCatalogI store := newMemoryStore() service := NewService("", registry, store) cfg := customGatewayProvider() - cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) if err := store.Save(context.Background(), ModelCatalog{ @@ -184,7 +183,6 @@ func TestListBuiltinProviderModelsAllowsUnsupportedOpenAICompatibleAPIStyleWhenC store := newMemoryStore() service := NewService("", registry, store) cfg := config.OpenAIProvider() - cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) if err := store.Save(context.Background(), ModelCatalog{ @@ -216,7 +214,6 @@ func TestListBuiltinProviderModelsSnapshotAllowsUnsupportedOpenAICompatibleAPISt service := NewService("", registry, newMemoryStore()) cfg := config.OpenAIProvider() - cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) if err := service.store.Save(context.Background(), ModelCatalog{ @@ -249,7 +246,6 @@ func TestListBuiltinProviderModelsSnapshotAndCachedAllowUnsupportedAPIStyleWithC store := newMemoryStore() service := NewService("", registry, store) cfg := config.OpenAIProvider() - cfg.APIStyle = "responses" input := mustCatalogInput(t, cfg) if err := store.Save(context.Background(), ModelCatalog{ diff --git a/internal/provider/chat_endpoint.go b/internal/provider/chat_endpoint.go new file mode 100644 index 00000000..97e70e90 --- /dev/null +++ b/internal/provider/chat_endpoint.go @@ -0,0 +1,54 @@ +package provider + +import ( + "fmt" + "strings" +) + +// ResolveChatEndpointPath 规范化聊天端点路径语义: +// 空字符串或 "/" 代表直连 base_url,其余必须是以 "/" 开头的相对路径。 +func ResolveChatEndpointPath(endpointPath string) (string, error) { + normalizedPath, err := NormalizeProviderChatEndpointPath(endpointPath) + if err != nil { + return "", err + } + if normalizedPath == "/" { + return "", nil + } + return normalizedPath, nil +} + +// ResolveChatEndpointURL 统一根据 base_url 与 chat_endpoint_path 生成聊天请求地址。 +// 当 chat_endpoint_path 为空或 "/" 时,按直连模式仅使用 base_url。 +func ResolveChatEndpointURL(baseURL string, endpointPath string) (string, error) { + normalizedPath, err := ResolveChatEndpointPath(endpointPath) + if err != nil { + return "", fmt.Errorf("provider chat endpoint path %q is invalid: %w", endpointPath, err) + } + + normalizedBaseURL, err := NormalizeProviderBaseURL(baseURL) + if err != nil { + if normalizedPath == "" { + return "", fmt.Errorf( + "provider base_url is invalid for direct chat endpoint mode (chat_endpoint_path is empty or '/'): %w", + err, + ) + } + return "", fmt.Errorf("provider base_url is invalid: %w", err) + } + + return joinEndpointURL(normalizedBaseURL, normalizedPath), nil +} + +// joinEndpointURL 将相对端点路径拼接到已规范化的 base_url。 +func joinEndpointURL(baseURL string, endpointPath string) string { + trimmedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/") + trimmedEndpointPath := strings.TrimSpace(endpointPath) + if trimmedEndpointPath == "" { + return trimmedBaseURL + } + if !strings.HasPrefix(trimmedEndpointPath, "/") { + trimmedEndpointPath = "/" + trimmedEndpointPath + } + return trimmedBaseURL + trimmedEndpointPath +} diff --git a/internal/provider/chat_endpoint_test.go b/internal/provider/chat_endpoint_test.go new file mode 100644 index 00000000..311e6813 --- /dev/null +++ b/internal/provider/chat_endpoint_test.go @@ -0,0 +1,129 @@ +package provider + +import ( + "strings" + "testing" +) + +func TestResolveChatEndpointPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "empty means direct", + input: "", + want: "", + }, + { + name: "slash means direct", + input: "/", + want: "", + }, + { + name: "relative path is normalized", + input: "chat/completions", + want: "/chat/completions", + }, + { + name: "absolute url is rejected", + input: "https://api.example.com/v1/chat/completions", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ResolveChatEndpointPath(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for input %q", tt.input) + } + return + } + if err != nil { + t.Fatalf("ResolveChatEndpointPath() error = %v", err) + } + if got != tt.want { + t.Fatalf("ResolveChatEndpointPath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveChatEndpointURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + path string + want string + wantErr string + }{ + { + name: "appends chat endpoint path", + baseURL: "https://api.example.com/v1", + path: "/chat/completions", + want: "https://api.example.com/v1/chat/completions", + }, + { + name: "direct mode with slash", + baseURL: "https://api.example.com/v1/text/chatcompletion_v2", + path: "/", + want: "https://api.example.com/v1/text/chatcompletion_v2", + }, + { + name: "direct mode with empty path", + baseURL: "https://api.example.com/v1/text/chatcompletion_v2", + path: "", + want: "https://api.example.com/v1/text/chatcompletion_v2", + }, + { + name: "invalid base url in direct mode returns actionable error", + baseURL: "api.example.com/v1", + path: "/", + wantErr: "direct chat endpoint mode", + }, + { + name: "invalid base url in append mode returns base url error", + baseURL: "api.example.com/v1", + path: "/chat/completions", + wantErr: "base_url is invalid", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ResolveChatEndpointURL(tt.baseURL, tt.path) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if got != "" { + t.Fatalf("expected empty url on error, got %q", got) + } + if want := tt.wantErr; want != "" && !strings.Contains(err.Error(), want) { + t.Fatalf("expected error containing %q, got %v", want, err) + } + return + } + if err != nil { + t.Fatalf("ResolveChatEndpointURL() error = %v", err) + } + if got != tt.want { + t.Fatalf("ResolveChatEndpointURL() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/provider/contracts.go b/internal/provider/contracts.go index 37a401d9..1e9ec348 100644 --- a/internal/provider/contracts.go +++ b/internal/provider/contracts.go @@ -8,22 +8,15 @@ import ( // RuntimeConfig 表示 provider 构建与模型发现使用的最小运行时输入。 type RuntimeConfig struct { - Name string - Driver string - BaseURL string - DefaultModel string - APIKey string - ChatProtocol string - ChatEndpointPath string - DiscoveryProtocol string - AuthStrategy string - ResponseProfile string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string - ModelFieldAliases string + Name string + Driver string + BaseURL string + DefaultModel string + APIKey string + SessionAssetLimits providertypes.SessionAssetLimits + ChatEndpointPath string + DiscoveryEndpointPath string + ModelFieldAliases string } // Provider 定义模型生成能力,通过 channel 推送流式事件给上层消费。 diff --git a/internal/provider/driver_defaults.go b/internal/provider/driver_defaults.go new file mode 100644 index 00000000..cd6a839e --- /dev/null +++ b/internal/provider/driver_defaults.go @@ -0,0 +1,63 @@ +package provider + +// DriverProtocolDefaults 描述各 driver 在运行期固定采用的协议、鉴权与发现解析默认值。 +type DriverProtocolDefaults struct { + ChatProtocol string + DiscoveryProtocol string + AuthStrategy string + ResponseProfile string + APIVersion string +} + +// ResolveDriverProtocolDefaults 根据 driver 返回运行期固定协议默认值,避免由外部配置决定协议分支。 +func ResolveDriverProtocolDefaults(driver string) DriverProtocolDefaults { + switch NormalizeProviderDriver(driver) { + case DriverGemini: + return DriverProtocolDefaults{ + ChatProtocol: ChatProtocolOpenAIChatCompletions, + DiscoveryProtocol: DiscoveryProtocolGeminiModels, + AuthStrategy: AuthStrategyBearer, + ResponseProfile: DiscoveryResponseProfileGemini, + } + case DriverAnthropic: + return DriverProtocolDefaults{ + ChatProtocol: ChatProtocolAnthropicMessages, + DiscoveryProtocol: DiscoveryProtocolAnthropicModels, + AuthStrategy: AuthStrategyAnthropic, + ResponseProfile: DiscoveryResponseProfileGeneric, + } + case DriverOpenAICompat: + return DriverProtocolDefaults{ + ChatProtocol: ChatProtocolOpenAIChatCompletions, + DiscoveryProtocol: DiscoveryProtocolOpenAIModels, + AuthStrategy: AuthStrategyBearer, + ResponseProfile: DiscoveryResponseProfileOpenAI, + } + default: + return DriverProtocolDefaults{ + ChatProtocol: ChatProtocolOpenAIChatCompletions, + DiscoveryProtocol: DiscoveryProtocolCustomHTTPJSON, + AuthStrategy: AuthStrategyBearer, + ResponseProfile: DiscoveryResponseProfileGeneric, + } + } +} + +// ResolveDriverDiscoveryConfig 解析 discovery 请求所需配置,并在端点为空时注入 driver 约定默认值。 +func ResolveDriverDiscoveryConfig(driver string, endpointPath string) (string, string, string, error) { + defaults := ResolveDriverProtocolDefaults(driver) + normalizedEndpointPath, err := NormalizeProviderDiscoveryEndpointPath(endpointPath) + if err != nil { + return "", "", "", err + } + if normalizedEndpointPath == "" { + normalizedEndpointPath = defaultDiscoveryEndpointPath(defaults.DiscoveryProtocol) + } + return defaults.DiscoveryProtocol, normalizedEndpointPath, defaults.ResponseProfile, nil +} + +// ResolveDriverAuthConfig 返回 driver 运行期鉴权策略及附加版本配置。 +func ResolveDriverAuthConfig(driver string) (string, string) { + defaults := ResolveDriverProtocolDefaults(driver) + return defaults.AuthStrategy, defaults.APIVersion +} diff --git a/internal/provider/gemini/driver.go b/internal/provider/gemini/driver.go index 40f601d1..14ca4830 100644 --- a/internal/provider/gemini/driver.go +++ b/internal/provider/gemini/driver.go @@ -18,67 +18,21 @@ func Driver() provider.DriverDefinition { return provider.DriverDefinition{ Name: DriverName, Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { - normalized, err := normalizeRuntimeConfig(cfg) - if err != nil { - return nil, err - } - return compatDriver.Build(ctx, normalized) + return compatDriver.Build(ctx, cfg) }, Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { - normalized, err := normalizeRuntimeConfig(cfg) - if err != nil { - return nil, err - } - return compatDriver.Discover(ctx, normalized) + return compatDriver.Discover(ctx, cfg) }, ValidateCatalogIdentity: validateCatalogIdentity, } } -// normalizeRuntimeConfig 统一收敛 Gemini runtime 配置并兼容映射到 openaicompat 执行路径。 -func normalizeRuntimeConfig(cfg provider.RuntimeConfig) (provider.RuntimeConfig, error) { - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( - cfg.Driver, - cfg.ChatProtocol, - cfg.ChatEndpointPath, - cfg.DiscoveryProtocol, - cfg.DiscoveryEndpointPath, - cfg.AuthStrategy, - cfg.ResponseProfile, - cfg.APIStyle, - cfg.DiscoveryResponseProfile, - ) - if err != nil { - return provider.RuntimeConfig{}, provider.NewDiscoveryConfigError(err.Error()) - } - - normalized := cfg - normalized.Driver = provider.DriverOpenAICompat - normalized.ChatProtocol = normalizedProtocols.ChatProtocol - normalized.ChatEndpointPath = normalizedProtocols.ChatEndpointPath - normalized.DiscoveryProtocol = normalizedProtocols.DiscoveryProtocol - normalized.AuthStrategy = normalizedProtocols.AuthStrategy - normalized.ResponseProfile = normalizedProtocols.ResponseProfile - normalized.APIStyle = normalizedProtocols.LegacyAPIStyle - normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile - return normalized, nil -} - -// validateCatalogIdentity 在 catalog 路径上执行 Gemini 协议静态校验,避免无效快照误导选择流程。 +// validateCatalogIdentity 在 catalog 路径上执行 Gemini 静态校验,避免无效快照误导选择流程。 func validateCatalogIdentity(identity provider.ProviderIdentity) error { - _, err := provider.NormalizeProviderProtocolSettings( - identity.Driver, - identity.ChatProtocol, - identity.ChatEndpointPath, - identity.DiscoveryProtocol, - identity.DiscoveryEndpointPath, - identity.AuthStrategy, - identity.ResponseProfile, - identity.APIStyle, - identity.DiscoveryResponseProfile, - ) - if err != nil { + if _, err := provider.NormalizeProviderChatEndpointPath(identity.ChatEndpointPath); err != nil { + return provider.NewDiscoveryConfigError(err.Error()) + } + if _, _, _, err := provider.ResolveDriverDiscoveryConfig(identity.Driver, identity.DiscoveryEndpointPath); err != nil { return provider.NewDiscoveryConfigError(err.Error()) } return nil diff --git a/internal/provider/gemini/driver_test.go b/internal/provider/gemini/driver_test.go index 194ebe2c..eaaf7e0b 100644 --- a/internal/provider/gemini/driver_test.go +++ b/internal/provider/gemini/driver_test.go @@ -29,8 +29,6 @@ func TestDriverDiscover(t *testing.T) { Driver: DriverName, BaseURL: server.URL, APIKey: "test-key", - DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, - ResponseProfile: provider.DiscoveryResponseProfileGemini, DiscoveryEndpointPath: "/models", }) if err != nil { @@ -46,11 +44,9 @@ func TestDriverBuild(t *testing.T) { driver := Driver() p, err := driver.Build(context.Background(), provider.RuntimeConfig{ - Driver: DriverName, - BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - APIKey: "test-key", - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - AuthStrategy: provider.AuthStrategyBearer, + Driver: DriverName, + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + APIKey: "test-key", }) if err != nil { t.Fatalf("Build() error = %v", err) @@ -70,27 +66,19 @@ func TestDriverValidateCatalogIdentity(t *testing.T) { err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ Driver: DriverName, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, DiscoveryEndpointPath: "/models", - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileGemini, }) if err != nil { t.Fatalf("expected valid identity, got %v", err) } }) - t.Run("invalid discovery protocol", func(t *testing.T) { + t.Run("invalid discovery endpoint path", func(t *testing.T) { t.Parallel() err := driver.ValidateCatalogIdentity(provider.ProviderIdentity{ Driver: DriverName, - ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - DiscoveryProtocol: "unknown-discovery", - DiscoveryEndpointPath: "/models", - AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: provider.DiscoveryResponseProfileGemini, + DiscoveryEndpointPath: "https://api.example.com/models", }) if err == nil { t.Fatal("expected discovery config error") @@ -98,7 +86,7 @@ func TestDriverValidateCatalogIdentity(t *testing.T) { if !provider.IsDiscoveryConfigError(err) { t.Fatalf("expected discovery config error, got %v", err) } - if !strings.Contains(err.Error(), "discovery protocol") { + if !strings.Contains(err.Error(), "must be a relative path") { t.Fatalf("unexpected error message: %v", err) } }) diff --git a/internal/provider/openaicompat/chatcompletions/provider.go b/internal/provider/openaicompat/chatcompletions/provider.go index 6dd83519..6c1b42fa 100644 --- a/internal/provider/openaicompat/chatcompletions/provider.go +++ b/internal/provider/openaicompat/chatcompletions/provider.go @@ -48,7 +48,10 @@ func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateReque return fmt.Errorf("%smarshal request: %w", shared.ErrorPrefix, err) } - endpoint := shared.Endpoint(p.cfg.BaseURL, resolveChatEndpointPath(p.cfg)) + endpoint, err := provider.ResolveChatEndpointURL(p.cfg.BaseURL, p.cfg.ChatEndpointPath) + if err != nil { + return fmt.Errorf("%sinvalid chat endpoint configuration: %w", shared.ErrorPrefix, err) + } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("%sbuild request: %w", shared.ErrorPrefix, err) @@ -73,12 +76,3 @@ func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateReque return p.ConsumeStream(ctx, resp.Body, events) } - -// resolveChatEndpointPath 统一解析聊天端点路径,优先使用 runtime 配置并在缺省时回退默认值。 -func resolveChatEndpointPath(cfg provider.RuntimeConfig) string { - normalizedPath, err := provider.NormalizeProviderChatEndpointPath(cfg.ChatEndpointPath) - if err != nil || normalizedPath == "" { - return "/chat/completions" - } - return normalizedPath -} diff --git a/internal/provider/openaicompat/chatcompletions/provider_test.go b/internal/provider/openaicompat/chatcompletions/provider_test.go index 1ab5eae8..f68fce88 100644 --- a/internal/provider/openaicompat/chatcompletions/provider_test.go +++ b/internal/provider/openaicompat/chatcompletions/provider_test.go @@ -172,6 +172,30 @@ func TestNewAndBuildRequest(t *testing.T) { t.Fatalf("expected session_asset total budget error, got %v", err) } + limitedCfg := testCfg("https://api.example.com/v1", "gpt-4.1", "test-key") + limitedCfg.SessionAssetLimits = providertypes.SessionAssetLimits{ + MaxSessionAssetBytes: 1, + MaxSessionAssetsTotalBytes: 1, + } + if _, err := BuildRequest(context.Background(), limitedCfg, providertypes.GenerateRequest{ + Messages: []providertypes.Message{ + { + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewSessionAssetImagePart("asset-limited", "image/png"), + }, + }, + }, + SessionAssetReader: stubSessionAssetReader{ + assets: map[string]stubSessionAsset{ + "asset-limited": {data: []byte("img"), mime: "image/png"}, + }, + }, + }); err == nil || (!strings.Contains(err.Error(), "session_asset total exceeds") && + !strings.Contains(err.Error(), `session_asset "asset-limited" exceeds 1 bytes`)) { + t.Fatalf("expected config driven session_asset limit error, got %v", err) + } + fallback, err := BuildRequest(context.Background(), testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{ SystemPrompt: " ", Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, @@ -389,12 +413,13 @@ func TestNewAndBuildRequest(t *testing.T) { func TestResolveSessionAssetDataURLVariants(t *testing.T) { t.Parallel() + limits := providertypes.DefaultSessionAssetLimits() if _, _, err := resolveSessionAssetDataURL(context.Background(), stubSessionAssetReader{ assets: map[string]stubSessionAsset{ "asset-no-budget": {data: []byte("x"), mime: "image/png"}, }, - }, &providertypes.AssetRef{ID: "asset-no-budget", MimeType: "image/png"}, 0); err == nil || + }, &providertypes.AssetRef{ID: "asset-no-budget", MimeType: "image/png"}, 0, limits); err == nil || !strings.Contains(err.Error(), "session_asset total exceeds") { t.Fatalf("expected no budget error, got %v", err) } @@ -403,7 +428,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { assets: map[string]stubSessionAsset{ "asset-nil-ctx": {data: []byte("x"), mime: "image/png"}, }, - }, &providertypes.AssetRef{ID: "asset-nil-ctx", MimeType: "image/png"}, maxSessionAssetsTotalBytes); err != nil { + }, &providertypes.AssetRef{ID: "asset-nil-ctx", MimeType: "image/png"}, maxSessionAssetsTotalBytes, limits); err != nil { t.Fatalf("expected nil context to fallback to background, got %v", err) } @@ -413,7 +438,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { _ = assetID return nil, "", errors.New("open failed") }, - }, &providertypes.AssetRef{ID: "asset-open-error", MimeType: "image/png"}, maxSessionAssetsTotalBytes); err == nil || !strings.Contains(err.Error(), "open session_asset") { + }, &providertypes.AssetRef{ID: "asset-open-error", MimeType: "image/png"}, maxSessionAssetsTotalBytes, limits); err == nil || !strings.Contains(err.Error(), "open session_asset") { t.Fatalf("expected wrapped open error, got %v", err) } @@ -423,7 +448,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { _ = assetID return &failingReadCloser{err: errors.New("read failed")}, "image/png", nil }, - }, &providertypes.AssetRef{ID: "asset-read-error", MimeType: "image/png"}, maxSessionAssetsTotalBytes); err == nil || !strings.Contains(err.Error(), "read session_asset") { + }, &providertypes.AssetRef{ID: "asset-read-error", MimeType: "image/png"}, maxSessionAssetsTotalBytes, limits); err == nil || !strings.Contains(err.Error(), "read session_asset") { t.Fatalf("expected wrapped read error, got %v", err) } @@ -434,7 +459,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { _ = assetID return &cancelOnReadCloser{cancel: cancel}, "image/png", nil }, - }, &providertypes.AssetRef{ID: "asset-cancel-after-read", MimeType: "image/png"}, maxSessionAssetsTotalBytes); err == nil || !strings.Contains(err.Error(), "context canceled") { + }, &providertypes.AssetRef{ID: "asset-cancel-after-read", MimeType: "image/png"}, maxSessionAssetsTotalBytes, limits); err == nil || !strings.Contains(err.Error(), "context canceled") { t.Fatalf("expected canceled context after read, got %v", err) } @@ -442,7 +467,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { assets: map[string]stubSessionAsset{ "asset-empty": {data: []byte{}, mime: "image/png"}, }, - }, &providertypes.AssetRef{ID: "asset-empty", MimeType: "image/png"}, maxSessionAssetsTotalBytes); err == nil || !strings.Contains(err.Error(), "is empty") { + }, &providertypes.AssetRef{ID: "asset-empty", MimeType: "image/png"}, maxSessionAssetsTotalBytes, limits); err == nil || !strings.Contains(err.Error(), "is empty") { t.Fatalf("expected empty asset error, got %v", err) } @@ -450,7 +475,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { assets: map[string]stubSessionAsset{ "asset-over-total": {data: []byte("12345"), mime: "image/png"}, }, - }, &providertypes.AssetRef{ID: "asset-over-total", MimeType: "image/png"}, 4); err == nil || + }, &providertypes.AssetRef{ID: "asset-over-total", MimeType: "image/png"}, 4, limits); err == nil || !strings.Contains(err.Error(), "session_asset total exceeds") { t.Fatalf("expected total budget error, got %v", err) } @@ -464,7 +489,7 @@ func TestResolveSessionAssetDataURLVariants(t *testing.T) { assets: map[string]stubSessionAsset{ "asset-negative-budget": {data: []byte("x"), mime: "image/png"}, }, - }, -1); err == nil || !strings.Contains(err.Error(), "session_asset total exceeds") { + }, -1, limits); err == nil || !strings.Contains(err.Error(), "session_asset total exceeds") { t.Fatalf("expected negative budget to fail as no budget, got %v", err) } } @@ -666,11 +691,12 @@ func TestEmitFlushMergeAndUsage(t *testing.T) { func testCfg(baseURL, model, apiKey string) provider.RuntimeConfig { return provider.RuntimeConfig{ - Name: "openaicompat", - Driver: "openaicompat", - BaseURL: baseURL, - DefaultModel: model, - APIKey: apiKey, + Name: "openaicompat", + Driver: "openaicompat", + BaseURL: baseURL, + DefaultModel: model, + APIKey: apiKey, + ChatEndpointPath: "/chat/completions", } } @@ -780,6 +806,27 @@ func TestConsumeStreamVariants(t *testing.T) { } }) + t.Run("eof without done but with finish reason finishes", func(t *testing.T) { + t.Parallel() + + events := make(chan providertypes.StreamEvent, 4) + err := mustProvider(t).ConsumeStream(context.Background(), strings.NewReader( + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n"+ + "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n", + ), events) + if err != nil { + t.Fatalf("expected stream to finish without [DONE] when finish_reason exists, got %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) + } + done := mustDone(t, drained[1]) + if done.FinishReason != "stop" || done.Usage == nil || done.Usage.TotalTokens != 2 { + t.Fatalf("unexpected done payload: %+v", done) + } + }) + t.Run("cancellation beats interruption", func(t *testing.T) { t.Parallel() @@ -880,8 +927,8 @@ func TestGenerateVariants(t *testing.T) { t.Parallel() invalidURL := &Provider{cfg: testCfg("://bad", "gpt-4.1", "test-key"), client: &http.Client{}} - if err := invalidURL.Generate(context.Background(), userTextGenerateRequest("hello"), nil); err == nil || !strings.Contains(err.Error(), "build request") { - t.Fatalf("expected build request error, got %v", err) + if err := invalidURL.Generate(context.Background(), userTextGenerateRequest("hello"), nil); err == nil || !strings.Contains(err.Error(), "invalid chat endpoint configuration") { + t.Fatalf("expected invalid endpoint configuration error, got %v", err) } sendErr := &Provider{ diff --git a/internal/provider/openaicompat/chatcompletions/request.go b/internal/provider/openaicompat/chatcompletions/request.go index a1807b13..18f68620 100644 --- a/internal/provider/openaicompat/chatcompletions/request.go +++ b/internal/provider/openaicompat/chatcompletions/request.go @@ -34,6 +34,7 @@ func BuildRequest(ctx context.Context, cfg provider.RuntimeConfig, req providert Stream: true, Messages: make([]Message, 0, len(req.Messages)+1), } + assetLimits := providertypes.NormalizeSessionAssetLimits(cfg.SessionAssetLimits) if strings.TrimSpace(req.SystemPrompt) != "" { payload.Messages = append(payload.Messages, Message{ @@ -44,12 +45,13 @@ func BuildRequest(ctx context.Context, cfg provider.RuntimeConfig, req providert var usedSessionAssetBytes int64 for _, message := range req.Messages { - remainingSessionAssetBytes := maxSessionAssetsTotalBytes - usedSessionAssetBytes + remainingSessionAssetBytes := assetLimits.MaxSessionAssetsTotalBytes - usedSessionAssetBytes msg, consumedBytes, err := toOpenAIMessageWithBudget( ctx, message, req.SessionAssetReader, remainingSessionAssetBytes, + assetLimits, ) if err != nil { return Request{}, err @@ -113,7 +115,13 @@ func cloneSchemaTopLevel(schema map[string]any) map[string]any { // ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 func ToOpenAIMessage(ctx context.Context, message providertypes.Message, assetReader providertypes.SessionAssetReader) (Message, error) { - msg, _, err := toOpenAIMessageWithBudget(ctx, message, assetReader, maxSessionAssetsTotalBytes) + msg, _, err := toOpenAIMessageWithBudget( + ctx, + message, + assetReader, + maxSessionAssetsTotalBytes, + providertypes.DefaultSessionAssetLimits(), + ) return msg, err } @@ -123,7 +131,9 @@ func toOpenAIMessageWithBudget( message providertypes.Message, assetReader providertypes.SessionAssetReader, remainingAssetBudget int64, + assetLimits providertypes.SessionAssetLimits, ) (Message, int64, error) { + normalizedAssetLimits := providertypes.NormalizeSessionAssetLimits(assetLimits) if remainingAssetBudget < 0 { remainingAssetBudget = 0 } @@ -185,6 +195,7 @@ func toOpenAIMessageWithBudget( assetReader, part.Image.Asset, remainingAssetBudget-usedAssetBytes, + normalizedAssetLimits, ) if err != nil { return Message{}, 0, err @@ -229,7 +240,9 @@ func resolveSessionAssetDataURL( assetReader providertypes.SessionAssetReader, asset *providertypes.AssetRef, remainingBudget int64, + assetLimits providertypes.SessionAssetLimits, ) (string, int64, error) { + normalizedAssetLimits := providertypes.NormalizeSessionAssetLimits(assetLimits) if ctx == nil { ctx = context.Background() } @@ -239,7 +252,7 @@ func resolveSessionAssetDataURL( if remainingBudget <= 0 { return "", 0, fmt.Errorf( "session_asset total exceeds %d bytes", - maxSessionAssetsTotalBytes, + normalizedAssetLimits.MaxSessionAssetsTotalBytes, ) } reader, mimeType, err := assetReader.Open(ctx, asset.ID) @@ -248,7 +261,7 @@ func resolveSessionAssetDataURL( } defer func() { _ = reader.Close() }() - readLimit := maxSessionAssetReadBytes + readLimit := normalizedAssetLimits.MaxSessionAssetBytes if remainingBudget < readLimit { readLimit = remainingBudget } @@ -261,13 +274,13 @@ func resolveSessionAssetDataURL( return "", 0, err } if int64(len(data)) > readLimit { - if readLimit < maxSessionAssetReadBytes { + if readLimit < normalizedAssetLimits.MaxSessionAssetBytes { return "", 0, fmt.Errorf( "session_asset total exceeds %d bytes", - maxSessionAssetsTotalBytes, + normalizedAssetLimits.MaxSessionAssetsTotalBytes, ) } - return "", 0, fmt.Errorf("session_asset %q exceeds %d bytes", asset.ID, maxSessionAssetReadBytes) + return "", 0, fmt.Errorf("session_asset %q exceeds %d bytes", asset.ID, normalizedAssetLimits.MaxSessionAssetBytes) } if len(data) == 0 { return "", 0, fmt.Errorf("session_asset %q is empty", asset.ID) diff --git a/internal/provider/openaicompat/chatcompletions/response.go b/internal/provider/openaicompat/chatcompletions/response.go index 8a862b15..1ec187d8 100644 --- a/internal/provider/openaicompat/chatcompletions/response.go +++ b/internal/provider/openaicompat/chatcompletions/response.go @@ -98,6 +98,9 @@ func (p *Provider) ConsumeStream( if flushErr := flushPendingData(); flushErr != nil { return flushErr } + if strings.TrimSpace(finishReason) != "" { + return finishStream() + } return fmt.Errorf("%w: %w", provider.ErrStreamInterrupted, err) } @@ -130,6 +133,9 @@ func (p *Provider) ConsumeStream( if done { return finishStream() } + if strings.TrimSpace(finishReason) != "" { + return finishStream() + } if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } diff --git a/internal/provider/openaicompat/discovery.go b/internal/provider/openaicompat/discovery.go index 3ba9a826..7449ce05 100644 --- a/internal/provider/openaicompat/discovery.go +++ b/internal/provider/openaicompat/discovery.go @@ -9,28 +9,22 @@ import ( // fetchModels 调用通用 discovery HTTP 引擎,并输出标准化原始模型对象列表。 func (p *Provider) fetchModels(ctx context.Context) ([]map[string]any, error) { - normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( + discoveryProtocol, discoveryEndpointPath, responseProfile, err := provider.ResolveDriverDiscoveryConfig( p.cfg.Driver, - p.cfg.ChatProtocol, - p.cfg.ChatEndpointPath, - p.cfg.DiscoveryProtocol, p.cfg.DiscoveryEndpointPath, - p.cfg.AuthStrategy, - p.cfg.ResponseProfile, - p.cfg.APIStyle, - p.cfg.DiscoveryResponseProfile, ) if err != nil { return nil, provider.NewDiscoveryConfigError(err.Error()) } + authStrategy, apiVersion := provider.ResolveDriverAuthConfig(p.cfg.Driver) return httpdiscovery.DiscoverRawModels(ctx, p.client, httpdiscovery.RequestConfig{ BaseURL: p.cfg.BaseURL, - EndpointPath: normalizedProtocols.DiscoveryEndpointPath, - DiscoveryProtocol: normalizedProtocols.DiscoveryProtocol, - ResponseProfile: normalizedProtocols.ResponseProfile, - AuthStrategy: normalizedProtocols.AuthStrategy, + EndpointPath: discoveryEndpointPath, + DiscoveryProtocol: discoveryProtocol, + ResponseProfile: responseProfile, + AuthStrategy: authStrategy, APIKey: p.cfg.APIKey, - APIVersion: p.cfg.APIVersion, + APIVersion: apiVersion, }) } diff --git a/internal/provider/openaicompat/driver_internal_test.go b/internal/provider/openaicompat/driver_internal_test.go index 673996fc..baad318f 100644 --- a/internal/provider/openaicompat/driver_internal_test.go +++ b/internal/provider/openaicompat/driver_internal_test.go @@ -12,7 +12,7 @@ import ( providertypes "neo-code/internal/provider/types" ) -func TestDriverClosuresAndAPIStyle(t *testing.T) { +func TestDriverClosuresAndSupportedProtocol(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -52,20 +52,12 @@ func TestDriverClosuresAndAPIStyle(t *testing.T) { 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 := supportedChatProtocol(provider.RuntimeConfig{}); err != nil || got != provider.ChatProtocolOpenAIChatCompletions { + if got, err := supportedChatProtocol(provider.RuntimeConfig{Driver: DriverName}); err != nil || got != provider.ChatProtocolOpenAIChatCompletions { t.Fatalf("expected default chat protocol, got protocol=%q err=%v", got, err) } - if _, err := supportedChatProtocol(provider.RuntimeConfig{APIStyle: " Responses "}); err == nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { - t.Fatalf("expected unsupported responses api_style, got %v", err) - } - if _, err := supportedChatProtocol(provider.RuntimeConfig{APIStyle: "custom_style"}); err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { - t.Fatalf("expected unsupported custom api_style, got %v", err) + if _, err := supportedChatProtocol(provider.RuntimeConfig{Driver: provider.DriverAnthropic}); err == nil || + !strings.Contains(err.Error(), "unsupported chat protocol") { + t.Fatalf("expected unsupported anthropic protocol error, got %v", err) } } @@ -125,11 +117,10 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { p, err := New(provider.RuntimeConfig{ Name: DriverName, - Driver: DriverName, + Driver: provider.DriverAnthropic, 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) @@ -137,48 +128,8 @@ func TestFetchModelsAndGenerateExtraBranches(t *testing.T) { err = p.Generate(context.Background(), providertypes.GenerateRequest{ Messages: []providertypes.Message{{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}}, }, nil) - if err == nil || !strings.Contains(err.Error(), `unsupported api_style "custom_style"`) { - t.Fatalf("expected unsupported api_style error, got %v", err) - } - - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": []map[string]any{}, - }) - })) - defer server.Close() - - p, err = New(provider.RuntimeConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: server.URL, - DefaultModel: "gpt-4.1", - APIKey: "test-key", - APIStyle: "responses", - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - p.client = server.Client() - if _, err := p.DiscoverModels(context.Background()); err != nil { - t.Fatalf("expected discovery to ignore chat-only api_style, got %v", err) - } - - p, err = New(provider.RuntimeConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: server.URL, - DefaultModel: "gpt-4.1", - APIKey: "test-key", - APIStyle: "custom_style", - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - p.client = server.Client() - if _, err := p.DiscoverModels(context.Background()); err != nil { - t.Fatalf("expected discovery to ignore unknown chat-only api_style, got %v", err) + if err == nil || !strings.Contains(err.Error(), "unsupported chat protocol") { + t.Fatalf("expected unsupported chat protocol error, got %v", err) } } diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index 532991aa..09ac4480 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -197,7 +197,6 @@ func TestDiscoverModelsParsesGeminiProfileModelList(t *testing.T) { defer server.Close() cfg := resolvedConfig(server.URL, "") - cfg.DiscoveryResponseProfile = provider.DiscoveryResponseProfileGemini p, err := New(cfg) if err != nil { t.Fatalf("New() error = %v", err) @@ -258,7 +257,6 @@ func TestDiscoverModelsOpenAIProfileFallsBackToGenericListKeys(t *testing.T) { defer server.Close() cfg := resolvedConfig(server.URL, "") - cfg.DiscoveryResponseProfile = provider.DiscoveryResponseProfileOpenAI p, err := New(cfg) if err != nil { t.Fatalf("New() error = %v", err) @@ -1241,6 +1239,72 @@ data: [DONE] } } +func TestGenerate_UsesDirectBaseURLWhenChatEndpointPathEmpty(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/text/chatcompletion_v2" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]} +data: [DONE] + +`)) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL+"/v1/text/chatcompletion_v2", config.OpenAIDefaultModel) + cfg.ChatEndpointPath = "" + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + events := make(chan providertypes.StreamEvent, 4) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Model: config.OpenAIDefaultModel, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, + }, events) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } +} + +func TestGenerate_UsesDirectBaseURLWhenChatEndpointPathSlash(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/text/chatcompletion_v2" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]} +data: [DONE] + +`)) + })) + defer server.Close() + + cfg := resolvedConfig(server.URL+"/v1/text/chatcompletion_v2", config.OpenAIDefaultModel) + cfg.ChatEndpointPath = "/" + p, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + events := make(chan providertypes.StreamEvent, 4) + err = p.Generate(context.Background(), providertypes.GenerateRequest{ + Model: config.OpenAIDefaultModel, + Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, + }, events) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } +} + // --- parseError 边界测试 --- func TestParseError_ReadBodyFailure(t *testing.T) { @@ -1429,11 +1493,11 @@ func TestProviderGenerateHTTPErrorResponses(t *testing.T) { } } -func TestProviderGenerateRejectsUnsupportedAPIStyle(t *testing.T) { +func TestProviderGenerateRejectsUnsupportedDriverProtocol(t *testing.T) { t.Parallel() cfg := resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel) - cfg.APIStyle = "responses" + cfg.Driver = provider.DriverAnthropic p, err := New(cfg) if err != nil { @@ -1445,9 +1509,9 @@ func TestProviderGenerateRejectsUnsupportedAPIStyle(t *testing.T) { Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hi")}}}, }, make(chan providertypes.StreamEvent, 1)) if err == nil { - t.Fatal("expected unsupported api_style error") + t.Fatal("expected unsupported protocol error") } - if !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { + if !strings.Contains(err.Error(), "unsupported chat protocol") { t.Fatalf("unexpected error: %v", err) } } @@ -1560,11 +1624,12 @@ func resolvedConfig(baseURL string, model string) provider.RuntimeConfig { model = config.OpenAIDefaultModel } return provider.RuntimeConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: baseURL, - DefaultModel: model, - APIKey: "test-key", + Name: DriverName, + Driver: DriverName, + BaseURL: baseURL, + DefaultModel: model, + APIKey: "test-key", + ChatEndpointPath: "/chat/completions", } } diff --git a/internal/provider/openaicompat/provider.go b/internal/provider/openaicompat/provider.go index 4cff8fb9..c644a1ed 100644 --- a/internal/provider/openaicompat/provider.go +++ b/internal/provider/openaicompat/provider.go @@ -77,7 +77,7 @@ func (p *Provider) DiscoverModels(ctx context.Context) ([]providertypes.ModelDes } // Generate 发起 SSE 流式生成请求。 -// 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 +// 流中断连或协议错误时直接返回错误,由上层调用方决定重试策略。 func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { if _, err := supportedChatProtocol(p.cfg); err != nil { return err @@ -90,56 +90,19 @@ func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateReque return impl.Generate(ctx, req, events) } -// normalizedAPIStyle 统一规范化 openaicompat 的 api_style,并为空值回退到 chat_completions。 -func normalizedAPIStyle(apiStyle string) string { - normalized := provider.NormalizeProviderAPIStyle(apiStyle) - if normalized == "" { - return provider.OpenAICompatibleAPIStyleChatCompletions - } - return normalized -} - -// normalizedChatProtocol 统一解析 chat protocol,优先新字段并兼容旧 api_style 配置。 -func normalizedChatProtocol(cfg provider.RuntimeConfig) string { - if normalized := provider.NormalizeProviderChatProtocol(cfg.ChatProtocol); normalized != "" { - return normalized - } - - normalizedLegacyAPIStyle := normalizedAPIStyle(cfg.APIStyle) - switch normalizedLegacyAPIStyle { - case provider.OpenAICompatibleAPIStyleResponses: - return provider.ChatProtocolOpenAIResponses - case provider.OpenAICompatibleAPIStyleChatCompletions, "": - return provider.ChatProtocolOpenAIChatCompletions - default: - return normalizedLegacyAPIStyle - } -} - // supportedChatProtocol 校验 openaicompat 当前支持的聊天协议。 func supportedChatProtocol(cfg provider.RuntimeConfig) (string, error) { - normalized := normalizedChatProtocol(cfg) - usingLegacyAPIStyle := strings.TrimSpace(cfg.APIStyle) != "" + normalized := provider.ResolveDriverProtocolDefaults(cfg.Driver).ChatProtocol switch normalized { case provider.ChatProtocolOpenAIChatCompletions: return normalized, nil case provider.ChatProtocolOpenAIResponses: - if usingLegacyAPIStyle { - return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: api_style %q is not supported yet", provider.OpenAICompatibleAPIStyleResponses), - ) - } return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: chat_protocol %q is not supported yet", normalized), + fmt.Sprintf("openaicompat provider: driver %q currently does not support chat protocol %q", cfg.Driver, normalized), ) default: - if usingLegacyAPIStyle { - return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: unsupported api_style %q", normalizedAPIStyle(cfg.APIStyle)), - ) - } return "", provider.NewDiscoveryConfigError( - fmt.Sprintf("openaicompat provider: unsupported chat_protocol %q", normalized), + fmt.Sprintf("openaicompat provider: driver %q resolved unsupported chat protocol %q", cfg.Driver, normalized), ) } } diff --git a/internal/provider/openaicompat/shared/shared.go b/internal/provider/openaicompat/shared/shared.go index e294f76a..f68bdf1c 100644 --- a/internal/provider/openaicompat/shared/shared.go +++ b/internal/provider/openaicompat/shared/shared.go @@ -32,5 +32,6 @@ func SetBearerAuthorization(header http.Header, apiKey string) { // ApplyAuthHeaders 根据 runtime 配置中的 auth strategy 写入鉴权头。 func ApplyAuthHeaders(header http.Header, cfg provider.RuntimeConfig) { - provider.ApplyAuthHeaders(header, cfg.AuthStrategy, cfg.APIKey, cfg.APIVersion) + authStrategy, apiVersion := provider.ResolveDriverAuthConfig(cfg.Driver) + provider.ApplyAuthHeaders(header, authStrategy, cfg.APIKey, apiVersion) } diff --git a/internal/provider/openaicompat/shared/shared_test.go b/internal/provider/openaicompat/shared/shared_test.go index 38c49aba..3dda6406 100644 --- a/internal/provider/openaicompat/shared/shared_test.go +++ b/internal/provider/openaicompat/shared/shared_test.go @@ -132,16 +132,16 @@ func TestSetBearerAuthorization(t *testing.T) { func TestApplyAuthHeaders(t *testing.T) { t.Parallel() - t.Run("x_api_key", func(t *testing.T) { + t.Run("openaicompat uses bearer", func(t *testing.T) { t.Parallel() header := http.Header{} ApplyAuthHeaders(header, provider.RuntimeConfig{ - AuthStrategy: provider.AuthStrategyXAPIKey, - APIKey: "x-key", + Driver: provider.DriverOpenAICompat, + APIKey: "x-key", }) - if got := header.Get("X-API-Key"); got != "x-key" { - t.Fatalf("expected x-api-key header, got %q", got) + if got := header.Get("Authorization"); got != "Bearer x-key" { + t.Fatalf("expected bearer authorization header, got %q", got) } }) @@ -150,8 +150,8 @@ func TestApplyAuthHeaders(t *testing.T) { header := http.Header{} ApplyAuthHeaders(header, provider.RuntimeConfig{ - AuthStrategy: provider.AuthStrategyAnthropic, - APIKey: "anthropic-key", + Driver: provider.DriverAnthropic, + APIKey: "anthropic-key", }) if got := header.Get("x-api-key"); got != "anthropic-key" { t.Fatalf("expected anthropic x-api-key, got %q", got) @@ -160,18 +160,4 @@ func TestApplyAuthHeaders(t *testing.T) { t.Fatal("expected default anthropic version") } }) - - t.Run("anthropic explicit version", func(t *testing.T) { - t.Parallel() - - header := http.Header{} - ApplyAuthHeaders(header, provider.RuntimeConfig{ - AuthStrategy: provider.AuthStrategyAnthropic, - APIKey: "anthropic-key", - APIVersion: "2024-01-01", - }) - if got := header.Get("anthropic-version"); got != "2024-01-01" { - t.Fatalf("expected explicit anthropic version, got %q", got) - } - }) } diff --git a/internal/provider/protocols.go b/internal/provider/protocols.go index f3bf9125..65e295e1 100644 --- a/internal/provider/protocols.go +++ b/internal/provider/protocols.go @@ -100,9 +100,6 @@ func NormalizeProviderProtocolSettings( return NormalizedProtocolSettings{}, err } - if settings.ChatEndpointPath == "" { - settings.ChatEndpointPath = defaultChatEndpointPath(settings.ChatProtocol) - } if settings.DiscoveryEndpointPath == "" { settings.DiscoveryEndpointPath = defaultDiscoveryEndpointPath(settings.DiscoveryProtocol) } diff --git a/internal/provider/protocols_test.go b/internal/provider/protocols_test.go index 4b2454fd..c1898288 100644 --- a/internal/provider/protocols_test.go +++ b/internal/provider/protocols_test.go @@ -137,19 +137,19 @@ func TestNormalizeProviderProtocolSettingsDefaults(t *testing.T) { t.Parallel() tests := []struct { - name string - driver string - chatProtocol string - discoveryProtocol string - wantChatEndpoint string - wantDiscoveryEndpoint string - wantAuthStrategy string - wantResponseProfile string + name string + driver string + chatProtocol string + discoveryProtocol string + wantChatEndpoint string + wantDiscoveryEndpoint string + wantAuthStrategy string + wantResponseProfile string }{ { name: "openai defaults", driver: DriverOpenAICompat, - wantChatEndpoint: "/chat/completions", + wantChatEndpoint: "", wantDiscoveryEndpoint: "/models", wantAuthStrategy: AuthStrategyBearer, wantResponseProfile: DiscoveryResponseProfileOpenAI, @@ -157,7 +157,7 @@ func TestNormalizeProviderProtocolSettingsDefaults(t *testing.T) { { name: "anthropic defaults", driver: DriverAnthropic, - wantChatEndpoint: "/messages", + wantChatEndpoint: "", wantDiscoveryEndpoint: "/models", wantAuthStrategy: AuthStrategyAnthropic, wantResponseProfile: DiscoveryResponseProfileGeneric, @@ -167,7 +167,7 @@ func TestNormalizeProviderProtocolSettingsDefaults(t *testing.T) { driver: DriverGemini, chatProtocol: ChatProtocolOpenAIResponses, discoveryProtocol: DiscoveryProtocolGeminiModels, - wantChatEndpoint: "/responses", + wantChatEndpoint: "", wantDiscoveryEndpoint: "/models", wantAuthStrategy: AuthStrategyBearer, wantResponseProfile: DiscoveryResponseProfileGemini, @@ -208,3 +208,25 @@ func TestNormalizeProviderProtocolSettingsDefaults(t *testing.T) { }) } } + +func TestNormalizeProviderProtocolSettingsPreservesExplicitChatEndpointPath(t *testing.T) { + t.Parallel() + + settings, err := NormalizeProviderProtocolSettings( + DriverOpenAICompat, + ChatProtocolOpenAIChatCompletions, + "/v1/text/chatcompletion_v2", + DiscoveryProtocolOpenAIModels, + "/models", + AuthStrategyBearer, + DiscoveryResponseProfileOpenAI, + "", + "", + ) + if err != nil { + t.Fatalf("NormalizeProviderProtocolSettings() error = %v", err) + } + if settings.ChatEndpointPath != "/v1/text/chatcompletion_v2" { + t.Fatalf("expected explicit chat endpoint to be preserved, got %q", settings.ChatEndpointPath) + } +} diff --git a/internal/provider/types/asset_limits.go b/internal/provider/types/asset_limits.go index 5a315551..1624f657 100644 --- a/internal/provider/types/asset_limits.go +++ b/internal/provider/types/asset_limits.go @@ -6,3 +6,38 @@ const ( // MaxSessionAssetsTotalBytes 定义单次请求允许携带的 session_asset 原始总字节上限(20 MiB)。 MaxSessionAssetsTotalBytes int64 = 20 * 1024 * 1024 ) + +// SessionAssetLimits 描述 session_asset 在单文件与单次请求维度的限制。 +type SessionAssetLimits struct { + MaxSessionAssetBytes int64 + MaxSessionAssetsTotalBytes int64 +} + +// DefaultSessionAssetLimits 返回 session_asset 限制的默认值。 +func DefaultSessionAssetLimits() SessionAssetLimits { + return SessionAssetLimits{ + MaxSessionAssetBytes: MaxSessionAssetBytes, + MaxSessionAssetsTotalBytes: MaxSessionAssetsTotalBytes, + } +} + +// NormalizeSessionAssetLimits 归一化 session_asset 限制并施加硬上限兜底。 +func NormalizeSessionAssetLimits(limits SessionAssetLimits) SessionAssetLimits { + normalized := limits + if normalized.MaxSessionAssetBytes <= 0 { + normalized.MaxSessionAssetBytes = MaxSessionAssetBytes + } + if normalized.MaxSessionAssetsTotalBytes <= 0 { + normalized.MaxSessionAssetsTotalBytes = MaxSessionAssetsTotalBytes + } + if normalized.MaxSessionAssetBytes > MaxSessionAssetBytes { + normalized.MaxSessionAssetBytes = MaxSessionAssetBytes + } + if normalized.MaxSessionAssetsTotalBytes > MaxSessionAssetsTotalBytes { + normalized.MaxSessionAssetsTotalBytes = MaxSessionAssetsTotalBytes + } + if normalized.MaxSessionAssetsTotalBytes < normalized.MaxSessionAssetBytes { + normalized.MaxSessionAssetsTotalBytes = normalized.MaxSessionAssetBytes + } + return normalized +} diff --git a/internal/provider/types/asset_limits_test.go b/internal/provider/types/asset_limits_test.go new file mode 100644 index 00000000..72b2c2ba --- /dev/null +++ b/internal/provider/types/asset_limits_test.go @@ -0,0 +1,54 @@ +package types + +import "testing" + +func TestNormalizeSessionAssetLimits(t *testing.T) { + t.Parallel() + + t.Run("defaults when empty", func(t *testing.T) { + t.Parallel() + + got := NormalizeSessionAssetLimits(SessionAssetLimits{}) + if got.MaxSessionAssetBytes != MaxSessionAssetBytes { + t.Fatalf("expected default MaxSessionAssetBytes=%d, got %d", MaxSessionAssetBytes, got.MaxSessionAssetBytes) + } + if got.MaxSessionAssetsTotalBytes != MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected default MaxSessionAssetsTotalBytes=%d, got %d", + MaxSessionAssetsTotalBytes, + got.MaxSessionAssetsTotalBytes, + ) + } + }) + + t.Run("clamps to hard max", func(t *testing.T) { + t.Parallel() + + got := NormalizeSessionAssetLimits(SessionAssetLimits{ + MaxSessionAssetBytes: MaxSessionAssetBytes + 1, + MaxSessionAssetsTotalBytes: MaxSessionAssetsTotalBytes + 1, + }) + if got.MaxSessionAssetBytes != MaxSessionAssetBytes { + t.Fatalf("expected clamped MaxSessionAssetBytes=%d, got %d", MaxSessionAssetBytes, got.MaxSessionAssetBytes) + } + if got.MaxSessionAssetsTotalBytes != MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected clamped MaxSessionAssetsTotalBytes=%d, got %d", + MaxSessionAssetsTotalBytes, + got.MaxSessionAssetsTotalBytes, + ) + } + }) + + t.Run("raises total to single limit", func(t *testing.T) { + t.Parallel() + + got := NormalizeSessionAssetLimits(SessionAssetLimits{ + MaxSessionAssetBytes: 1024, + MaxSessionAssetsTotalBytes: 512, + }) + if got.MaxSessionAssetsTotalBytes != 1024 { + t.Fatalf("expected total limit promoted to 1024, got %d", got.MaxSessionAssetsTotalBytes) + } + }) +} diff --git a/internal/runtime/input_prepare.go b/internal/runtime/input_prepare.go index 98ae2c57..774b78f9 100644 --- a/internal/runtime/input_prepare.go +++ b/internal/runtime/input_prepare.go @@ -7,6 +7,7 @@ import ( "strings" "time" + providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" ) @@ -43,8 +44,14 @@ func (s *Service) PrepareUserInput(ctx context.Context, input PrepareInput) (Use } defaultWorkdir := "" + sessionAssetLimits := providertypes.DefaultSessionAssetLimits() if s.configManager != nil { - defaultWorkdir = strings.TrimSpace(s.configManager.Get().Workdir) + cfg := s.configManager.Get() + defaultWorkdir = strings.TrimSpace(cfg.Workdir) + sessionAssetLimits = cfg.Runtime.ResolveSessionAssetLimits() + } + if limitAwareStore, ok := s.sessionAssetStore.(sessionAssetLimitStore); ok { + limitAwareStore.SetSessionAssetLimits(sessionAssetLimits) } prepared, err := s.userInputPreparer.Prepare(ctx, input, defaultWorkdir) @@ -120,6 +127,10 @@ type sessionInputPreparer struct { preparer *agentsession.InputPreparer } +type sessionAssetLimitStore interface { + SetSessionAssetLimits(limits providertypes.SessionAssetLimits) +} + // Prepare 将 runtime 输入 DTO 映射到 session 子层并返回标准 UserInput 结果。 func (p sessionInputPreparer) Prepare( ctx context.Context, diff --git a/internal/runtime/input_prepare_test.go b/internal/runtime/input_prepare_test.go index 3e0b1755..f12a1bdc 100644 --- a/internal/runtime/input_prepare_test.go +++ b/internal/runtime/input_prepare_test.go @@ -148,11 +148,45 @@ func TestServicePrepareUserInputDoesNotBlockWhenPrepareEventQueueIsFull(t *testi } } +func TestServicePrepareUserInputAppliesRuntimeAssetLimitsToSessionStore(t *testing.T) { + t.Parallel() + + workdir := t.TempDir() + runtimeCfg := config.StaticDefaults().Runtime + runtimeCfg.Assets.MaxSessionAssetBytes = 32 + runtimeCfg.Assets.MaxSessionAssetsTotalBytes = 32 + svc, _ := newPrepareTestServiceWithRuntimeConfig(t, workdir, true, runtimeCfg) + + imagePath := filepath.Join(workdir, "img.png") + if err := os.WriteFile(imagePath, minimalPNGBytesForRuntimeTest(), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + + _, err := svc.PrepareUserInput(context.Background(), PrepareInput{ + RunID: "run-prepare-limit-1", + Text: "hello", + Images: []UserImageInput{{Path: imagePath, MimeType: "image/png"}}, + }) + if err == nil { + t.Fatal("expected PrepareUserInput() to fail when runtime assets limit is too small") + } +} + func newPrepareTestService(t *testing.T, workdir string, withPreparer bool) (*Service, *agentsession.SQLiteStore) { + return newPrepareTestServiceWithRuntimeConfig(t, workdir, withPreparer, config.StaticDefaults().Runtime) +} + +func newPrepareTestServiceWithRuntimeConfig( + t *testing.T, + workdir string, + withPreparer bool, + runtimeCfg config.RuntimeConfig, +) (*Service, *agentsession.SQLiteStore) { t.Helper() cfg := config.StaticDefaults() cfg.Workdir = workdir + cfg.Runtime = runtimeCfg loader := config.NewLoader(t.TempDir(), cfg) manager := config.NewManager(loader) if _, err := manager.Load(context.Background()); err != nil { diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 9b59eaca..5487420a 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -262,6 +262,8 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur if err != nil { return turnSnapshot{}, false, err } + providerRuntimeCfg := resolvedProvider.ToRuntimeConfig() + providerRuntimeCfg.SessionAssetLimits = cfg.Runtime.ResolveSessionAssetLimits() state.mu.Lock() streak := state.progress.LastScore.NoProgressStreak @@ -278,7 +280,7 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur model := strings.TrimSpace(cfg.CurrentModel) return turnSnapshot{ config: cfg, - providerConfig: resolvedProvider.ToRuntimeConfig(), + providerConfig: providerRuntimeCfg, model: model, workdir: activeWorkdir, toolTimeout: time.Duration(cfg.ToolTimeoutSec) * time.Second, diff --git a/internal/session/asset_store_test.go b/internal/session/asset_store_test.go index 999ef6c5..57b62c67 100644 --- a/internal/session/asset_store_test.go +++ b/internal/session/asset_store_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "testing" + + providertypes "neo-code/internal/provider/types" ) func TestSQLiteStoreSaveAssetOpenAndStat(t *testing.T) { @@ -147,6 +149,23 @@ func TestSQLiteStoreAssetMethodsRespectContext(t *testing.T) { } } +func TestSQLiteStoreSaveAssetRespectsConfiguredAssetLimit(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + store.SetSessionAssetLimits(providertypes.SessionAssetLimits{ + MaxSessionAssetBytes: 1, + MaxSessionAssetsTotalBytes: 1, + }) + session, err := store.CreateSession(ctx, CreateSessionInput{ID: "session_assets_limit", Title: "assets"}) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + if _, err := store.SaveAsset(ctx, session.ID, strings.NewReader("xx"), "image/png"); err == nil || + !strings.Contains(err.Error(), "asset size exceeds 1 bytes") { + t.Fatalf("expected configured asset size limit error, got %v", err) + } +} + func MaxSessionAssetBytesForTest() int64 { return 20 * 1024 * 1024 } diff --git a/internal/session/sqlite_store.go b/internal/session/sqlite_store.go index e6f3fd54..b8253185 100644 --- a/internal/session/sqlite_store.go +++ b/internal/session/sqlite_store.go @@ -48,8 +48,31 @@ type SQLiteStore struct { assetsDir string dbPath string - initMu sync.Mutex - db *sql.DB + initMu sync.Mutex + db *sql.DB + limitsMu sync.RWMutex + limits providertypes.SessionAssetLimits +} + +// SetSessionAssetLimits 设置会话附件大小限制;非法值会回退到默认并应用硬上限兜底。 +func (s *SQLiteStore) SetSessionAssetLimits(limits providertypes.SessionAssetLimits) { + if s == nil { + return + } + s.limitsMu.Lock() + s.limits = providertypes.NormalizeSessionAssetLimits(limits) + s.limitsMu.Unlock() +} + +// sessionAssetLimits 返回当前生效的会话附件限制。 +func (s *SQLiteStore) sessionAssetLimits() providertypes.SessionAssetLimits { + if s == nil { + return providertypes.DefaultSessionAssetLimits() + } + s.limitsMu.RLock() + limits := s.limits + s.limitsMu.RUnlock() + return providertypes.NormalizeSessionAssetLimits(limits) } // Close 释放数据库连接,供测试和上层生命周期管理复用。 @@ -444,16 +467,17 @@ func (s *SQLiteStore) SaveAsset(ctx context.Context, sessionID string, r io.Read return AssetMeta{}, err } - written, copyErr := io.Copy(tempFile, io.LimitReader(r, providertypes.MaxSessionAssetBytes+1)) + limits := s.sessionAssetLimits() + written, copyErr := io.Copy(tempFile, io.LimitReader(r, limits.MaxSessionAssetBytes+1)) syncErr := tempFile.Sync() closeErr := tempFile.Close() if copyErr != nil { _ = os.Remove(tempPath) return AssetMeta{}, fmt.Errorf("session: write temp asset: %w", copyErr) } - if written > providertypes.MaxSessionAssetBytes { + if written > limits.MaxSessionAssetBytes { _ = os.Remove(tempPath) - return AssetMeta{}, fmt.Errorf("session: asset size exceeds %d bytes", providertypes.MaxSessionAssetBytes) + return AssetMeta{}, fmt.Errorf("session: asset size exceeds %d bytes", limits.MaxSessionAssetBytes) } if syncErr != nil { _ = os.Remove(tempPath) diff --git a/internal/session/store.go b/internal/session/store.go index 75e755f0..6a2228c3 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -126,6 +126,7 @@ func NewSQLiteStore(baseDir string, workspaceRoot string) *SQLiteStore { projectDir: projectDirectory(baseDir, workspaceRoot), assetsDir: assetsDirectory(baseDir, workspaceRoot), dbPath: databasePath(baseDir, workspaceRoot), + limits: providertypes.DefaultSessionAssetLimits(), } } diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 082b0542..8d9a1109 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -133,25 +133,22 @@ type pendingImageAttachment struct { // providerAddFormState 保存添加新 provider 表单的状态。 type providerAddFormState struct { - Stage providerAddFormStage - Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 - Name string - Driver string - ModelSource string - BaseURL string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string - ManualModelsJSON string - APIKeyEnv string - APIKey string - Error string - ErrorIsHard bool - Submitting bool - Drivers []string // 可选的 Driver 列表 - ModelSources []string // 可选的模型来源列表 + Stage providerAddFormStage + Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 + Name string + Driver string + ModelSource string + BaseURL string + ChatEndpointPath string + DiscoveryEndpointPath string + ManualModelsJSON string + APIKeyEnv string + APIKey string + Error string + ErrorIsHard bool + Submitting bool + Drivers []string // 可选的 Driver 列表 + ModelSources []string // 可选的模型来源列表 } type providerAddFormStage int diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 35597cff..fc9c7c97 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -2442,11 +2442,8 @@ const ( providerAddFieldDriver providerAddFieldModelSource providerAddFieldBaseURL - providerAddFieldAPIStyle - providerAddFieldDeploymentMode - providerAddFieldAPIVersion + providerAddFieldChatEndpointPath providerAddFieldDiscoveryEndpointPath - providerAddFieldDiscoveryResponseProfile providerAddFieldAPIKeyEnv providerAddFieldAPIKey ) @@ -2457,19 +2454,11 @@ func providerAddVisibleFields(driver string, modelSource string) []providerAddFi providerAddFieldDriver, providerAddFieldModelSource, providerAddFieldBaseURL, - } - - switch provider.NormalizeProviderDriver(driver) { - case provider.DriverOpenAICompat: - fields = append(fields, providerAddFieldAPIStyle) - case provider.DriverGemini: - fields = append(fields, providerAddFieldDeploymentMode) - case provider.DriverAnthropic: - fields = append(fields, providerAddFieldAPIVersion) + providerAddFieldChatEndpointPath, } if provider.NormalizeModelSource(strings.TrimSpace(modelSource)) == provider.ModelSourceDiscover { - fields = append(fields, providerAddFieldDiscoveryEndpointPath, providerAddFieldDiscoveryResponseProfile) + fields = append(fields, providerAddFieldDiscoveryEndpointPath) } fields = append(fields, providerAddFieldAPIKeyEnv, providerAddFieldAPIKey) return fields @@ -2504,26 +2493,33 @@ func currentProviderAddField(form *providerAddFormState) providerAddFieldID { return fields[form.Step] } +// isProviderAddEnumField 判断当前新增 Provider 表单焦点是否在枚举字段(Driver/Model Source)。 +func isProviderAddEnumField(form *providerAddFormState) bool { + switch currentProviderAddField(form) { + case providerAddFieldDriver, providerAddFieldModelSource: + return true + default: + return false + } +} + func (a *App) startProviderAddForm() { a.providerAddForm = &providerAddFormState{ - Stage: providerAddFormStageFields, - Step: 0, - Name: "", - Driver: provider.DriverOpenAICompat, - ModelSource: provider.ModelSourceDiscover, - BaseURL: "", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, - DeploymentMode: "", - APIVersion: "", - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, - ManualModelsJSON: "", - APIKeyEnv: "", - APIKey: "", - Error: "", - ErrorIsHard: false, - Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, - ModelSources: []string{provider.ModelSourceDiscover, provider.ModelSourceManual}, + Stage: providerAddFormStageFields, + Step: 0, + Name: "", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, + BaseURL: "", + ChatEndpointPath: "/chat/completions", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + ManualModelsJSON: "", + APIKeyEnv: "", + APIKey: "", + Error: "", + ErrorIsHard: false, + Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, + ModelSources: []string{provider.ModelSourceDiscover, provider.ModelSourceManual}, } a.state.ActivePicker = pickerProviderAdd a.state.StatusText = "Add new provider" @@ -2586,27 +2582,19 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch currentProviderAddField(a.providerAddForm) { case providerAddFieldName: a.providerAddForm.Name = trimLastRune(a.providerAddForm.Name) - case providerAddFieldDriver: - a.providerAddForm.Driver = trimLastRune(a.providerAddForm.Driver) case providerAddFieldBaseURL: a.providerAddForm.BaseURL = trimLastRune(a.providerAddForm.BaseURL) - case providerAddFieldAPIStyle: - a.providerAddForm.APIStyle = trimLastRune(a.providerAddForm.APIStyle) - case providerAddFieldDeploymentMode: - a.providerAddForm.DeploymentMode = trimLastRune(a.providerAddForm.DeploymentMode) - case providerAddFieldAPIVersion: - a.providerAddForm.APIVersion = trimLastRune(a.providerAddForm.APIVersion) + case providerAddFieldChatEndpointPath: + a.providerAddForm.ChatEndpointPath = trimLastRune(a.providerAddForm.ChatEndpointPath) case providerAddFieldDiscoveryEndpointPath: a.providerAddForm.DiscoveryEndpointPath = trimLastRune(a.providerAddForm.DiscoveryEndpointPath) - case providerAddFieldDiscoveryResponseProfile: - a.providerAddForm.DiscoveryResponseProfile = trimLastRune(a.providerAddForm.DiscoveryResponseProfile) case providerAddFieldAPIKeyEnv: a.providerAddForm.APIKeyEnv = trimLastRune(a.providerAddForm.APIKeyEnv) case providerAddFieldAPIKey: a.providerAddForm.APIKey = trimLastRune(a.providerAddForm.APIKey) } return a, nil - case typed.Type == tea.KeyUp: + case typed.Type == tea.KeyUp || (isProviderAddEnumField(a.providerAddForm) && key.Matches(typed, a.keys.ScrollUp)): if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { currentIdx := -1 for i, d := range a.providerAddForm.Drivers { @@ -2633,7 +2621,7 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { clampProviderAddStep(a.providerAddForm) } return a, nil - case typed.Type == tea.KeyDown: + case typed.Type == tea.KeyDown || (isProviderAddEnumField(a.providerAddForm) && key.Matches(typed, a.keys.ScrollDown)): if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { currentIdx := -1 for i, d := range a.providerAddForm.Drivers { @@ -2668,16 +2656,10 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerAddForm.Name += cleanInput case providerAddFieldBaseURL: a.providerAddForm.BaseURL += cleanInput - case providerAddFieldAPIStyle: - a.providerAddForm.APIStyle += cleanInput - case providerAddFieldDeploymentMode: - a.providerAddForm.DeploymentMode += cleanInput - case providerAddFieldAPIVersion: - a.providerAddForm.APIVersion += cleanInput + case providerAddFieldChatEndpointPath: + a.providerAddForm.ChatEndpointPath += cleanInput case providerAddFieldDiscoveryEndpointPath: a.providerAddForm.DiscoveryEndpointPath += cleanInput - case providerAddFieldDiscoveryResponseProfile: - a.providerAddForm.DiscoveryResponseProfile += cleanInput case providerAddFieldAPIKeyEnv: a.providerAddForm.APIKeyEnv += cleanInput case providerAddFieldAPIKey: @@ -2710,9 +2692,6 @@ func (a *App) submitProviderAddForm() tea.Cmd { a.providerAddForm.Stage = providerAddFormStageManualModels a.providerAddForm.Error = "" a.providerAddForm.ErrorIsHard = false - if strings.TrimSpace(a.providerAddForm.ManualModelsJSON) == "" { - a.providerAddForm.ManualModelsJSON = providerAddManualModelsJSONTemplate - } a.state.StatusText = "Fill manual model JSON" return nil } @@ -2732,18 +2711,15 @@ func (a *App) submitProviderAddForm() tea.Cmd { } type providerAddRequest struct { - Name string - Driver string - BaseURL string - ModelSource string - ManualModelsJSON string - APIStyle string - DeploymentMode string - APIVersion string - DiscoveryEndpointPath string - DiscoveryResponseProfile string - APIKeyEnv string - APIKey string + Name string + Driver string + BaseURL string + ChatEndpointPath string + ModelSource string + ManualModelsJSON string + DiscoveryEndpointPath string + APIKeyEnv string + APIKey string } type providerAddResultMsg struct { @@ -2755,18 +2731,15 @@ type providerAddResultMsg struct { func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, string) { request := providerAddRequest{ - Name: normalizeProviderAddFieldValue(form.Name), - Driver: provider.NormalizeProviderDriver(normalizeProviderAddFieldValue(form.Driver)), - ModelSource: provider.NormalizeModelSource(normalizeProviderAddFieldValue(form.ModelSource)), - BaseURL: normalizeProviderAddFieldValue(form.BaseURL), - ManualModelsJSON: strings.TrimSpace(form.ManualModelsJSON), - APIStyle: normalizeProviderAddFieldValue(form.APIStyle), - DeploymentMode: normalizeProviderAddFieldValue(form.DeploymentMode), - APIVersion: normalizeProviderAddFieldValue(form.APIVersion), - DiscoveryEndpointPath: normalizeProviderAddFieldValue(form.DiscoveryEndpointPath), - DiscoveryResponseProfile: normalizeProviderAddFieldValue(form.DiscoveryResponseProfile), - APIKeyEnv: normalizeProviderAddFieldValue(form.APIKeyEnv), - APIKey: normalizeProviderAddFieldValue(form.APIKey), + Name: normalizeProviderAddFieldValue(form.Name), + Driver: provider.NormalizeProviderDriver(normalizeProviderAddFieldValue(form.Driver)), + ModelSource: provider.NormalizeModelSource(normalizeProviderAddFieldValue(form.ModelSource)), + BaseURL: normalizeProviderAddFieldValue(form.BaseURL), + ChatEndpointPath: normalizeProviderAddFieldValue(form.ChatEndpointPath), + ManualModelsJSON: strings.TrimSpace(form.ManualModelsJSON), + DiscoveryEndpointPath: normalizeProviderAddFieldValue(form.DiscoveryEndpointPath), + APIKeyEnv: normalizeProviderAddFieldValue(form.APIKeyEnv), + APIKey: normalizeProviderAddFieldValue(form.APIKey), } if request.Name == "" { @@ -2796,63 +2769,40 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str if request.BaseURL == "" { request.BaseURL = config.OpenAIDefaultBaseURL } - if request.APIStyle == "" { - request.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions - } - request.DeploymentMode = "" - request.APIVersion = "" case provider.DriverGemini: if request.BaseURL == "" { request.BaseURL = config.GeminiDefaultBaseURL } - request.APIStyle = "" - request.APIVersion = "" case provider.DriverAnthropic: if request.BaseURL == "" { return providerAddRequest{}, "Base URL is required for anthropic provider" } - request.APIStyle = "" - request.DeploymentMode = "" default: if request.BaseURL == "" { return providerAddRequest{}, "Base URL is required for custom driver" } - request.APIStyle = "" - request.DeploymentMode = "" - request.APIVersion = "" - } - - if request.ModelSource == provider.ModelSourceManual { - request.DiscoveryEndpointPath = "" - request.DiscoveryResponseProfile = "" - return request, "" - } - - if request.DiscoveryEndpointPath == "" { - return providerAddRequest{}, "Discovery Endpoint is required for discover model source" } normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( request.Driver, "", - "", + request.ChatEndpointPath, "", request.DiscoveryEndpointPath, "", "", - request.APIStyle, - request.DiscoveryResponseProfile, + "", + "", ) if err != nil { return providerAddRequest{}, err.Error() } - if request.Driver == provider.DriverOpenAICompat { - request.APIStyle = normalizedProtocols.LegacyAPIStyle - } else { - request.APIStyle = "" + request.ChatEndpointPath = normalizedProtocols.ChatEndpointPath + if request.ModelSource == provider.ModelSourceManual { + request.DiscoveryEndpointPath = "" + return request, "" } request.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - request.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile return request, "" } @@ -2940,18 +2890,15 @@ func (a *App) runProviderAddFlow(request providerAddRequest) tea.Cmd { defer cancel() selection, err := providerSvc.CreateCustomProvider(ctx, configstate.CreateCustomProviderInput{ - Name: request.Name, - Driver: request.Driver, - BaseURL: request.BaseURL, - ModelSource: request.ModelSource, - ManualModelsJSON: request.ManualModelsJSON, - APIStyle: request.APIStyle, - DeploymentMode: request.DeploymentMode, - APIVersion: request.APIVersion, - DiscoveryEndpointPath: request.DiscoveryEndpointPath, - DiscoveryResponseProfile: request.DiscoveryResponseProfile, - APIKeyEnv: request.APIKeyEnv, - APIKey: request.APIKey, + Name: request.Name, + Driver: request.Driver, + BaseURL: request.BaseURL, + ChatEndpointPath: request.ChatEndpointPath, + ModelSource: request.ModelSource, + ManualModelsJSON: request.ManualModelsJSON, + DiscoveryEndpointPath: request.DiscoveryEndpointPath, + APIKeyEnv: request.APIKeyEnv, + APIKey: request.APIKey, }) if err != nil { if errors.Is(err, context.DeadlineExceeded) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 5a2ca655..53a8043a 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -359,7 +359,6 @@ func TestSubmitProviderAddFormAsyncSuccess(t *testing.T) { app.providerAddForm.BaseURL = "https://team-gateway.example.com/v1" app.providerAddForm.APIKeyEnv = "TEAM_GATEWAY_API_KEY" app.providerAddForm.APIKey = "sk-test-123" - app.providerAddForm.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions cmd := app.submitProviderAddForm() if cmd == nil { @@ -452,8 +451,8 @@ func TestSubmitProviderAddFormTransitionsToManualStageWhenModelSourceManual(t *t if app.providerAddForm.Stage != providerAddFormStageManualModels { t.Fatalf("expected form stage manual models, got %v", app.providerAddForm.Stage) } - if strings.TrimSpace(app.providerAddForm.ManualModelsJSON) != strings.TrimSpace(providerAddManualModelsJSONTemplate) { - t.Fatalf("expected manual model template to be prefilled, got %q", app.providerAddForm.ManualModelsJSON) + if strings.TrimSpace(app.providerAddForm.ManualModelsJSON) != "" { + t.Fatalf("expected manual model json buffer to stay empty, got %q", app.providerAddForm.ManualModelsJSON) } if app.state.StatusText != "Fill manual model JSON" { t.Fatalf("expected manual stage status text, got %q", app.state.StatusText) @@ -2299,7 +2298,7 @@ func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { t.Fatalf("expected name field append, got %q", app.providerAddForm.Name) } - app.providerAddForm.Step = 7 // api key env + app.providerAddForm.Step = 6 // api key env model, cmd = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\x00', 'D', 'E', 'E', 'P'}}) if cmd != nil { t.Fatalf("expected nil cmd for env key rune input") @@ -2452,7 +2451,7 @@ func TestBuildProviderAddRequest(t *testing.T) { } }) - t.Run("openai compat discover mode requires explicit discovery endpoint path", func(t *testing.T) { + t.Run("openai compat discover mode uses default discovery endpoint path", func(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ Name: "openai-compat", Driver: provider.DriverOpenAICompat, @@ -2460,8 +2459,11 @@ func TestBuildProviderAddRequest(t *testing.T) { APIKey: "k", APIKeyEnv: "OPENAI_COMPAT_API_KEY", }) - if !strings.Contains(err, "Discovery Endpoint is required") { - t.Fatalf("expected missing discovery endpoint error, got %q and req=%+v", err, req) + if err != "" { + t.Fatalf("expected default discovery endpoint, got %q and req=%+v", err, req) + } + if req.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { + t.Fatalf("expected default discovery endpoint /models, got %+v", req) } }) @@ -2470,6 +2472,7 @@ func TestBuildProviderAddRequest(t *testing.T) { Name: "openai-compat-discover", Driver: provider.DriverOpenAICompat, ModelSource: provider.ModelSourceDiscover, + ChatEndpointPath: "/chat/completions", APIKey: "k", APIKeyEnv: "OPENAI_COMPAT_DISCOVER_API_KEY", DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, @@ -2483,8 +2486,8 @@ func TestBuildProviderAddRequest(t *testing.T) { if req.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { t.Fatalf("expected default discovery endpoint, got %q", req.DiscoveryEndpointPath) } - if req.DiscoveryResponseProfile != provider.DiscoveryResponseProfileOpenAI { - t.Fatalf("expected default discovery response profile, got %q", req.DiscoveryResponseProfile) + if req.ChatEndpointPath != "/chat/completions" { + t.Fatalf("expected default chat endpoint, got %q", req.ChatEndpointPath) } }) @@ -2516,21 +2519,18 @@ func TestBuildProviderAddRequest(t *testing.T) { } }) - t.Run("gemini applies defaults and clears unrelated fields", func(t *testing.T) { + t.Run("gemini applies default base url", func(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ - Name: "gemini", - Driver: provider.DriverGemini, - ModelSource: provider.ModelSourceManual, - APIKey: "k", - APIKeyEnv: "GEMINI_GATEWAY_API_KEY", - APIStyle: "x", - APIVersion: "v", - DeploymentMode: "d", + Name: "gemini", + Driver: provider.DriverGemini, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "GEMINI_GATEWAY_API_KEY", }) if err != "" { t.Fatalf("unexpected error: %s", err) } - if req.BaseURL != config.GeminiDefaultBaseURL || req.APIStyle != "" || req.APIVersion != "" { + if req.BaseURL != config.GeminiDefaultBaseURL { t.Fatalf("expected gemini normalization, got %+v", req) } }) @@ -2548,17 +2548,16 @@ func TestBuildProviderAddRequest(t *testing.T) { } }) - t.Run("rejects invalid discovery response profile", func(t *testing.T) { + t.Run("rejects invalid chat endpoint path", func(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ - Name: "openai-compat", - Driver: provider.DriverOpenAICompat, - ModelSource: provider.ModelSourceDiscover, - APIKey: "k", - APIKeyEnv: "OPENAI_COMPAT_API_KEY", - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: "unsupported-profile", - }); !strings.Contains(err, "unsupported") { - t.Fatalf("expected invalid discovery response profile error, got %q", err) + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, + APIKey: "k", + APIKeyEnv: "OPENAI_COMPAT_API_KEY", + ChatEndpointPath: "https://api.example.com/chat/completions", + }); !strings.Contains(err, "relative path") { + t.Fatalf("expected invalid chat endpoint path error, got %q", err) } }) @@ -2587,18 +2586,17 @@ func TestBuildProviderAddRequest(t *testing.T) { t.Run("manual source clears discovery settings", func(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ - Name: "manual", - Driver: provider.DriverOpenAICompat, - ModelSource: provider.ModelSourceManual, - APIKey: "k", - APIKeyEnv: "MANUAL_GATEWAY_API_KEY", - DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, - DiscoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, + Name: "manual", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "MANUAL_GATEWAY_API_KEY", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, }) if err != "" { t.Fatalf("unexpected error: %s", err) } - if req.DiscoveryEndpointPath != "" || req.DiscoveryResponseProfile != "" { + if req.DiscoveryEndpointPath != "" { t.Fatalf("expected manual mode to clear discovery settings, got %+v", req) } }) @@ -3075,11 +3073,11 @@ func TestUpdateInputPanelTypingPathAndProviderAddFormExtraBranches(t *testing.T) app.startProviderAddForm() app.providerAddForm.Driver = provider.DriverAnthropic - app.providerAddForm.Step = 4 // api version + app.providerAddForm.Step = 4 // chat endpoint modelPtr, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2024-10-01")}) app = *modelPtr.(*App) - if app.providerAddForm.APIVersion == "" { - t.Fatalf("expected api version to accept rune input") + if app.providerAddForm.ChatEndpointPath == "" { + t.Fatalf("expected chat endpoint to accept rune input") } } @@ -3326,13 +3324,13 @@ func TestSlashSelectionAndProviderAddUtilityBranches(t *testing.T) { t.Fatalf("expected provider add visible fields to start from name field") } if !slices.Contains(fields, providerAddFieldDiscoveryEndpointPath) || - !slices.Contains(fields, providerAddFieldDiscoveryResponseProfile) { + !slices.Contains(fields, providerAddFieldChatEndpointPath) { t.Fatalf("expected discover source to include discovery fields") } manualFields := providerAddVisibleFields(provider.DriverOpenAICompat, provider.ModelSourceManual) if slices.Contains(manualFields, providerAddFieldDiscoveryEndpointPath) || - slices.Contains(manualFields, providerAddFieldDiscoveryResponseProfile) { + slices.Contains(manualFields, providerAddFieldDiscoveryEndpointPath) { t.Fatalf("expected manual source to exclude discovery fields") } clampProviderAddStep(nil) @@ -3369,7 +3367,7 @@ func TestRunProviderAddFlowDeadlineExceededBranch(t *testing.T) { Name: "demo", Driver: provider.DriverOpenAICompat, BaseURL: "https://example.com", - APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + ChatEndpointPath: "/chat/completions", DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, APIKeyEnv: "DEMO_API_KEY", APIKey: "secret", diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 9a61ccca..f1cd4f97 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -254,16 +254,15 @@ func (a App) renderProviderAddForm() string { required: baseURLRequired, note: note, }) - case providerAddFieldAPIStyle: + case providerAddFieldChatEndpointPath: note := "" - if strings.TrimSpace(a.providerAddForm.APIStyle) == "" { - note = "默认 chat_completions" + trimmedPath := strings.TrimSpace(a.providerAddForm.ChatEndpointPath) + if trimmedPath == "" || trimmedPath == "/" { + note = "留空或\"/\" 使用直连 base_url" + } else { + note = "以 \"/\" 开头的端点路径" } - fields = append(fields, renderField{label: "API Style", value: a.providerAddForm.APIStyle, note: note}) - case providerAddFieldDeploymentMode: - fields = append(fields, renderField{label: "Deployment Mode", value: a.providerAddForm.DeploymentMode}) - case providerAddFieldAPIVersion: - fields = append(fields, renderField{label: "API Version", value: a.providerAddForm.APIVersion}) + fields = append(fields, renderField{label: "Chat Endpoint", value: a.providerAddForm.ChatEndpointPath, note: note}) case providerAddFieldDiscoveryEndpointPath: note := "" if strings.TrimSpace(a.providerAddForm.DiscoveryEndpointPath) == "" { @@ -274,16 +273,6 @@ func (a App) renderProviderAddForm() string { value: a.providerAddForm.DiscoveryEndpointPath, note: note, }) - case providerAddFieldDiscoveryResponseProfile: - note := "支持 openai / gemini / generic" - if strings.TrimSpace(a.providerAddForm.DiscoveryResponseProfile) == "" { - note = "OpenAI-compatible 默认 openai" - } - fields = append(fields, renderField{ - label: "Discovery Profile", - value: a.providerAddForm.DiscoveryResponseProfile, - note: note, - }) case providerAddFieldAPIKeyEnv: fields = append(fields, renderField{label: "API Key Env", value: a.providerAddForm.APIKeyEnv, required: true}) case providerAddFieldAPIKey: @@ -315,7 +304,7 @@ func (a App) renderProviderAddForm() string { sb.WriteString("\n" + label + " " + a.providerAddForm.Error + "\n") } - sb.WriteString("\n[Tab] switch field [Enter] confirm [Esc] cancel") + sb.WriteString("\n[Tab] switch field [Up/Down or K/J] change option [Enter] confirm [Esc] cancel") return sb.String() } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 1dfbc76c..01c7c31c 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -9,7 +9,6 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" - "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" tuistate "neo-code/internal/tui/state" @@ -273,7 +272,7 @@ func TestRenderProviderAddFormMasksAPIKeyAndShowsHints(t *testing.T) { app.providerAddForm.Name = "team-gateway" app.providerAddForm.APIKey = "sk-secret-98765" app.providerAddForm.BaseURL = "" - app.providerAddForm.APIStyle = "" + app.providerAddForm.ChatEndpointPath = "" app.providerAddForm.Error = "input invalid" app.providerAddForm.ErrorIsHard = true @@ -290,8 +289,8 @@ func TestRenderProviderAddFormMasksAPIKeyAndShowsHints(t *testing.T) { if !strings.Contains(form, "留空会自动填充默认地址") { t.Fatalf("expected base url hint, got %q", form) } - if !strings.Contains(form, "默认 chat_completions") { - t.Fatalf("expected api style hint, got %q", form) + if !strings.Contains(form, "留空或\"/\" 使用直连 base_url") { + t.Fatalf("expected chat endpoint direct-mode hint, got %q", form) } if !strings.Contains(form, "[Error] input invalid") { t.Fatalf("expected hard error label, got %q", form) @@ -508,18 +507,16 @@ func TestRenderMessageBlockWithCopyExtraBranches(t *testing.T) { } } -func TestRenderProviderAddFormNoFormAndGeminiField(t *testing.T) { +func TestRenderProviderAddFormNoFormAndChatEndpointField(t *testing.T) { app, _ := newTestApp(t) if got := app.renderProviderAddForm(); got != "No form active" { t.Fatalf("unexpected no-form output: %q", got) } app.startProviderAddForm() - app.providerAddForm.Driver = provider.DriverGemini - app.providerAddForm.DeploymentMode = "vertex" form := app.renderProviderAddForm() - if !strings.Contains(form, "Deployment Mode") { - t.Fatalf("expected deployment mode field for gemini") + if !strings.Contains(form, "Chat Endpoint") { + t.Fatalf("expected chat endpoint field in add form") } } From e6188e6112f4aadb634442a4ccc90fd02a6a1745 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 19 Apr 2026 15:51:33 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B9=B1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/configuration.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 79f61f0d..42ea6e95 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -267,9 +267,3 @@ config: environment variable OPENAI_API_KEY is empty - [添加 Provider](./adding-providers.md) - [配置管理详细设计](../config-management-detail-design.md) - [Context Compact](../context-compact.md) - -## runtime.assets�������� - -- `runtime.assets.max_session_asset_bytes`������ `session_asset` ������ֽ�����Ĭ�� 20 MiB���� -- `runtime.assets.max_session_assets_total_bytes`������ provider ������ȫ�� `session_asset` ԭʼ�������ֽ����ޣ�Ĭ�� 20 MiB���� -- �������û�������ʱ�·��� session �洢�� provider ���󹹽�������·�������Ա���Ӳ���޶��ס� From da9fe34cbcab4e728a83c34ef4d18aad647be523 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 08:05:35 +0000 Subject: [PATCH 3/6] fix(config): propagate session asset limits via ToRuntimeConfig Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/config/provider.go | 11 ++++-- internal/config/provider_test.go | 62 +++++++++++++++++++++++++++++--- internal/runtime/run.go | 1 - 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index b07c0c3f..f54a50d7 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -34,7 +34,8 @@ type ProviderConfig struct { type ResolvedProviderConfig struct { ProviderConfig - APIKey string `yaml:"-"` + APIKey string `yaml:"-"` + SessionAssetLimits providertypes.SessionAssetLimits `yaml:"-"` } // ResolveSelectedProvider 解析当前配置中选中的 provider,并补全运行时所需的密钥信息。 @@ -48,7 +49,12 @@ func ResolveSelectedProvider(cfg Config) (ResolvedProviderConfig, error) { if err != nil { return ResolvedProviderConfig{}, err } - return providerCfg.Resolve() + resolved, err := providerCfg.Resolve() + if err != nil { + return ResolvedProviderConfig{}, err + } + resolved.SessionAssetLimits = cfg.Runtime.ResolveSessionAssetLimits() + return resolved, nil } func (p ProviderConfig) Validate() error { @@ -210,6 +216,7 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { BaseURL: baseURL, DefaultModel: p.Model, APIKey: p.APIKey, + SessionAssetLimits: p.SessionAssetLimits, ChatEndpointPath: normalizedProtocols.ChatEndpointPath, DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, ModelFieldAliases: p.ModelFieldAliases, diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index ceaefd72..0c070337 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -62,6 +62,50 @@ func TestResolveSelectedProviderNotFound(t *testing.T) { } } +func TestResolveSelectedProviderIncludesRuntimeSessionAssetLimits(t *testing.T) { + const ( + maxSingle = int64(128) + maxTotal = int64(512) + ) + envName := "TEST_PROVIDER_KEY" + t.Setenv(envName, "secret") + + cfg := Config{ + SelectedProvider: "custom", + Providers: []ProviderConfig{ + { + Name: "custom", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: envName, + Source: ProviderSourceCustom, + }, + }, + Runtime: RuntimeConfig{ + Assets: RuntimeAssetsConfig{ + MaxSessionAssetBytes: maxSingle, + MaxSessionAssetsTotalBytes: maxTotal, + }, + }, + } + + resolved, err := ResolveSelectedProvider(cfg) + if err != nil { + t.Fatalf("ResolveSelectedProvider() error = %v", err) + } + + if resolved.SessionAssetLimits.MaxSessionAssetBytes != maxSingle { + t.Fatalf("expected MaxSessionAssetBytes=%d, got %d", maxSingle, resolved.SessionAssetLimits.MaxSessionAssetBytes) + } + if resolved.SessionAssetLimits.MaxSessionAssetsTotalBytes != maxTotal { + t.Fatalf( + "expected MaxSessionAssetsTotalBytes=%d, got %d", + maxTotal, + resolved.SessionAssetLimits.MaxSessionAssetsTotalBytes, + ) + } +} + func TestProviderConfigIdentity(t *testing.T) { t.Parallel() @@ -514,15 +558,23 @@ func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { Model: "server-default", }, APIKey: "secret-key", + SessionAssetLimits: providertypes.SessionAssetLimits{ + MaxSessionAssetBytes: 1024, + MaxSessionAssetsTotalBytes: 2048, + }, } got := resolved.ToRuntimeConfig() want := providerpkg.RuntimeConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - DefaultModel: "server-default", - APIKey: "secret-key", + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + DefaultModel: "server-default", + APIKey: "secret-key", + SessionAssetLimits: providertypes.SessionAssetLimits{ + MaxSessionAssetBytes: 1024, + MaxSessionAssetsTotalBytes: 2048, + }, ChatEndpointPath: "", DiscoveryEndpointPath: providerpkg.DiscoveryEndpointPathModels, } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 5487420a..ab59c997 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -263,7 +263,6 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur return turnSnapshot{}, false, err } providerRuntimeCfg := resolvedProvider.ToRuntimeConfig() - providerRuntimeCfg.SessionAssetLimits = cfg.Runtime.ResolveSessionAssetLimits() state.mu.Lock() streak := state.progress.LastScore.NoProgressStreak From 71713144f49e421c010a97c940ac3d01cfe19026 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 08:21:04 +0000 Subject: [PATCH 4/6] fix(runtime,config): unify compact asset limits and add provider yaml migration script Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/guides/configuration.md | 21 +- internal/runtime/compact.go | 1 + .../runtime_remaining_branches_test.go | 41 +++ scripts/migrate_provider_yaml.go | 260 ++++++++++++++++++ scripts/migrate_provider_yaml_test.go | 169 ++++++++++++ 5 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 scripts/migrate_provider_yaml.go create mode 100644 scripts/migrate_provider_yaml_test.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 42ea6e95..2e15d0ba 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -141,9 +141,9 @@ name: company-gateway driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY model_source: discover -openai_compatible: - base_url: https://llm.example.com/v1 - api_style: chat_completions +base_url: https://llm.example.com/v1 +chat_endpoint_path: /chat/completions +discovery_endpoint_path: /models ``` `model_source` 语义如下: @@ -158,8 +158,8 @@ name: company-gateway-manual driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY model_source: manual -openai_compatible: - base_url: https://llm.example.com/v1 +base_url: https://llm.example.com/v1 +chat_endpoint_path: /chat/completions models: - id: gpt-4o-mini name: GPT-4o Mini @@ -171,6 +171,17 @@ models: - 老配置未声明 `model_source` 时,默认按 `discover` 处理。 - `manual` 模式下必须提供 `models`,否则会在加载/创建阶段报错。 - `manual` 模式会忽略 discovery 相关字段(如 `discovery_endpoint_path`、`discovery_response_profile`)。 +- 旧版嵌套字段(如 `openai_compatible/gemini/anthropic`)在严格校验下会被拒绝,升级前请先执行迁移脚本: + +```bash +go run ./scripts/migrate_provider_yaml.go --base-dir ~/.neocode +``` + +仅预览不落盘: + +```bash +go run ./scripts/migrate_provider_yaml.go --base-dir ~/.neocode --dry-run +``` ## Auto Compact 失败与校验补充 diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 1d16c9ee..5ecbf131 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -180,6 +180,7 @@ func resolveCompactProviderSelection(session agentsession.Session, cfg config.Co if err != nil { return config.ResolvedProviderConfig{}, "", err } + resolved.SessionAssetLimits = cfg.Runtime.ResolveSessionAssetLimits() return resolved, sessionModel, nil } diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index e5fd395c..398a4add 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -171,6 +171,47 @@ func TestResolveCompactProviderSelectionResolveErrorBranch(t *testing.T) { } } +func TestResolveCompactProviderSelectionSessionBranchInjectsRuntimeAssetLimits(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Runtime.Assets.MaxSessionAssetBytes = 1024 + cfg.Runtime.Assets.MaxSessionAssetsTotalBytes = 4096 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + cfg := manager.Get() + + resolved, model, err := resolveCompactProviderSelection(agentsession.Session{ + Provider: cfg.SelectedProvider, + Model: "session-model", + }, cfg) + if err != nil { + t.Fatalf("resolveCompactProviderSelection() error = %v", err) + } + if model != "session-model" { + t.Fatalf("expected model=session-model, got %q", model) + } + + expected := cfg.Runtime.ResolveSessionAssetLimits() + if resolved.SessionAssetLimits.MaxSessionAssetBytes != expected.MaxSessionAssetBytes { + t.Fatalf( + "expected MaxSessionAssetBytes=%d, got %d", + expected.MaxSessionAssetBytes, + resolved.SessionAssetLimits.MaxSessionAssetBytes, + ) + } + if resolved.SessionAssetLimits.MaxSessionAssetsTotalBytes != expected.MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected MaxSessionAssetsTotalBytes=%d, got %d", + expected.MaxSessionAssetsTotalBytes, + resolved.SessionAssetLimits.MaxSessionAssetsTotalBytes, + ) + } +} + func TestGenerateStreamingMessageHooksAndContextDoneBranches(t *testing.T) { t.Parallel() diff --git a/scripts/migrate_provider_yaml.go b/scripts/migrate_provider_yaml.go new file mode 100644 index 00000000..4a974b04 --- /dev/null +++ b/scripts/migrate_provider_yaml.go @@ -0,0 +1,260 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + defaultNeoCodeDirName = ".neocode" + providerConfigFileName = "provider.yaml" +) + +// migrationFile 表示迁移脚本支持处理的 provider.yaml 字段集合(含旧版嵌套块)。 +type migrationFile struct { + Name string `yaml:"name"` + Driver string `yaml:"driver"` + APIKeyEnv string `yaml:"api_key_env"` + ModelSource string `yaml:"model_source,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` + Models []map[string]any `yaml:"models,omitempty"` + OpenAICompatible legacyProviderProtocol `yaml:"openai_compatible,omitempty"` + Gemini legacyProviderProtocol `yaml:"gemini,omitempty"` + Anthropic legacyProviderProtocol `yaml:"anthropic,omitempty"` +} + +// legacyProviderProtocol 描述旧版 provider.yaml 里各 driver 嵌套块的公共可迁移字段。 +type legacyProviderProtocol struct { + BaseURL string `yaml:"base_url,omitempty"` + ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` + DiscoveryEndpointPath string `yaml:"discovery_endpoint_path,omitempty"` +} + +// hasValue 判断旧块是否含有可迁移信息。 +func (l legacyProviderProtocol) hasValue() bool { + return strings.TrimSpace(l.BaseURL) != "" || + strings.TrimSpace(l.ChatEndpointPath) != "" || + strings.TrimSpace(l.DiscoveryEndpointPath) != "" +} + +// migrationResult 汇总单个 provider.yaml 的迁移结果。 +type migrationResult struct { + Path string + Changed bool + Backup string + Reason string + FileName string +} + +// main 解析命令参数并执行 provider.yaml 迁移。 +func main() { + baseDirDefault := defaultBaseDir() + baseDir := flag.String("base-dir", baseDirDefault, "NeoCode 配置根目录(默认 ~/.neocode)") + target := flag.String("target", "", "指定待迁移文件或目录;为空时扫描 /providers") + dryRun := flag.Bool("dry-run", false, "仅预览迁移结果,不写入文件") + flag.Parse() + + results, err := runMigration(strings.TrimSpace(*baseDir), strings.TrimSpace(*target), *dryRun) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "迁移失败: %v\n", err) + os.Exit(1) + } + + changed := 0 + for _, result := range results { + if result.Changed { + changed++ + if *dryRun { + fmt.Printf("[DRY-RUN] 将迁移: %s\n", result.Path) + } else { + fmt.Printf("已迁移: %s (备份: %s)\n", result.Path, result.Backup) + } + continue + } + if result.Reason != "" { + fmt.Printf("跳过: %s (%s)\n", result.Path, result.Reason) + } + } + + fmt.Printf("完成: 总计 %d 个文件,发生迁移 %d 个\n", len(results), changed) +} + +// runMigration 收集目标 provider.yaml 文件并逐个执行迁移。 +func runMigration(baseDir string, target string, dryRun bool) ([]migrationResult, error) { + files, err := collectProviderFiles(baseDir, target) + if err != nil { + return nil, err + } + + results := make([]migrationResult, 0, len(files)) + for _, file := range files { + result, err := migrateProviderFile(file, dryRun) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} + +// collectProviderFiles 解析 target 入参并返回需要处理的 provider.yaml 列表。 +func collectProviderFiles(baseDir string, target string) ([]string, error) { + if target != "" { + info, err := os.Stat(target) + if err != nil { + return nil, fmt.Errorf("stat target: %w", err) + } + if info.IsDir() { + return collectProviderFilesFromProvidersDir(target) + } + if filepath.Base(target) != providerConfigFileName { + return nil, fmt.Errorf("target must be %s or a providers dir", providerConfigFileName) + } + return []string{target}, nil + } + + providersDir := filepath.Join(baseDir, "providers") + return collectProviderFilesFromProvidersDir(providersDir) +} + +// collectProviderFilesFromProvidersDir 从 providers 目录中收集所有 provider.yaml 文件。 +func collectProviderFilesFromProvidersDir(providersDir string) ([]string, error) { + entries, err := os.ReadDir(providersDir) + if err != nil { + return nil, fmt.Errorf("read providers dir: %w", err) + } + + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + filePath := filepath.Join(providersDir, entry.Name(), providerConfigFileName) + if _, err := os.Stat(filePath); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("stat provider file %s: %w", filePath, err) + } + files = append(files, filePath) + } + if len(files) == 0 { + return nil, fmt.Errorf("no %s found under %s", providerConfigFileName, providersDir) + } + return files, nil +} + +// migrateProviderFile 对单个 provider.yaml 执行迁移并在非 dry-run 模式下落盘。 +func migrateProviderFile(path string, dryRun bool) (migrationResult, error) { + result := migrationResult{Path: path, FileName: filepath.Base(path)} + + raw, err := os.ReadFile(path) + if err != nil { + return result, fmt.Errorf("read %s: %w", path, err) + } + + migrated, changed, err := migrateProviderContent(raw) + if err != nil { + return result, fmt.Errorf("migrate %s: %w", path, err) + } + if !changed { + result.Reason = "未检测到旧版嵌套字段" + return result, nil + } + + result.Changed = true + if dryRun { + return result, nil + } + + backup := path + ".bak" + if err := os.WriteFile(backup, raw, 0o644); err != nil { + return result, fmt.Errorf("write backup %s: %w", backup, err) + } + if err := os.WriteFile(path, migrated, 0o644); err != nil { + return result, fmt.Errorf("write migrated %s: %w", path, err) + } + result.Backup = backup + return result, nil +} + +// migrateProviderContent 将旧版嵌套字段收敛为当前扁平字段。 +func migrateProviderContent(raw []byte) ([]byte, bool, error) { + var cfg migrationFile + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, false, err + } + + legacy := selectLegacyBlock(cfg) + if !legacy.hasValue() { + return raw, false, nil + } + + if strings.TrimSpace(cfg.BaseURL) == "" { + cfg.BaseURL = strings.TrimSpace(legacy.BaseURL) + } + if strings.TrimSpace(cfg.ChatEndpointPath) == "" { + cfg.ChatEndpointPath = strings.TrimSpace(legacy.ChatEndpointPath) + } + if strings.TrimSpace(cfg.DiscoveryEndpointPath) == "" { + cfg.DiscoveryEndpointPath = strings.TrimSpace(legacy.DiscoveryEndpointPath) + } + + cfg.OpenAICompatible = legacyProviderProtocol{} + cfg.Gemini = legacyProviderProtocol{} + cfg.Anthropic = legacyProviderProtocol{} + + out, err := yaml.Marshal(cfg) + if err != nil { + return nil, false, err + } + return out, true, nil +} + +// selectLegacyBlock 按 driver 优先选择对应旧版块,若 driver 未命中则回退到首个非空块。 +func selectLegacyBlock(cfg migrationFile) legacyProviderProtocol { + driver := normalizeDriver(cfg.Driver) + switch driver { + case "openaicompat": + if cfg.OpenAICompatible.hasValue() { + return cfg.OpenAICompatible + } + case "gemini": + if cfg.Gemini.hasValue() { + return cfg.Gemini + } + case "anthropic": + if cfg.Anthropic.hasValue() { + return cfg.Anthropic + } + } + + for _, candidate := range []legacyProviderProtocol{cfg.OpenAICompatible, cfg.Gemini, cfg.Anthropic} { + if candidate.hasValue() { + return candidate + } + } + return legacyProviderProtocol{} +} + +// normalizeDriver 统一 driver 字段,避免大小写和空白导致分支漂移。 +func normalizeDriver(driver string) string { + return strings.ToLower(strings.TrimSpace(driver)) +} + +// defaultBaseDir 返回当前用户主目录下默认的 NeoCode 配置路径。 +func defaultBaseDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join("~", defaultNeoCodeDirName) + } + return filepath.Join(home, defaultNeoCodeDirName) +} diff --git a/scripts/migrate_provider_yaml_test.go b/scripts/migrate_provider_yaml_test.go new file mode 100644 index 00000000..d615f09f --- /dev/null +++ b/scripts/migrate_provider_yaml_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMigrateProviderContentFlattensLegacyOpenAICompatibleBlock(t *testing.T) { + t.Parallel() + + input := []byte(strings.TrimSpace(` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_API_KEY +openai_compatible: + base_url: https://llm.example.com/v1 + chat_endpoint_path: /gateway/chat + discovery_endpoint_path: /gateway/models +`) + "\n") + + out, changed, err := migrateProviderContent(input) + if err != nil { + t.Fatalf("migrateProviderContent() error = %v", err) + } + if !changed { + t.Fatal("expected migration change") + } + text := string(out) + if strings.Contains(text, "openai_compatible:") { + t.Fatalf("expected legacy block removed, got:\n%s", text) + } + if !strings.Contains(text, "base_url: https://llm.example.com/v1") { + t.Fatalf("expected base_url migrated, got:\n%s", text) + } + if !strings.Contains(text, "chat_endpoint_path: /gateway/chat") { + t.Fatalf("expected chat_endpoint_path migrated, got:\n%s", text) + } + if !strings.Contains(text, "discovery_endpoint_path: /gateway/models") { + t.Fatalf("expected discovery_endpoint_path migrated, got:\n%s", text) + } +} + +func TestMigrateProviderContentKeepsExistingFlatFields(t *testing.T) { + t.Parallel() + + input := []byte(strings.TrimSpace(` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_API_KEY +base_url: https://new.example.com/v1 +chat_endpoint_path: /new/chat +openai_compatible: + base_url: https://old.example.com/v1 + chat_endpoint_path: /old/chat +`) + "\n") + + out, changed, err := migrateProviderContent(input) + if err != nil { + t.Fatalf("migrateProviderContent() error = %v", err) + } + if !changed { + t.Fatal("expected migration change") + } + text := string(out) + if !strings.Contains(text, "base_url: https://new.example.com/v1") { + t.Fatalf("expected existing flat base_url kept, got:\n%s", text) + } + if !strings.Contains(text, "chat_endpoint_path: /new/chat") { + t.Fatalf("expected existing flat chat_endpoint_path kept, got:\n%s", text) + } + if strings.Contains(text, "old.example.com") { + t.Fatalf("expected old legacy value not override flat value, got:\n%s", text) + } +} + +func TestMigrateProviderContentNoLegacyReturnsUnchanged(t *testing.T) { + t.Parallel() + + input := []byte(strings.TrimSpace(` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_API_KEY +base_url: https://new.example.com/v1 +`) + "\n") + + out, changed, err := migrateProviderContent(input) + if err != nil { + t.Fatalf("migrateProviderContent() error = %v", err) + } + if changed { + t.Fatal("expected no migration change") + } + if string(out) != string(input) { + t.Fatalf("expected output unchanged, got:\n%s", string(out)) + } +} + +func TestCollectProviderFilesFromProvidersDir(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + providersDir := filepath.Join(baseDir, "providers") + if err := os.MkdirAll(filepath.Join(providersDir, "a"), 0o755); err != nil { + t.Fatalf("mkdir provider a: %v", err) + } + if err := os.MkdirAll(filepath.Join(providersDir, "b"), 0o755); err != nil { + t.Fatalf("mkdir provider b: %v", err) + } + if err := os.WriteFile(filepath.Join(providersDir, "a", providerConfigFileName), []byte("name: a\n"), 0o644); err != nil { + t.Fatalf("write provider a: %v", err) + } + if err := os.WriteFile(filepath.Join(providersDir, "b", providerConfigFileName), []byte("name: b\n"), 0o644); err != nil { + t.Fatalf("write provider b: %v", err) + } + + files, err := collectProviderFiles(baseDir, "") + if err != nil { + t.Fatalf("collectProviderFiles() error = %v", err) + } + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } +} + +func TestMigrateProviderFileCreatesBackup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, providerConfigFileName) + original := strings.TrimSpace(` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_API_KEY +openai_compatible: + base_url: https://llm.example.com/v1 +`) + "\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("write provider file: %v", err) + } + + result, err := migrateProviderFile(path, false) + if err != nil { + t.Fatalf("migrateProviderFile() error = %v", err) + } + if !result.Changed { + t.Fatal("expected file changed") + } + if strings.TrimSpace(result.Backup) == "" { + t.Fatal("expected backup path") + } + + backupContent, err := os.ReadFile(result.Backup) + if err != nil { + t.Fatalf("read backup: %v", err) + } + if string(backupContent) != original { + t.Fatalf("expected backup keep original content, got:\n%s", string(backupContent)) + } + + newContent, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read migrated file: %v", err) + } + if strings.Contains(string(newContent), "openai_compatible:") { + t.Fatalf("expected migrated file remove legacy block, got:\n%s", string(newContent)) + } +} From 06bc56f2cb5273c9545dd339803e8320af330768 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 08:40:17 +0000 Subject: [PATCH 5/6] fix(config): fail fast on runtime provider config normalization - make ResolvedProviderConfig.ToRuntimeConfig return error and wire explicit handling through runtime/app/config-state paths - add tests for ToRuntimeConfig error path and runtime asset zero fallback semantics - document runtime.max_repeat_cycle_streak and runtime.assets fields with zero-value behavior Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/guides/configuration.md | 7 ++++ internal/app/bootstrap.go | 6 +++- internal/config/config_test.go | 5 ++- internal/config/provider.go | 9 +++-- internal/config/provider_test.go | 33 +++++++++++++++-- internal/config/runtime.go | 6 ++-- internal/config/runtime_test.go | 25 +++++++++++++ internal/config/state/model.go | 2 +- internal/provider/catalog/service_test.go | 2 +- internal/provider/types/asset_limits_test.go | 19 ++++++++++ internal/runtime/compact.go | 6 +++- internal/runtime/compact_generator_test.go | 37 +++++++++++++++++--- internal/runtime/run.go | 5 ++- internal/runtime/subagent_engine.go | 6 +++- 14 files changed, 148 insertions(+), 20 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 2e15d0ba..776f3d91 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -39,6 +39,10 @@ shell: bash tool_timeout_sec: 20 runtime: max_no_progress_streak: 3 + max_repeat_cycle_streak: 3 + assets: + max_session_asset_bytes: 20971520 + max_session_assets_total_bytes: 20971520 tools: webfetch: @@ -90,6 +94,9 @@ context: | 字段 | 说明 | |------|------| | `runtime.max_no_progress_streak` | 连续”无进展”轮次熔断阈值,默认 `3`;streak 达到 `limit-1`(默认第 2 轮)时向模型注入一次系统级纠偏提示,达到 `limit`(默认第 3 轮)时终止运行 | +| `runtime.max_repeat_cycle_streak` | 连续“重复调用同一工具参数”轮次熔断阈值,默认 `3`;达到阈值后终止运行 | +| `runtime.assets.max_session_asset_bytes` | 单个 `session_asset` 最大原始字节数,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 | +| `runtime.assets.max_session_assets_total_bytes` | 单次请求可携带的 `session_asset` 原始总字节上限,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 | ### `tools` 字段 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 05bde9c4..82f39939 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -67,7 +67,11 @@ func newMemoExtractorAdapter( } generator := textGenAdapter(func(ctx context.Context, prompt string, msgs []providertypes.Message) (string, error) { - p, err := factory.Build(ctx, resolved.ToRuntimeConfig()) + runtimeConfig, err := resolved.ToRuntimeConfig() + if err != nil { + return "", err + } + p, err := factory.Build(ctx, runtimeConfig) if err != nil { return "", err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 30197b74..e20c3bc7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1919,7 +1919,10 @@ func TestToRuntimeConfigMapsAllFields(t *testing.T) { APIKey: "resolved-secret-key", } - got := resolved.ToRuntimeConfig() + got, err := resolved.ToRuntimeConfig() + if err != nil { + t.Fatalf("ToRuntimeConfig() error = %v", err) + } if got.Name != "test-provider" { t.Fatalf("expected Name=test-provider, got %q", got.Name) } diff --git a/internal/config/provider.go b/internal/config/provider.go index f54a50d7..426da2c2 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -206,8 +206,11 @@ func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, } // ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 -func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { - normalizedProtocols, _ := normalizeProviderProtocolSettingsFromConfig(p.ProviderConfig) +func (p ResolvedProviderConfig) ToRuntimeConfig() (provider.RuntimeConfig, error) { + normalizedProtocols, err := normalizeProviderProtocolSettingsFromConfig(p.ProviderConfig) + if err != nil { + return provider.RuntimeConfig{}, err + } baseURL := sanitizeRuntimeBaseURL(p.BaseURL) return provider.RuntimeConfig{ @@ -220,7 +223,7 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { ChatEndpointPath: normalizedProtocols.ChatEndpointPath, DiscoveryEndpointPath: normalizedProtocols.DiscoveryEndpointPath, ModelFieldAliases: p.ModelFieldAliases, - } + }, nil } // normalizeProviderProtocolSettingsFromConfig 根据最小外部字段推导 provider 内部协议配置。 diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 0c070337..a602d6cb 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -564,7 +564,10 @@ func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { }, } - got := resolved.ToRuntimeConfig() + got, err := resolved.ToRuntimeConfig() + if err != nil { + t.Fatalf("ToRuntimeConfig() error = %v", err) + } want := providerpkg.RuntimeConfig{ Name: "company-gateway", Driver: "openaicompat", @@ -597,7 +600,10 @@ func TestResolvedProviderConfigToRuntimeConfigStripsBaseURLUserinfo(t *testing.T APIKey: "secret-key", } - got := resolved.ToRuntimeConfig() + got, err := resolved.ToRuntimeConfig() + if err != nil { + t.Fatalf("ToRuntimeConfig() error = %v", err) + } if strings.Contains(got.BaseURL, "token@") { t.Fatalf("expected runtime base URL to strip userinfo, got %q", got.BaseURL) } @@ -605,3 +611,26 @@ func TestResolvedProviderConfigToRuntimeConfigStripsBaseURLUserinfo(t *testing.T t.Fatalf("expected sanitized runtime base URL, got %q", got.BaseURL) } } + +func TestResolvedProviderConfigToRuntimeConfigReturnsProtocolNormalizationError(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "TEST_KEY", + ChatEndpointPath: "https://llm.example.com/chat/completions", + }, + APIKey: "secret-key", + } + + _, err := resolved.ToRuntimeConfig() + if err == nil { + t.Fatal("expected ToRuntimeConfig() to return normalization error for invalid chat endpoint path") + } + if !strings.Contains(err.Error(), "must be a relative path") { + t.Fatalf("expected relative path error, got %v", err) + } +} diff --git a/internal/config/runtime.go b/internal/config/runtime.go index 9835fb5d..9390e1e8 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -50,7 +50,7 @@ func (c RuntimeConfig) Clone() RuntimeConfig { } } -// ApplyDefaults 在配置缺失或非法时回填默认阈值。 +// ApplyDefaults 在配置缺失、为零或非法时回填默认阈值。 func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { if c == nil { return @@ -88,7 +88,7 @@ func (c RuntimeAssetsConfig) Clone() RuntimeAssetsConfig { return c } -// ApplyDefaults 在配置缺失或非法时回填附件限制默认值。 +// ApplyDefaults 在配置缺失、为零或非法时回填附件限制默认值。 func (c *RuntimeAssetsConfig) ApplyDefaults(defaults RuntimeAssetsConfig) { if c == nil { return @@ -101,7 +101,7 @@ func (c *RuntimeAssetsConfig) ApplyDefaults(defaults RuntimeAssetsConfig) { } } -// Validate 校验附件限制配置是否满足最小约束。 +// Validate 校验附件限制配置是否满足最小约束;0 表示使用默认值,仅禁止负数。 func (c RuntimeAssetsConfig) Validate() error { if c.MaxSessionAssetBytes < 0 { return errors.New("runtime.assets.max_session_asset_bytes must be greater than or equal to 0") diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index 23f0b711..1952198c 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -121,3 +121,28 @@ func TestRuntimeConfigValidate(t *testing.T) { t.Fatal("expected validation error for assets.max_session_assets_total_bytes=-1") } } + +func TestRuntimeAssetsConfigZeroValuesResolveToDefaults(t *testing.T) { + t.Parallel() + + cfg := RuntimeAssetsConfig{ + MaxSessionAssetBytes: 0, + MaxSessionAssetsTotalBytes: 0, + } + resolved := cfg.ResolveSessionAssetLimits() + defaults := defaultRuntimeAssetsConfig() + if resolved.MaxSessionAssetBytes != defaults.MaxSessionAssetBytes { + t.Fatalf( + "expected MaxSessionAssetBytes to fallback to default=%d, got %d", + defaults.MaxSessionAssetBytes, + resolved.MaxSessionAssetBytes, + ) + } + if resolved.MaxSessionAssetsTotalBytes != defaults.MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected MaxSessionAssetsTotalBytes to fallback to default=%d, got %d", + defaults.MaxSessionAssetsTotalBytes, + resolved.MaxSessionAssetsTotalBytes, + ) + } +} diff --git a/internal/config/state/model.go b/internal/config/state/model.go index 1f31f0ba..496cc5e9 100644 --- a/internal/config/state/model.go +++ b/internal/config/state/model.go @@ -104,7 +104,7 @@ func catalogInputFromProvider(cfg config.ProviderConfig) (provider.CatalogInput, if err != nil { return provider.RuntimeConfig{}, err } - return resolved.ToRuntimeConfig(), nil + return resolved.ToRuntimeConfig() }, } if cloned.Source != config.ProviderSourceCustom { diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 00f175ee..07ebe084 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -666,7 +666,7 @@ func mustCatalogInput(t *testing.T, cfg config.ProviderConfig) provider.CatalogI if err != nil { return provider.RuntimeConfig{}, err } - return resolved.ToRuntimeConfig(), nil + return resolved.ToRuntimeConfig() }, } if cloned.Source != config.ProviderSourceCustom { diff --git a/internal/provider/types/asset_limits_test.go b/internal/provider/types/asset_limits_test.go index 72b2c2ba..7f6c4c78 100644 --- a/internal/provider/types/asset_limits_test.go +++ b/internal/provider/types/asset_limits_test.go @@ -21,6 +21,25 @@ func TestNormalizeSessionAssetLimits(t *testing.T) { } }) + t.Run("defaults when explicit zero values are provided", func(t *testing.T) { + t.Parallel() + + got := NormalizeSessionAssetLimits(SessionAssetLimits{ + MaxSessionAssetBytes: 0, + MaxSessionAssetsTotalBytes: 0, + }) + if got.MaxSessionAssetBytes != MaxSessionAssetBytes { + t.Fatalf("expected default MaxSessionAssetBytes=%d, got %d", MaxSessionAssetBytes, got.MaxSessionAssetBytes) + } + if got.MaxSessionAssetsTotalBytes != MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected default MaxSessionAssetsTotalBytes=%d, got %d", + MaxSessionAssetsTotalBytes, + got.MaxSessionAssetsTotalBytes, + ) + } + }) + t.Run("clamps to hard max", func(t *testing.T) { t.Parallel() diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 5ecbf131..49406a9d 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -164,7 +164,11 @@ func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config. if err != nil { return nil, err } - return contextcompact.NewRunner(newCompactSummaryGenerator(s.providerFactory, resolvedProvider.ToRuntimeConfig(), model)), nil + runtimeConfig, err := resolvedProvider.ToRuntimeConfig() + if err != nil { + return nil, err + } + return contextcompact.NewRunner(newCompactSummaryGenerator(s.providerFactory, runtimeConfig, model)), nil } // resolveCompactProviderSelection 优先复用会话最近成功运行时记录的 provider 与 model。 diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index ebfd65da..974ebeb3 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -19,6 +19,17 @@ func validCompactSummaryJSON() string { return `{"task_state":{"goal":"Finish task state refactor","progress":["Persisted task_state in session"],"open_items":["Update runtime tests"],"next_step":"Continue from retained context","blockers":[],"key_artifacts":["internal/runtime/compact_generator.go"],"decisions":["Do not keep old summary-only protocol"],"user_constraints":["No backward compatibility"]},"display_summary":"[compact_summary]\ndone:\n- Persisted durable task state.\n\nin_progress:\n- Continue from the retained recent window.\n\ndecisions:\n- Do not keep the old summary-only protocol.\n\ncode_changes:\n- Updated compact summary generation behavior.\n\nconstraints:\n- Preserve only the minimum information needed to continue the work."}` } +// mustRuntimeConfigForCompactTests 将解析后的 provider 配置转换为 runtime 配置,失败时直接终止测试。 +func mustRuntimeConfigForCompactTests(t *testing.T, resolved config.ResolvedProviderConfig) provider.RuntimeConfig { + t.Helper() + + runtimeCfg, err := resolved.ToRuntimeConfig() + if err != nil { + t.Fatalf("ToRuntimeConfig() error = %v", err) + } + return runtimeCfg +} + func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) { t.Parallel() @@ -34,7 +45,7 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) }, } factory := &scriptedProviderFactory{provider: scripted} - generator := newCompactSummaryGenerator(factory, resolvedProvider.ToRuntimeConfig(), "session-model") + generator := newCompactSummaryGenerator(factory, mustRuntimeConfigForCompactTests(t, resolvedProvider), "session-model") summary, err := generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -130,7 +141,11 @@ func TestCompactSummaryGeneratorRejectsToolCalls(t *testing.T) { }, }, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") + generator := newCompactSummaryGenerator( + &scriptedProviderFactory{provider: scripted}, + mustRuntimeConfigForCompactTests(t, resolvedProvider), + "session-model", + ) _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -160,7 +175,11 @@ func TestCompactSummaryGeneratorRejectsMalformedStreamEvent(t *testing.T) { }, }, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") + generator := newCompactSummaryGenerator( + &scriptedProviderFactory{provider: scripted}, + mustRuntimeConfigForCompactTests(t, resolvedProvider), + "session-model", + ) _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -190,7 +209,11 @@ func TestCompactSummaryGeneratorRejectsCompletionWithoutMessageDone(t *testing.T return nil }, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") + generator := newCompactSummaryGenerator( + &scriptedProviderFactory{provider: scripted}, + mustRuntimeConfigForCompactTests(t, resolvedProvider), + "session-model", + ) _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -220,7 +243,11 @@ func TestCompactSummaryGeneratorMalformedStreamEventDoesNotDeadlock(t *testing.T scripted := &scriptedProvider{ streams: [][]providertypes.StreamEvent{stream}, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") + generator := newCompactSummaryGenerator( + &scriptedProviderFactory{provider: scripted}, + mustRuntimeConfigForCompactTests(t, resolvedProvider), + "session-model", + ) errCh := make(chan error, 1) go func() { diff --git a/internal/runtime/run.go b/internal/runtime/run.go index ab59c997..72c6619c 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -262,7 +262,10 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur if err != nil { return turnSnapshot{}, false, err } - providerRuntimeCfg := resolvedProvider.ToRuntimeConfig() + providerRuntimeCfg, err := resolvedProvider.ToRuntimeConfig() + if err != nil { + return turnSnapshot{}, false, err + } state.mu.Lock() streak := state.progress.LastScore.NoProgressStreak diff --git a/internal/runtime/subagent_engine.go b/internal/runtime/subagent_engine.go index 5494160d..7d81b6be 100644 --- a/internal/runtime/subagent_engine.go +++ b/internal/runtime/subagent_engine.go @@ -192,7 +192,11 @@ func (e runtimeSubAgentEngine) buildProvider( ctx context.Context, resolvedProvider config.ResolvedProviderConfig, ) (provider.Provider, error) { - modelProvider, err := e.service.providerFactory.Build(ctx, resolvedProvider.ToRuntimeConfig()) + runtimeConfig, err := resolvedProvider.ToRuntimeConfig() + if err != nil { + return nil, fmt.Errorf("runtime: normalize subagent provider config: %w", err) + } + modelProvider, err := e.service.providerFactory.Build(ctx, runtimeConfig) if err != nil { return nil, fmt.Errorf("runtime: build subagent provider: %w", err) } From f009812d81d7eaabb4604fe878a35cec4a3e88b4 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 19 Apr 2026 08:53:58 +0000 Subject: [PATCH 6/6] test: improve coverage for provider defaults and migration script Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/provider/chat_endpoint_test.go | 12 ++ internal/provider/driver_defaults_test.go | 139 +++++++++++++ internal/provider/types/asset_limits_test.go | 16 ++ scripts/migrate_provider_yaml_test.go | 201 +++++++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 internal/provider/driver_defaults_test.go diff --git a/internal/provider/chat_endpoint_test.go b/internal/provider/chat_endpoint_test.go index 311e6813..0a7e2738 100644 --- a/internal/provider/chat_endpoint_test.go +++ b/internal/provider/chat_endpoint_test.go @@ -86,6 +86,18 @@ func TestResolveChatEndpointURL(t *testing.T) { path: "", want: "https://api.example.com/v1/text/chatcompletion_v2", }, + { + name: "appends non-slash-prefixed path and trims base slash", + baseURL: " https://api.example.com/v1/ ", + path: "chat/completions", + want: "https://api.example.com/v1/chat/completions", + }, + { + name: "rejects absolute chat endpoint path", + baseURL: "https://api.example.com/v1", + path: "https://api.example.com/chat/completions", + wantErr: "chat endpoint path", + }, { name: "invalid base url in direct mode returns actionable error", baseURL: "api.example.com/v1", diff --git a/internal/provider/driver_defaults_test.go b/internal/provider/driver_defaults_test.go new file mode 100644 index 00000000..4d4e517e --- /dev/null +++ b/internal/provider/driver_defaults_test.go @@ -0,0 +1,139 @@ +package provider + +import "testing" + +func TestResolveDriverProtocolDefaults(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + driver string + wantChat string + wantDiscovery string + wantAuthStrategy string + wantProfile string + }{ + { + name: "gemini defaults", + driver: DriverGemini, + wantChat: ChatProtocolOpenAIChatCompletions, + wantDiscovery: DiscoveryProtocolGeminiModels, + wantAuthStrategy: AuthStrategyBearer, + wantProfile: DiscoveryResponseProfileGemini, + }, + { + name: "anthropic defaults", + driver: DriverAnthropic, + wantChat: ChatProtocolAnthropicMessages, + wantDiscovery: DiscoveryProtocolAnthropicModels, + wantAuthStrategy: AuthStrategyAnthropic, + wantProfile: DiscoveryResponseProfileGeneric, + }, + { + name: "openaicompat defaults", + driver: DriverOpenAICompat, + wantChat: ChatProtocolOpenAIChatCompletions, + wantDiscovery: DiscoveryProtocolOpenAIModels, + wantAuthStrategy: AuthStrategyBearer, + wantProfile: DiscoveryResponseProfileOpenAI, + }, + { + name: "unknown driver falls back to custom defaults", + driver: " custom ", + wantChat: ChatProtocolOpenAIChatCompletions, + wantDiscovery: DiscoveryProtocolCustomHTTPJSON, + wantAuthStrategy: AuthStrategyBearer, + wantProfile: DiscoveryResponseProfileGeneric, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ResolveDriverProtocolDefaults(tt.driver) + if got.ChatProtocol != tt.wantChat { + t.Fatalf("expected chat protocol %q, got %q", tt.wantChat, got.ChatProtocol) + } + if got.DiscoveryProtocol != tt.wantDiscovery { + t.Fatalf("expected discovery protocol %q, got %q", tt.wantDiscovery, got.DiscoveryProtocol) + } + if got.AuthStrategy != tt.wantAuthStrategy { + t.Fatalf("expected auth strategy %q, got %q", tt.wantAuthStrategy, got.AuthStrategy) + } + if got.ResponseProfile != tt.wantProfile { + t.Fatalf("expected response profile %q, got %q", tt.wantProfile, got.ResponseProfile) + } + }) + } +} + +func TestResolveDriverDiscoveryConfig(t *testing.T) { + t.Parallel() + + t.Run("uses default endpoint when endpoint is empty", func(t *testing.T) { + t.Parallel() + + protocol, endpoint, profile, err := ResolveDriverDiscoveryConfig(DriverGemini, "") + if err != nil { + t.Fatalf("ResolveDriverDiscoveryConfig() error = %v", err) + } + if protocol != DiscoveryProtocolGeminiModels { + t.Fatalf("expected discovery protocol %q, got %q", DiscoveryProtocolGeminiModels, protocol) + } + if endpoint != DiscoveryEndpointPathModels { + t.Fatalf("expected endpoint %q, got %q", DiscoveryEndpointPathModels, endpoint) + } + if profile != DiscoveryResponseProfileGemini { + t.Fatalf("expected response profile %q, got %q", DiscoveryResponseProfileGemini, profile) + } + }) + + t.Run("keeps explicit relative endpoint", func(t *testing.T) { + t.Parallel() + + protocol, endpoint, profile, err := ResolveDriverDiscoveryConfig(DriverOpenAICompat, " custom/models ") + if err != nil { + t.Fatalf("ResolveDriverDiscoveryConfig() error = %v", err) + } + if protocol != DiscoveryProtocolOpenAIModels { + t.Fatalf("expected discovery protocol %q, got %q", DiscoveryProtocolOpenAIModels, protocol) + } + if endpoint != "/custom/models" { + t.Fatalf("expected endpoint %q, got %q", "/custom/models", endpoint) + } + if profile != DiscoveryResponseProfileOpenAI { + t.Fatalf("expected response profile %q, got %q", DiscoveryResponseProfileOpenAI, profile) + } + }) + + t.Run("returns validation error for absolute endpoint", func(t *testing.T) { + t.Parallel() + + _, _, _, err := ResolveDriverDiscoveryConfig(DriverOpenAICompat, "https://api.example.com/models") + if err == nil { + t.Fatal("expected error for absolute endpoint path") + } + }) +} + +func TestResolveDriverAuthConfig(t *testing.T) { + t.Parallel() + + authStrategy, apiVersion := ResolveDriverAuthConfig(DriverAnthropic) + if authStrategy != AuthStrategyAnthropic { + t.Fatalf("expected auth strategy %q, got %q", AuthStrategyAnthropic, authStrategy) + } + if apiVersion != "" { + t.Fatalf("expected empty api version, got %q", apiVersion) + } + + authStrategy, apiVersion = ResolveDriverAuthConfig("unknown") + if authStrategy != AuthStrategyBearer { + t.Fatalf("expected default auth strategy %q, got %q", AuthStrategyBearer, authStrategy) + } + if apiVersion != "" { + t.Fatalf("expected empty api version for default driver, got %q", apiVersion) + } +} diff --git a/internal/provider/types/asset_limits_test.go b/internal/provider/types/asset_limits_test.go index 7f6c4c78..a7132c81 100644 --- a/internal/provider/types/asset_limits_test.go +++ b/internal/provider/types/asset_limits_test.go @@ -2,6 +2,22 @@ package types import "testing" +func TestDefaultSessionAssetLimits(t *testing.T) { + t.Parallel() + + got := DefaultSessionAssetLimits() + if got.MaxSessionAssetBytes != MaxSessionAssetBytes { + t.Fatalf("expected MaxSessionAssetBytes=%d, got %d", MaxSessionAssetBytes, got.MaxSessionAssetBytes) + } + if got.MaxSessionAssetsTotalBytes != MaxSessionAssetsTotalBytes { + t.Fatalf( + "expected MaxSessionAssetsTotalBytes=%d, got %d", + MaxSessionAssetsTotalBytes, + got.MaxSessionAssetsTotalBytes, + ) + } +} + func TestNormalizeSessionAssetLimits(t *testing.T) { t.Parallel() diff --git a/scripts/migrate_provider_yaml_test.go b/scripts/migrate_provider_yaml_test.go index d615f09f..fca0511f 100644 --- a/scripts/migrate_provider_yaml_test.go +++ b/scripts/migrate_provider_yaml_test.go @@ -7,6 +7,18 @@ import ( "testing" ) +func legacyProviderYAML() string { + return strings.TrimSpace(` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_API_KEY +openai_compatible: + base_url: https://llm.example.com/v1 + chat_endpoint_path: /gateway/chat + discovery_endpoint_path: /gateway/models +`) + "\n" +} + func TestMigrateProviderContentFlattensLegacyOpenAICompatibleBlock(t *testing.T) { t.Parallel() @@ -167,3 +179,192 @@ openai_compatible: t.Fatalf("expected migrated file remove legacy block, got:\n%s", string(newContent)) } } + +func TestRunMigrationWithTargetFileDryRun(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, providerConfigFileName) + if err := os.WriteFile(target, []byte(legacyProviderYAML()), 0o644); err != nil { + t.Fatalf("write target file: %v", err) + } + + results, err := runMigration(dir, target, true) + if err != nil { + t.Fatalf("runMigration() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if !results[0].Changed { + t.Fatal("expected dry-run to detect migration change") + } + if results[0].Backup != "" { + t.Fatalf("expected no backup in dry-run, got %q", results[0].Backup) + } + + content, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target file: %v", err) + } + if !strings.Contains(string(content), "openai_compatible:") { + t.Fatalf("expected dry-run not to mutate file, got:\n%s", string(content)) + } +} + +func TestRunMigrationReturnsErrorWhenTargetIsMissing(t *testing.T) { + t.Parallel() + + _, err := runMigration(t.TempDir(), filepath.Join(t.TempDir(), providerConfigFileName), true) + if err == nil { + t.Fatal("expected error for missing target") + } + if !strings.Contains(err.Error(), "stat target") { + t.Fatalf("expected stat target error, got %v", err) + } +} + +func TestRunMigrationReturnsErrorWhenProviderFileIsInvalid(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, providerConfigFileName) + if err := os.WriteFile(target, []byte("name: [invalid"), 0o644); err != nil { + t.Fatalf("write invalid target file: %v", err) + } + + _, err := runMigration(dir, target, false) + if err == nil { + t.Fatal("expected migration error for invalid yaml") + } + if !strings.Contains(err.Error(), "migrate") { + t.Fatalf("expected migrate error wrapper, got %v", err) + } +} + +func TestCollectProviderFilesRejectsNonProviderFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, "not-provider.yaml") + if err := os.WriteFile(target, []byte("name: x\n"), 0o644); err != nil { + t.Fatalf("write target file: %v", err) + } + + _, err := collectProviderFiles(dir, target) + if err == nil { + t.Fatal("expected error for non-provider target file") + } + if !strings.Contains(err.Error(), "target must be provider.yaml or a providers dir") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCollectProviderFilesFromProvidersDirNoProviderYAML(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + providersDir := filepath.Join(dir, "providers") + if err := os.MkdirAll(filepath.Join(providersDir, "only-dir"), 0o755); err != nil { + t.Fatalf("mkdir providers dir: %v", err) + } + + _, err := collectProviderFilesFromProvidersDir(providersDir) + if err == nil { + t.Fatal("expected error when no provider.yaml exists") + } + if !strings.Contains(err.Error(), "no provider.yaml found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMigrateProviderFileDryRunDoesNotWriteBackup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, providerConfigFileName) + if err := os.WriteFile(target, []byte(legacyProviderYAML()), 0o644); err != nil { + t.Fatalf("write target file: %v", err) + } + + result, err := migrateProviderFile(target, true) + if err != nil { + t.Fatalf("migrateProviderFile() error = %v", err) + } + if !result.Changed { + t.Fatal("expected dry-run result changed") + } + if result.Backup != "" { + t.Fatalf("expected no backup in dry-run, got %q", result.Backup) + } + if _, err := os.Stat(target + ".bak"); !os.IsNotExist(err) { + t.Fatalf("expected no backup file created, stat err=%v", err) + } +} + +func TestMigrateProviderFileReturnsReadError(t *testing.T) { + t.Parallel() + + _, err := migrateProviderFile(filepath.Join(t.TempDir(), providerConfigFileName), false) + if err == nil { + t.Fatal("expected read error") + } + if !strings.Contains(err.Error(), "read") { + t.Fatalf("expected read wrapper, got %v", err) + } +} + +func TestSelectLegacyBlock(t *testing.T) { + t.Parallel() + + t.Run("prefers driver matched block", func(t *testing.T) { + t.Parallel() + + cfg := migrationFile{ + Driver: "gemini", + OpenAICompatible: legacyProviderProtocol{ + BaseURL: "https://openai.example.com", + }, + Gemini: legacyProviderProtocol{ + BaseURL: "https://gemini.example.com", + }, + } + + got := selectLegacyBlock(cfg) + if got.BaseURL != "https://gemini.example.com" { + t.Fatalf("expected gemini block, got %#v", got) + } + }) + + t.Run("falls back to first non-empty block", func(t *testing.T) { + t.Parallel() + + cfg := migrationFile{ + Driver: "unknown-driver", + OpenAICompatible: legacyProviderProtocol{ + BaseURL: "https://fallback.example.com", + }, + } + + got := selectLegacyBlock(cfg) + if got.BaseURL != "https://fallback.example.com" { + t.Fatalf("expected fallback block, got %#v", got) + } + }) +} + +func TestDefaultBaseDir(t *testing.T) { + home, err := os.UserHomeDir() + if err == nil && strings.TrimSpace(home) != "" { + got := defaultBaseDir() + if got != filepath.Join(home, defaultNeoCodeDirName) { + t.Fatalf("expected %q, got %q", filepath.Join(home, defaultNeoCodeDirName), got) + } + return + } + + t.Setenv("HOME", "") + if got := defaultBaseDir(); got != filepath.Join("~", defaultNeoCodeDirName) { + t.Fatalf("expected fallback path %q, got %q", filepath.Join("~", defaultNeoCodeDirName), got) + } +}