Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ go run ./cmd/neocode
- **`internal/config`** — 配置管理、环境变量、YAML 加载
- **`internal/context`** — system prompt、消息裁剪与上下文构建
- **`internal/provider`** — Provider 契约、驱动注册与通用领域类型
- **`internal/provider/openaicompat`** — OpenAI-compatible 协议入口、discovery 与 `api_style` 分流
- **`internal/provider/openaicompat/chatcompletions`** — `/chat/completions` 请求组装、SSE 解析与 tool-call 增量处理
- **`internal/provider/catalog`** — 模型发现、catalog 缓存与后台刷新
- **`internal/provider/selection`** — provider/model 选择与配置同步
- **`internal/provider/builtin`** — 内建 driver 注册
- **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型)
- **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`)
Expand All @@ -115,7 +116,7 @@ go run ./cmd/neocode
│ ├── provider # Provider 契约与驱动注册
│ │ ├── builtin # 内建 driver 注册
│ │ ├── catalog # 模型发现与缓存
│ │ └── selection # provider/model 选择服务
│ │ └── openaicompat # OpenAI-compatible 协议入口与子协议实现
│ ├── runtime # ReAct 循环与事件流
│ ├── session # 会话模型与持久化
│ ├── tools # 工具系统
Expand Down
2 changes: 1 addition & 1 deletion internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ func TestBuildRuntimeRejectsInvalidConfigFile(t *testing.T) {
}

_, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err == nil || !strings.Contains(err.Error(), "no longer supported") {
if err == nil || !strings.Contains(err.Error(), "workdir not found") {
t.Fatalf("expected legacy config error, got %v", err)
}
}
Expand Down
22 changes: 10 additions & 12 deletions internal/config/builtin_providers.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package config

import "neo-code/internal/provider"

const (
OpenAIName = "openai"
OpenAIDefaultBaseURL = "https://api.openai.com/v1"
Expand All @@ -20,21 +22,17 @@ const (
QiniuDefaultBaseURL = "https://api.qnaigc.com/v1"
QiniuDefaultModel = "openai/gpt-5"
QiniuDefaultAPIKeyEnv = "QINIU_API_KEY"

defaultOpenAICompatibleAPIStyle = "chat_completions"
)

const OpenaiCompatDriver = "openaicompat"

// OpenAIProvider returns the builtin OpenAI provider definition.
func OpenAIProvider() ProviderConfig {
return ProviderConfig{
Name: OpenAIName,
Driver: OpenaiCompatDriver,
Driver: provider.DriverOpenAICompat,
BaseURL: OpenAIDefaultBaseURL,
Model: OpenAIDefaultModel,
APIKeyEnv: OpenAIDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions,
Source: ProviderSourceBuiltin,
}
}
Expand All @@ -43,11 +41,11 @@ func OpenAIProvider() ProviderConfig {
func GeminiProvider() ProviderConfig {
return ProviderConfig{
Name: GeminiName,
Driver: OpenaiCompatDriver,
Driver: provider.DriverOpenAICompat,
BaseURL: GeminiDefaultBaseURL,
Model: GeminiDefaultModel,
APIKeyEnv: GeminiDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions,
Source: ProviderSourceBuiltin,
}
}
Expand All @@ -56,11 +54,11 @@ func GeminiProvider() ProviderConfig {
func OpenLLProvider() ProviderConfig {
return ProviderConfig{
Name: OpenLLName,
Driver: OpenaiCompatDriver,
Driver: provider.DriverOpenAICompat,
BaseURL: OpenLLDefaultBaseURL,
Model: OpenLLDefaultModel,
APIKeyEnv: OpenLLDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions,
Source: ProviderSourceBuiltin,
}
}
Expand All @@ -69,11 +67,11 @@ func OpenLLProvider() ProviderConfig {
func QiniuProvider() ProviderConfig {
return ProviderConfig{
Name: QiniuName,
Driver: OpenaiCompatDriver,
Driver: provider.DriverOpenAICompat,
BaseURL: QiniuDefaultBaseURL,
Model: QiniuDefaultModel,
APIKeyEnv: QiniuDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions,
Source: ProviderSourceBuiltin,
}
}
Expand Down
101 changes: 30 additions & 71 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"sync"
"testing"

providerpkg "neo-code/internal/provider"
)

const (
Expand All @@ -19,11 +21,11 @@ const (
func testDefaultProviderConfig() ProviderConfig {
return ProviderConfig{
Name: testProviderName,
Driver: "openaicompat",
Driver: providerpkg.DriverOpenAICompat,
BaseURL: testBaseURL,
Model: testModel,
APIKeyEnv: testAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
APIStyle: providerpkg.OpenAICompatibleAPIStyleChatCompletions,
Source: ProviderSourceBuiltin,
}
}
Expand All @@ -47,24 +49,17 @@ func TestParseConfigFormats(t *testing.T) {
assert func(t *testing.T, cfg *Config)
}{
{
name: "current format ignores persisted provider metadata",
name: "current format parses runtime settings only",
data: `
selected_provider: openai
current_model: gpt-5.4
shell: powershell

provider_overrides:
tools:
webfetch:
max_response_bytes: 4096
supported_content_types:
- text/html
- text/plain
providers:
- name: openai
base_url: https://example.com/v1
model: gpt-5.4
api_key_env: OPENAI_API_KEY
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
Expand Down Expand Up @@ -97,7 +92,7 @@ current_model: gpt-4.1
default_workdir: ./from-default
shell: powershell
`,
err: "legacy config key \"default_workdir\" is no longer supported",
err: "field default_workdir not found",
},
{
name: "legacy workdir key is rejected",
Expand All @@ -107,10 +102,10 @@ current_model: gpt-4.1
workdir: ./from-legacy
shell: powershell
`,
err: "legacy config key \"workdir\" is no longer supported",
err: "field workdir not found",
},
{
name: "legacy persisted providers list keeps selection only",
name: "legacy persisted providers list is rejected",
data: `
selected_provider: openai
current_model: gpt-5.4
Expand All @@ -122,25 +117,10 @@ providers:
model: gpt-5.4
api_key_env: OPENAI_API_KEY
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
provider, err := cfg.SelectedProviderConfig()
if err != nil {
t.Fatalf("selected provider: %v", err)
}
if provider.BaseURL != testBaseURL {
t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL)
}
if provider.Model != testModel {
t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model)
}
if cfg.CurrentModel != "gpt-5.4" {
t.Fatalf("expected selected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel)
}
},
err: `field providers not found`,
},
{
name: "legacy fields are ignored",
name: "legacy unknown fields are rejected",
data: `
selected_provider: openai
current_model: gpt-4o
Expand All @@ -155,31 +135,7 @@ providers:
models:
- gpt-4o
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.MaxLoops != DefaultMaxLoops {
t.Fatalf("expected legacy max_loop to be ignored, got %d", cfg.MaxLoops)
}
provider, err := cfg.SelectedProviderConfig()
if err != nil {
t.Fatalf("selected provider: %v", err)
}
if provider.Model != testModel {
t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model)
}
if cfg.CurrentModel != "gpt-4o" {
t.Fatalf("expected current model %q, got %q", "gpt-4o", cfg.CurrentModel)
}
if strings.Contains(filepath.ToSlash(cfg.Workdir), "definitely-legacy-root") {
t.Fatalf("expected legacy workspace_root to be ignored, got %q", cfg.Workdir)
}
if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes {
t.Fatalf("expected default max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes)
}
if len(cfg.Tools.WebFetch.SupportedContentTypes) != len(DefaultWebFetchSupportedContentTypes()) {
t.Fatalf("expected default supported content types, got %+v", cfg.Tools.WebFetch.SupportedContentTypes)
}
},
err: `field workspace_root not found`,
},
}

Expand Down Expand Up @@ -603,23 +559,11 @@ func TestProviderConfigValidateFailures(t *testing.T) {
},
expectErr: "driver is empty",
},
{
name: "legacy openai driver rejected",
provider: ProviderConfig{
Name: "custom-openai",
Driver: "openai",
BaseURL: "https://example.com/v1",
Model: "gpt-4.1",
APIKeyEnv: "CUSTOM_API_KEY",
Source: ProviderSourceCustom,
},
expectErr: "no longer supported",
},
{
name: "custom provider must not define model",
provider: ProviderConfig{
Name: "custom-openai",
Driver: "openaicompat",
Driver: providerpkg.DriverOpenAICompat,
BaseURL: "https://example.com/v1",
Model: "gpt-4.1",
APIKeyEnv: "CUSTOM_API_KEY",
Expand All @@ -631,15 +575,15 @@ func TestProviderConfigValidateFailures(t *testing.T) {
name: "missing base url",
provider: ProviderConfig{
Name: testProviderName,
Driver: "openaicompat",
Driver: providerpkg.DriverOpenAICompat,
},
expectErr: "base_url is empty",
},
{
name: "missing model",
provider: ProviderConfig{
Name: testProviderName,
Driver: "openaicompat",
Driver: providerpkg.DriverOpenAICompat,
BaseURL: testBaseURL,
},
expectErr: "model is empty",
Expand All @@ -648,7 +592,7 @@ func TestProviderConfigValidateFailures(t *testing.T) {
name: "missing api key env",
provider: ProviderConfig{
Name: testProviderName,
Driver: "openaicompat",
Driver: providerpkg.DriverOpenAICompat,
BaseURL: testBaseURL,
Model: testModel,
},
Expand All @@ -668,6 +612,21 @@ func TestProviderConfigValidateFailures(t *testing.T) {
}
}

func TestProviderConfigValidateAllowsStructurallyValidCustomDriver(t *testing.T) {
t.Parallel()

err := (ProviderConfig{
Name: "custom-openai",
Driver: "custom-driver",
BaseURL: "https://example.com/v1",
APIKeyEnv: "CUSTOM_API_KEY",
Source: ProviderSourceCustom,
}).Validate()
if err != nil {
t.Fatalf("expected custom driver to pass structural validation, got %v", err)
}
}

func TestProviderLookupAndResolveSelectedProvider(t *testing.T) {
t.Setenv(testAPIKeyEnv, "lookup-key")

Expand Down
27 changes: 10 additions & 17 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,13 @@ type Loader struct {
}

type persistedConfig struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping workdir from persistedConfig removes the only YAML-backed way to configure the default workspace, but Config.Workdir is still validated and then fed into bootstrap, session storage, and tool sandbox setup. After this change a user can no longer persist a non-default workdir in ~/.neocode/config.yaml: old files fail to load, and newly saved files silently omit the setting. That is a behavior regression, not just a cleanup.

SelectedProvider string `yaml:"selected_provider"`
CurrentModel string `yaml:"current_model"`
LegacyDefaultWorkdir *string `yaml:"default_workdir,omitempty"`
LegacyWorkdir *string `yaml:"workdir,omitempty"`
Shell string `yaml:"shell"`
MaxLoops int `yaml:"max_loops,omitempty"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
Context persistedContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
SelectedProvider string `yaml:"selected_provider"`
CurrentModel string `yaml:"current_model"`
Shell string `yaml:"shell"`
MaxLoops int `yaml:"max_loops,omitempty"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
Context persistedContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
}

type persistedContextConfig struct {
Expand Down Expand Up @@ -182,16 +180,11 @@ func parseConfigWithContextDefaults(data []byte, contextDefaults ContextConfig)

func parseCurrentConfig(data []byte, contextDefaults ContextConfig) (*Config, error) {
var file persistedConfig
if err := yaml.Unmarshal(data, &file); err != nil {
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the top-level config loader from the previous "read old keys, then rewrite to the canonical format" behavior into a hard startup failure for any existing config.yaml that still contains legacy keys like workdir, default_workdir, or providers. The updated tests explicitly codify that regression, so users upgrading from the prior build will now have to hand-edit their config before NeoCode can start. If the breaking change is intentional, it needs a migration path or at least a compatibility shim here rather than failing on load.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KnownFields(true) is now applied after the legacy keys were removed from the persisted schema, so existing installs with providers, provider_overrides, workdir, or default_workdir now hard-fail during startup instead of being rewritten into the current minimal format. The previous loader explicitly preserved that upgrade path, and the docs still say those old keys are cleaned up automatically, so this is a breaking migration regression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KnownFields(true) 把主配置解析从“忽略/重写旧字段”改成了“遇到未知字段直接启动失败”。这会让已经存在的 ~/.neocode/config.yaml 里只要还保留 workdirdefault_workdirprovidersworkspace_root 等历史字段,就在升级后无法启动,而这次 PR 的目标只是 provider 目录重构。这里需要保留迁移/重写路径,或者显式做 config version bump,否则是一次无预警的兼容性回归。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching the root config parser to KnownFields(true) turns previously migratable configs into hard startup failures. On main, providers / provider_overrides were accepted and rewritten away during Load(), and the docs still describe that upgrade path. With this change, users carrying an older config.yaml can no longer boot to let the loader normalize it. Please preserve the legacy-key migration path (or rewrite before strict validation) instead of rejecting those files outright.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching the root config parser to KnownFields(true) removes the existing upgrade path for legacy config.yaml keys. On main, providers / provider_overrides were tolerated and rewritten away during Load(), and the docs still describe that cleanup behavior. After this change, older configs become hard startup failures instead of being normalized.

if err := decoder.Decode(&file); err != nil {
return nil, err
}
if file.LegacyDefaultWorkdir != nil {
return nil, fmt.Errorf("legacy config key %q is no longer supported", "default_workdir")
}
if file.LegacyWorkdir != nil {
return nil, fmt.Errorf("legacy config key %q is no longer supported", "workdir")
}

cfg := &Config{
SelectedProvider: strings.TrimSpace(file.SelectedProvider),
CurrentModel: strings.TrimSpace(file.CurrentModel),
Expand Down
Loading
Loading