From 55491d3f2ea4397f9d7c2d98ca1585b552a19457 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 8 Apr 2026 21:04:21 +0800 Subject: [PATCH 01/13] =?UTF-8?q?pref(provider):=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E5=A5=91=E7=BA=A6=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/session-persistence-design.md | 44 ----------- internal/config/config_test.go | 16 ++-- internal/config/model.go | 2 +- internal/provider/capabilities.go | 43 ++++++++++ internal/provider/catalog/service_test.go | 2 +- internal/provider/openai/driver.go | 6 ++ internal/provider/openai/openai_test.go | 36 ++++----- internal/provider/openai/provider.go | 2 +- internal/provider/openai/request.go | 4 +- internal/provider/openai/response.go | 12 --- internal/provider/provider.go | 4 +- internal/provider/registry.go | 16 +++- internal/provider/registry_test.go | 23 +++++- internal/provider/types/request.go | 4 +- internal/provider/types/types_test.go | 4 +- internal/runtime/compact_generator.go | 5 +- internal/runtime/compact_generator_test.go | 39 +++++++++ internal/runtime/events.go | 6 +- internal/runtime/runtime.go | 36 +++++++-- internal/runtime/runtime_test.go | 92 ++++++++++++++++------ 20 files changed, 268 insertions(+), 128 deletions(-) delete mode 100644 docs/session-persistence-design.md create mode 100644 internal/provider/capabilities.go diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md deleted file mode 100644 index fb22d26d..00000000 --- a/docs/session-persistence-design.md +++ /dev/null @@ -1,44 +0,0 @@ -# Session 持久化设计 - -## 模块职责与收口边界 -- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层 -- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节 -- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化 - -## 存储策略 -NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 - -## 数据模型 -- `Session`:完整消息历史以及 `id`、`title`、`updated_at`、`token_input_total`、`token_output_total` 等元信息 -- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`) - -### Token 持久化 -- `token_input_total` 和 `token_output_total` 分别记录会话累计输入和输出 token。 -- 使用 `omitempty` 标签,确保旧版 JSON 文件正常加载(零值不序列化)。 -- runtime 在每次 provider 调用后更新 session 的 token 字段,随 session save 一起持久化。 -- 会话加载时,runtime 从 session 恢复 token 计数器;新建会话时计数器清零。 -- 自动压缩成功后 token 计数器重置为零,并持久化到 session。 - -## 加载策略 -- `ListSummaries` 只读取渲染侧边栏所需的基础信息 -- `Load` 仅在用户真正进入某个会话时读取完整消息历史 -- `Save` 通过临时文件原子写入完整 Session - -## 命名策略 -- 新会话默认展示为 `New Session` -- 一旦持久化,runtime 会根据首轮用户消息生成简短标题 - -## 并发约束 -- `internal/session` 中的 Store 实现必须自行保护共享访问 -- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 - -## 兼容性与演进说明 -- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` -- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 - -## 工作区隔离 - -- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` -- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` -- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 -- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d2d37542..20dc6c24 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1231,10 +1231,10 @@ func TestDescriptorFromRawModel(t *testing.T) { t.Parallel() tests := []struct { - name string - raw map[string]any - want ModelDescriptor - wantOK bool + name string + raw map[string]any + want ModelDescriptor + wantOK bool }{ { name: "empty map returns false", @@ -1252,10 +1252,10 @@ func TestDescriptorFromRawModel(t *testing.T) { { name: "full descriptor", raw: map[string]any{ - "id": "gpt-4.1", - "display_name": "GPT-4.1", - "description": "desc", - "context_window": 128000, + "id": "gpt-4.1", + "display_name": "GPT-4.1", + "description": "desc", + "context_window": 128000, "max_output_tokens": 16384, }, want: ModelDescriptor{ diff --git a/internal/config/model.go b/internal/config/model.go index 9b8d80f1..4281b4b1 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -17,7 +17,7 @@ const ( DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 DefaultCompactManualKeepRecentMessages = 10 DefaultCompactMaxSummaryChars = 1200 - DefaultAutoCompactInputTokenThreshold = 100000 + DefaultAutoCompactInputTokenThreshold = 100000 ) const ( diff --git a/internal/provider/capabilities.go b/internal/provider/capabilities.go new file mode 100644 index 00000000..2218d227 --- /dev/null +++ b/internal/provider/capabilities.go @@ -0,0 +1,43 @@ +package provider + +// CapabilitySupport 表示模型能力的确认状态。 +type CapabilitySupport string + +const ( + CapabilitySupportUnknown CapabilitySupport = "unknown" + CapabilitySupportUnsupported CapabilitySupport = "unsupported" + CapabilitySupportSupported CapabilitySupport = "supported" +) + +// ReasoningMode 表示模型推理能力的形态。 +type ReasoningMode string + +const ( + ReasoningModeUnknown ReasoningMode = "unknown" + ReasoningModeNone ReasoningMode = "none" + ReasoningModeNative ReasoningMode = "native" + ReasoningModeConfigurable ReasoningMode = "configurable" +) + +// DriverCapabilities 描述 driver 层本身是否能传输某类能力。 +type DriverCapabilities struct { + Streaming bool + ToolTransport bool + ModelDiscovery bool + ImageInputTransport bool +} + +// ModelCapabilities 描述具体模型的能力声明。 +type ModelCapabilities struct { + ToolCalling CapabilitySupport + ImageInput CapabilitySupport + ReasoningMode ReasoningMode + MaxContextTokens int + MaxOutputTokens int +} + +// ResolvedCapabilities 是 driver 能力与模型能力的合并视图。 +type ResolvedCapabilities struct { + Driver DriverCapabilities + Model ModelCapabilities +} diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index da3218dc..476a1f59 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -235,7 +235,7 @@ func containsModelDescriptorID(models []config.ModelDescriptor, modelID string) type catalogTestProvider struct{} -func (catalogTestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { +func (catalogTestProvider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { return nil } diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go index fbde6820..5b41a4d9 100644 --- a/internal/provider/openai/driver.go +++ b/internal/provider/openai/driver.go @@ -31,5 +31,11 @@ func Driver() provider.DriverDefinition { } return p.DiscoverModels(ctx) }, + Capabilities: provider.DriverCapabilities{ + Streaming: true, + ToolTransport: true, + ModelDiscovery: true, + ImageInputTransport: false, + }, } } diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index d16edc56..a55c370f 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -287,7 +287,7 @@ func TestBuildRequest_EmptyModelReturnsError(t *testing.T) { client: &http.Client{}, } - _, buildErr := p.buildRequest(providertypes.ChatRequest{}) + _, buildErr := p.buildRequest(providertypes.GenerateRequest{}) if buildErr == nil { t.Fatal("expected error for empty model") } @@ -304,7 +304,7 @@ func TestBuildRequest_FallsBackToConfigModel(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -321,7 +321,7 @@ func TestBuildRequest_RequestModelTakesPrecedence(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := p.buildRequest(providertypes.GenerateRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -338,7 +338,7 @@ func TestBuildRequest_NoSystemPrompt(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := p.buildRequest(providertypes.GenerateRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -357,7 +357,7 @@ func TestBuildRequest_NoTools(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) + payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -374,7 +374,7 @@ func TestBuildRequest_EmptyToolsSlice(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) + payload, err := p.buildRequest(providertypes.GenerateRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -391,7 +391,7 @@ func TestBuildRequest_MultipleTools(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{ + payload, err := p.buildRequest(providertypes.GenerateRequest{ Messages: []providertypes.Message{{Role: "user", Content: "use tools"}}, Tools: []providertypes.ToolSpec{ {Name: "tool_a", Description: "Tool A", Schema: map[string]any{"type": "object"}}, @@ -417,7 +417,7 @@ func TestBuildRequest_WhitespaceSystemPromptSkipped(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + payload, err := p.buildRequest(providertypes.GenerateRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("buildRequest() error = %v", err) } @@ -821,12 +821,12 @@ data: [DONE] p.client = server.Client() events := make(chan providertypes.StreamEvent, 4) - err = p.Chat(context.Background(), providertypes.ChatRequest{ + err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, }, events) if err != nil { - t.Fatalf("Chat() error = %v", err) + t.Fatalf("Generate() error = %v", err) } } @@ -908,7 +908,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { p.client = server.Client() events := make(chan providertypes.StreamEvent, 8) - err = p.Chat(context.Background(), providertypes.ChatRequest{ + err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: "gpt-5.4", Messages: []providertypes.Message{ {Role: "user", Content: "please edit the file"}, @@ -918,7 +918,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit one matching block in a file", Schema: map[string]any{"type": "object"}}}, }, events) if err != nil { - t.Fatalf("Chat() error = %v", err) + t.Fatalf("Generate() error = %v", err) } streamEvents := drainStreamEvents(events) @@ -1003,7 +1003,7 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { } p.client = server.Client() - err = p.Chat(context.Background(), providertypes.ChatRequest{Model: config.OpenAIDefaultModel}, make(chan providertypes.StreamEvent, 1)) + err = p.Generate(context.Background(), providertypes.GenerateRequest{Model: config.OpenAIDefaultModel}, make(chan providertypes.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) } @@ -1019,7 +1019,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("New() error = %v", err) } - payload, err := p.buildRequest(providertypes.ChatRequest{ + payload, err := p.buildRequest(providertypes.GenerateRequest{ SystemPrompt: "system prompt", Messages: []providertypes.Message{ {Role: "user", Content: "hello"}, @@ -1368,12 +1368,12 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { p.client = server.Client() events := make(chan providertypes.StreamEvent, 8) - err = p.Chat(context.Background(), providertypes.ChatRequest{ + err = p.Generate(context.Background(), providertypes.GenerateRequest{ Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "edit"}}, Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}}, }, events) if err != nil { - t.Fatalf("Chat() error = %v", err) + t.Fatalf("Generate() error = %v", err) } var foundToolCallStart bool @@ -1413,9 +1413,9 @@ func TestProviderChatEmitsFullEventStream(t *testing.T) { p.client = server.Client() events := make(chan providertypes.StreamEvent, 16) - err = p.Chat(context.Background(), providertypes.ChatRequest{Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "test"}}}, events) + err = p.Generate(context.Background(), providertypes.GenerateRequest{Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "test"}}}, events) if err != nil { - t.Fatalf("Chat() error = %v", err) + t.Fatalf("Generate() error = %v", err) } var foundTextDelta, foundToolCallStart, foundToolCallDelta, foundMessageDone bool diff --git a/internal/provider/openai/provider.go b/internal/provider/openai/provider.go index f6db0d37..acf48233 100644 --- a/internal/provider/openai/provider.go +++ b/internal/provider/openai/provider.go @@ -82,7 +82,7 @@ func (p *Provider) DiscoverModels(ctx context.Context) ([]config.ModelDescriptor // Chat 发起 SSE 流式对话请求。 // 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 -func (p *Provider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { +func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { payload, err := p.buildRequest(req) if err != nil { return err diff --git a/internal/provider/openai/request.go b/internal/provider/openai/request.go index 02f5398b..9a9b8d2b 100644 --- a/internal/provider/openai/request.go +++ b/internal/provider/openai/request.go @@ -12,9 +12,9 @@ import ( providertypes "neo-code/internal/provider/types" ) -// buildRequest 将 provider.ChatRequest 转换为 OpenAI API 请求结构。 +// buildRequest 将 provider.GenerateRequest 转换为 OpenAI API 请求结构。 // 模型优先取 req.Model,其次使用配置中的默认模型。 -func (p *Provider) buildRequest(req providertypes.ChatRequest) (chatCompletionRequest, error) { +func (p *Provider) buildRequest(req providertypes.GenerateRequest) (chatCompletionRequest, error) { model := strings.TrimSpace(req.Model) if model == "" { model = strings.TrimSpace(p.cfg.Model) diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 5026a15d..02b7b9b5 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "strings" "neo-code/internal/provider" @@ -68,7 +67,6 @@ func (p *Provider) consumeStream( // finishStream 统一的流结束处理:发送 message_done 事件。 finishStream := func() error { - log.Printf("[DEBUG-STREAM] finishStream called: finishReason=%q, done=%v", finishReason, done) return emitMessageDone(ctx, events, finishReason, &usage) } @@ -116,16 +114,6 @@ func (p *Provider) consumeStream( } if errors.Is(err, io.EOF) { - // [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因 - log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d", - done, finishReason, reader.totalRead, len(toolCalls)) - if !done { - log.Printf("[DEBUG-STREAM] WARNING: stream ended WITHOUT [DONE] marker — treating as interruption") - if flushErr := flushPendingData(); flushErr != nil { - return flushErr - } - return fmt.Errorf("%w: missing [DONE] marker before EOF", provider.ErrStreamInterrupted) - } if flushErr := flushPendingData(); flushErr != nil { return flushErr } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f8aff036..0d84bc00 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -6,7 +6,7 @@ import ( "neo-code/internal/provider/types" ) -// Provider 定义模型对话能力,通过 channel 推送流式事件给上层消费。 +// Provider 定义模型生成能力,通过 channel 推送流式事件给上层消费。 type Provider interface { - Chat(ctx context.Context, req types.ChatRequest, events chan<- types.StreamEvent) error + Generate(ctx context.Context, req types.GenerateRequest, events chan<- types.StreamEvent) error } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 8a2d4e36..8a33edb4 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -13,9 +13,10 @@ type Builder func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provi type DiscoveryFunc func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) type DriverDefinition struct { - Name string - Build Builder - Discover DiscoveryFunc + Name string + Build Builder + Discover DiscoveryFunc + Capabilities DriverCapabilities } type Registry struct { @@ -72,6 +73,15 @@ func (r *Registry) Supports(driverType string) bool { return err == nil } +// DriverCapabilities 返回指定 driver 的能力声明,不存在时返回零值。 +func (r *Registry) DriverCapabilities(driverType string) DriverCapabilities { + driver, err := r.driver(driverType) + if err != nil { + return DriverCapabilities{} + } + return driver.Capabilities +} + func (r *Registry) driver(driverType string) (DriverDefinition, error) { if r == nil { return DriverDefinition{}, ErrDriverNotFound diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 5ddc5b31..f8f19bf6 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -13,7 +13,7 @@ import ( type stubProvider struct{} -func (stubProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { +func (stubProvider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { return nil } @@ -187,6 +187,27 @@ func TestRegistrySupports(t *testing.T) { } } +func TestRegistryDriverCapabilities(t *testing.T) { + t.Parallel() + + registry := newTestRegistry(t) + got := registry.DriverCapabilities("OPENAI") + if !got.Streaming { + t.Fatalf("expected openai driver to support streaming, got %+v", got) + } + if !got.ToolTransport { + t.Fatalf("expected openai driver to support tool transport, got %+v", got) + } + if !got.ModelDiscovery { + t.Fatalf("expected openai driver to support model discovery, got %+v", got) + } + + missing := registry.DriverCapabilities("missing") + if missing != (provider.DriverCapabilities{}) { + t.Fatalf("expected zero-value capabilities for missing driver, got %+v", missing) + } +} + func TestRegistryRegisterErrors(t *testing.T) { t.Parallel() diff --git a/internal/provider/types/request.go b/internal/provider/types/request.go index 1171f6b0..79ae4ba5 100644 --- a/internal/provider/types/request.go +++ b/internal/provider/types/request.go @@ -1,7 +1,7 @@ package types -// ChatRequest 是 provider.Chat() 的请求参数。 -type ChatRequest struct { +// GenerateRequest 是 provider.Generate() 的请求参数。 +type GenerateRequest struct { Model string `json:"model"` SystemPrompt string `json:"system_prompt"` Messages []Message `json:"messages"` diff --git a/internal/provider/types/types_test.go b/internal/provider/types/types_test.go index 958ebb16..e9a07c15 100644 --- a/internal/provider/types/types_test.go +++ b/internal/provider/types/types_test.go @@ -195,10 +195,10 @@ func TestToolSpecStructFields(t *testing.T) { } } -func TestChatRequestStructFields(t *testing.T) { +func TestGenerateRequestStructFields(t *testing.T) { t.Parallel() - req := ChatRequest{ + req := GenerateRequest{ Model: "gpt-4", SystemPrompt: "you are helpful", Messages: []Message{{Role: RoleUser}}, diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index ed6ce60e..beb346bb 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -41,6 +41,9 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom strings.TrimSpace(g.providerConfig.APIKey) == "" { return "", errors.New("runtime: compact summary generator provider config is incomplete") } + if err := ensureProviderDriverCapabilities(g.providerFactory, g.providerConfig, true, false); err != nil { + return "", err + } prompt := agentcontext.BuildCompactPrompt(agentcontext.CompactPromptInput{ Mode: string(input.Mode), @@ -84,7 +87,7 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom } }() - err = modelProvider.Chat(ctx, providertypes.ChatRequest{ + err = modelProvider.Generate(ctx, providertypes.GenerateRequest{ Model: g.model, SystemPrompt: prompt.SystemPrompt, Messages: []providertypes.Message{{ diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 00a96ec9..bb63190e 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/config" contextcompact "neo-code/internal/context/compact" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" ) @@ -198,3 +199,41 @@ func TestCompactSummaryGeneratorMalformedStreamEventDoesNotDeadlock(t *testing.T t.Fatal("expected compact generation to fail instead of deadlocking on malformed stream event") } } + +func TestCompactSummaryGeneratorRejectsDriverWithoutStreaming(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + resolvedProvider, err := resolvedProviderForTests(manager.Get(), config.OpenAIName) + if err != nil { + t.Fatalf("resolve provider: %v", err) + } + + scripted := &scriptedProvider{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("should not run")}, + }, + } + factory := &scriptedProviderFactory{ + provider: scripted, + capabilities: provider.DriverCapabilities{ + Streaming: false, + ToolTransport: true, + }, + } + generator := newCompactSummaryGenerator(factory, resolvedProvider, "session-model") + + _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ + Mode: contextcompact.ModeManual, + Config: manager.Get().Context.Compact, + }) + if err == nil || !strings.Contains(err.Error(), "does not support streaming") { + t.Fatalf("expected streaming capability rejection, got %v", err) + } + if factory.calls != 0 { + t.Fatalf("expected provider build to be skipped, got %d", factory.calls) + } + if scripted.callCount != 0 { + t.Fatalf("expected provider Generate() to be skipped, got %d", scripted.callCount) + } +} diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 80117aec..97eadc6a 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -85,8 +85,8 @@ const ( // TokenUsagePayload carries token usage statistics for a single provider turn. type TokenUsagePayload struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` SessionInputTokens int `json:"session_input_tokens"` SessionOutputTokens int `json:"session_output_tokens"` -} \ No newline at end of file +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 2e13466d..24fedd75 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -23,7 +23,7 @@ import ( ) const ( - // defaultProviderRetryMax 是 runtime 层对单次 provider.Chat() 的最大重试次数(不含首次调用)。 + // defaultProviderRetryMax 是 runtime 层对单次 provider.Generate() 的最大重试次数(不含首次调用)。 // 与 RetryTransport 的 HTTP 层重试互补:Transport 耗尽后 runtime 仍可重试整个 Chat 调用。 defaultProviderRetryMax = 2 // providerRetryBaseWait 是 runtime 层重试的初始等待时间。 @@ -130,6 +130,7 @@ type UserInput struct { type ProviderFactory interface { Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) + DriverCapabilities(driverType string) provider.DriverCapabilities } type Service struct { @@ -267,7 +268,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, session.ID, err) } - acc, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, providertypes.ChatRequest{ + acc, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, providertypes.GenerateRequest{ Model: cfg.CurrentModel, SystemPrompt: builtContext.SystemPrompt, Messages: builtContext.Messages, @@ -666,7 +667,29 @@ func isRetryableProviderError(err error) bool { return pErr.Retryable } -// callProviderWithRetry 在可重试的 ProviderError 上自动重试 provider.Chat() 调用。 +// ensureProviderDriverCapabilities 校验当前 driver 是否满足指定运行场景的基础能力要求。 +func ensureProviderDriverCapabilities( + factory ProviderFactory, + cfg config.ResolvedProviderConfig, + requireStreaming bool, + requireToolTransport bool, +) error { + if factory == nil { + return errors.New("runtime: provider factory is nil") + } + + driverType := strings.TrimSpace(cfg.Driver) + caps := factory.DriverCapabilities(driverType) + if requireStreaming && !caps.Streaming { + return fmt.Errorf("runtime: provider driver %q does not support streaming", driverType) + } + if requireToolTransport && !caps.ToolTransport { + return fmt.Errorf("runtime: provider driver %q does not support tool transport", driverType) + } + return nil +} + +// callProviderWithRetry 在可重试的 ProviderError 上自动重试 provider.Generate() 调用。 // 每次重试都会重新创建 provider 实例、流式事件转发管道和累积器。 // 非可重试错误、context 取消、重试耗尽时直接返回错误。 // 返回值 acc 保存了本轮流式事件的完整累积状态,供调用方构建 assistant Message 使用。 @@ -674,7 +697,7 @@ func (s *Service) callProviderWithRetry( ctx context.Context, runID string, sessionID string, - req providertypes.ChatRequest, + req providertypes.GenerateRequest, ) (*streamAccumulator, error) { acc := newStreamAccumulator() var lastErr error @@ -699,6 +722,9 @@ func (s *Service) callProviderWithRetry( if err != nil { return nil, err } + if err := ensureProviderDriverCapabilities(s.providerFactory, resolvedProvider, true, true); err != nil { + return nil, err + } modelProvider, err := s.providerFactory.Build(ctx, resolvedProvider) if err != nil { @@ -709,7 +735,7 @@ func (s *Service) callProviderWithRetry( streamDone := make(chan error, 1) go s.forwardProviderEvents(ctx, runID, sessionID, streamEvents, streamDone, acc) - err = modelProvider.Chat(ctx, req, streamEvents) + err = modelProvider.Generate(ctx, req, streamEvents) close(streamEvents) forwardErr := <-streamDone if forwardErr != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index ac40bf8a..1bad5069 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -94,9 +94,9 @@ type scriptedProvider struct { name string streams [][]providertypes.StreamEvent responses []scriptedResponse - requests []providertypes.ChatRequest + requests []providertypes.GenerateRequest callCount int - chatFn func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error + chatFn func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error } type scriptedResponse struct { @@ -104,8 +104,8 @@ type scriptedResponse struct { FinishReason string } -func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { - p.requests = append(p.requests, cloneChatRequest(req)) +func (p *scriptedProvider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + p.requests = append(p.requests, cloneGenerateRequest(req)) callIndex := p.callCount p.callCount++ @@ -172,10 +172,11 @@ func streamContainsMessageDone(events []providertypes.StreamEvent) bool { } type scriptedProviderFactory struct { - provider provider.Provider - calls int - configs []config.ResolvedProviderConfig - err error + provider provider.Provider + calls int + configs []config.ResolvedProviderConfig + err error + capabilities provider.DriverCapabilities } func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { @@ -187,6 +188,16 @@ func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.Resolved return f.provider, nil } +func (f *scriptedProviderFactory) DriverCapabilities(driverType string) provider.DriverCapabilities { + if f.capabilities == (provider.DriverCapabilities{}) { + return provider.DriverCapabilities{ + Streaming: true, + ToolTransport: true, + } + } + return f.capabilities +} + type stubTool struct { name string content string @@ -580,7 +591,7 @@ func TestServiceRunRejectsProviderCompletionWithoutMessageDone(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { select { case events <- providertypes.NewTextDeltaStreamEvent("partial"): case <-ctx.Done(): @@ -1456,7 +1467,7 @@ func TestServiceRunErrorPaths(t *testing.T) { callIdx := 0 return &scriptedProvider{ name: "retry-then-success", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { callIdx++ if callIdx == 1 { return &provider.ProviderError{ @@ -1492,7 +1503,7 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-no-retry", Content: "hello"}, provider: &scriptedProvider{ name: "auth-error-no-retry", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { return &provider.ProviderError{ StatusCode: 401, Code: provider.ErrorCodeAuthFailed, @@ -1515,7 +1526,7 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-retry-exhausted", Content: "hello"}, provider: &scriptedProvider{ name: "always-500", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { return &provider.ProviderError{ StatusCode: 500, Code: provider.ErrorCodeServer, @@ -1599,7 +1610,7 @@ func TestServiceCancelActiveRun(t *testing.T) { started := make(chan struct{}) scripted := &scriptedProvider{ name: "cancel-active-run-provider", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return ctx.Err() @@ -1642,7 +1653,7 @@ func TestServiceRunCanceledByProvider(t *testing.T) { started := make(chan struct{}) scripted := &scriptedProvider{ name: "blocking-provider", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return ctx.Err() @@ -1687,7 +1698,7 @@ func TestServiceRunPreservesProviderErrorAfterCancel(t *testing.T) { providerErr := errors.New("provider failed after cancel") scripted := &scriptedProvider{ name: "provider-error-after-cancel", - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return providerErr @@ -2071,7 +2082,7 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { }, } factory := &scriptedProviderFactory{provider: scripted} - service := NewWithFactory(manager, registry, store, factory, nil) + service := NewWithFactory(manager, registry, store, factory, &stubContextBuilder{}) result, err := service.Compact(context.Background(), CompactInput{ SessionID: session.ID, @@ -2143,7 +2154,7 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t }, } factory := &scriptedProviderFactory{provider: scripted} - service := NewWithFactory(manager, registry, store, factory, nil) + service := NewWithFactory(manager, registry, store, factory, &stubContextBuilder{}) result, err := service.Compact(context.Background(), CompactInput{ SessionID: session.ID, @@ -2259,7 +2270,7 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { providerStarted := make(chan struct{}) unblockProvider := make(chan struct{}) scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { select { case <-providerStarted: default: @@ -2676,7 +2687,7 @@ func cloneSession(session agentsession.Session) agentsession.Session { return cloned } -func cloneChatRequest(req providertypes.ChatRequest) providertypes.ChatRequest { +func cloneGenerateRequest(req providertypes.GenerateRequest) providertypes.GenerateRequest { cloned := req cloned.Messages = append([]providertypes.Message(nil), req.Messages...) cloned.Tools = append([]providertypes.ToolSpec(nil), req.Tools...) @@ -3073,7 +3084,7 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { store := newMemoryStore() scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { events <- providertypes.StreamEvent{Type: providertypes.StreamEventTextDelta} return errors.New("provider chat failed") }, @@ -3084,7 +3095,7 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { context.Background(), "run-forward-error", "session-forward-error", - providertypes.ChatRequest{ + providertypes.GenerateRequest{ Model: "test-model", SystemPrompt: "prompt", Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, @@ -3095,6 +3106,43 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { } } +func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("should not run")}, + }, + } + factory := &scriptedProviderFactory{ + provider: scripted, + capabilities: provider.DriverCapabilities{ + Streaming: true, + ToolTransport: false, + }, + } + + service := NewWithFactory(manager, registry, store, factory, &stubContextBuilder{}) + err := service.Run(context.Background(), UserInput{ + RunID: "run-driver-no-tools", + Content: "hello", + }) + if err == nil || !containsError(err, "does not support tool transport") { + t.Fatalf("expected tool transport capability error, got %v", err) + } + if factory.calls != 0 { + t.Fatalf("expected provider build to be skipped, got %d", factory.calls) + } + if scripted.callCount != 0 { + t.Fatalf("expected provider Generate() to be skipped, got %d", scripted.callCount) + } +} + func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { t.Parallel() @@ -3105,7 +3153,7 @@ func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { builder := &stubContextBuilder{} scripted := &scriptedProvider{} - scripted.chatFn = func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + scripted.chatFn = func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { usage := &providertypes.Usage{} if scripted.callCount == 1 { usage.InputTokens = 100 From 707049c6e56b5ce72351dafb899bf13c44395a4e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 8 Apr 2026 21:48:27 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=AD=98=E5=9C=A8EOF=E8=A2=AB=E8=AF=AF=E5=BD=93?= =?UTF-8?q?=E4=BD=9C=E6=AD=A3=E5=B8=B8=E5=AE=8C=E6=88=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/catalog/service_test.go | 186 +++++++++++++++++++++ internal/provider/catalog/store_test.go | 119 +++++++++++++ internal/provider/openai/openai_test.go | 79 +++++++++ internal/provider/openai/response.go | 8 +- internal/provider/registry.go | 8 +- internal/provider/registry_test.go | 11 +- internal/provider/types/types_test.go | 85 ++++++++++ internal/runtime/compact_generator_test.go | 28 ++++ internal/runtime/runtime.go | 7 +- internal/runtime/runtime_test.go | 46 ++++- 10 files changed, 558 insertions(+), 19 deletions(-) diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 476a1f59..cbf3a854 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -2,7 +2,9 @@ package catalog import ( "context" + "errors" "sync" + "sync/atomic" "testing" "time" @@ -203,6 +205,190 @@ func TestDescriptorsFromIDsHelper(t *testing.T) { } } +func TestServiceValidateErrors(t *testing.T) { + t.Parallel() + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + + var service *Service + _, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + if err == nil || err.Error() != "provider catalog: service is nil" { + t.Fatalf("expected nil service error, got %v", err) + } + }) + + t.Run("nil registry", func(t *testing.T) { + t.Parallel() + + service := NewService("", nil, newMemoryStore()) + _, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + if err == nil || err.Error() != "provider catalog: registry is nil" { + t.Fatalf("expected nil registry error, got %v", err) + } + }) +} + +func TestListProviderModelsHonorsContextError(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), newMemoryStore()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := service.ListProviderModels(ctx, config.OpenAIProvider()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + identity, err := config.OpenAIProvider().Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + + store := newMemoryStore() + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + if err := store.Save(context.Background(), ModelCatalog{ + SchemaVersion: schemaVersion, + Identity: identity, + FetchedAt: now.Add(-time.Hour), + ExpiresAt: now.Add(time.Hour), + Models: []config.ModelDescriptor{ + {ID: "cached-model", Name: "Cached Model"}, + }, + }); err != nil { + t.Fatalf("seed fresh catalog: %v", err) + } + + var discoverCalls int32 + registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + return []config.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil + }) + + service := NewService("", registry, store) + service.now = func() time.Time { return now } + + models, err := service.ListProviderModelsCached(context.Background(), config.OpenAIProvider()) + if err != nil { + t.Fatalf("ListProviderModelsCached() error = %v", err) + } + if !containsModelDescriptorID(models, "cached-model") { + t.Fatalf("expected cached model to be returned, got %+v", models) + } + if atomic.LoadInt32(&discoverCalls) != 0 { + t.Fatalf("expected no discovery for fresh cache, got %d", discoverCalls) + } +} + +func TestDiscoverAndPersistFailurePaths(t *testing.T) { + t.Run("unsupported driver", func(t *testing.T) { + service := NewService("", provider.NewRegistry(), newMemoryStore()) + if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil { + t.Fatalf("expected unsupported driver to skip discovery, got ok=%v models=%+v", ok, discovered) + } + }) + + t.Run("resolve provider config failure", func(t *testing.T) { + service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + return nil, nil + }), newMemoryStore()) + + providerCfg := config.ProviderConfig{ + Name: "broken-openai", + Driver: config.OpenAIName, + BaseURL: config.OpenAIDefaultBaseURL, + Model: config.OpenAIDefaultModel, + APIKeyEnv: "", + } + + if discovered, ok := service.discoverAndPersist(context.Background(), providerCfg); ok || discovered != nil { + t.Fatalf("expected resolve failure to skip discovery, got ok=%v models=%+v", ok, discovered) + } + }) + + t.Run("discovery error", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + return nil, errors.New("discover failed") + }), newMemoryStore()) + + if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil { + t.Fatalf("expected discovery error to skip persistence, got ok=%v models=%+v", ok, discovered) + } + }) + + t.Run("store nil still returns discovered models", func(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + return []config.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil + }), nil) + + discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()) + if !ok { + t.Fatal("expected discovery without store to succeed") + } + if !containsModelDescriptorID(discovered, "gpt-4.1") { + t.Fatalf("expected discovered model to be returned, got %+v", discovered) + } + }) +} + +func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + identity, err := config.OpenAIProvider().Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + + started := make(chan struct{}, 1) + release := make(chan struct{}) + var discoverCalls int32 + registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + select { + case started <- struct{}{}: + default: + } + select { + case <-release: + case <-ctx.Done(): + } + return []config.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil + }) + + service := NewService("", registry, newMemoryStore()) + service.backgroundTimeout = time.Second + + service.queueRefresh(config.OpenAIProvider(), identity) + <-started + service.queueRefresh(config.OpenAIProvider(), identity) + + time.Sleep(50 * time.Millisecond) + if calls := atomic.LoadInt32(&discoverCalls); calls != 1 { + t.Fatalf("expected exactly one in-flight refresh, got %d", calls) + } + + close(release) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + service.refreshMu.Lock() + _, exists := service.inFlightByID[identity.Key()] + service.refreshMu.Unlock() + if !exists { + return + } + time.Sleep(20 * time.Millisecond) + } + + t.Fatal("expected in-flight refresh marker to be cleared") +} + func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *provider.Registry { t.Helper() diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index 86c7c098..82024d28 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -142,3 +143,121 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { t.Fatalf("expected persisted catalog to end with newline, got %q", string(data)) } } + +func TestModelCatalogExpired(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + if (ModelCatalog{}).Expired(now) { + t.Fatal("expected zero-value catalog to be treated as not expired") + } + if !(ModelCatalog{ExpiresAt: now}).Expired(now) { + t.Fatal("expected catalog expiring at now to be expired") + } + if (ModelCatalog{ExpiresAt: now.Add(time.Minute)}).Expired(now) { + t.Fatal("expected future expiry to be treated as fresh") + } +} + +func TestJSONStoreLoadHonorsContextError(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = store.Load(ctx, identity) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestJSONStoreLoadRejectsInvalidIdentity(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + _, err := store.Load(context.Background(), config.ProviderIdentity{ + Driver: "openai", + BaseURL: "://bad", + }) + if err == nil || !strings.Contains(err.Error(), "normalize model catalog key") { + t.Fatalf("expected identity normalization error, got %v", err) + } +} + +func TestJSONStoreLoadRejectsInvalidJSON(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + + if err := os.MkdirAll(filepath.Dir(store.catalogPath(identity)), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(store.catalogPath(identity), []byte("{not-json"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err = store.Load(context.Background(), identity) + if err == nil || !strings.Contains(err.Error(), "decode model catalog") { + t.Fatalf("expected decode error, got %v", err) + } +} + +func TestJSONStoreSaveHonorsContextError(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err = store.Save(ctx, ModelCatalog{Identity: identity}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestJSONStoreSaveRejectsInvalidIdentity(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + err := store.Save(context.Background(), ModelCatalog{ + Identity: config.ProviderIdentity{ + Driver: "", + BaseURL: "https://api.openai.com/v1", + }, + }) + if err == nil || !strings.Contains(err.Error(), "normalize model catalog key") { + t.Fatalf("expected identity normalization error, got %v", err) + } +} + +func TestNormalizeCatalogDefaultsSchemaAndDedupesModels(t *testing.T) { + t.Parallel() + + modelCatalog := normalizeCatalog(ModelCatalog{ + Models: []config.ModelDescriptor{ + {ID: "gpt-4o", Name: "GPT-4o"}, + {ID: "gpt-4o", Name: "GPT-4o Duplicate"}, + }, + }) + if modelCatalog.SchemaVersion != schemaVersion { + t.Fatalf("expected schema version %d, got %d", schemaVersion, modelCatalog.SchemaVersion) + } + if len(modelCatalog.Models) != 1 { + t.Fatalf("expected duplicate models to be merged, got %+v", modelCatalog.Models) + } +} diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index a55c370f..aa3d4414 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -737,6 +737,85 @@ func TestDiscoverModels_NetworkError(t *testing.T) { } } +func TestFetchModelsSetsAuthorizationHeader(t *testing.T) { + t.Parallel() + + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}}) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL, "header-key")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + if _, err := p.fetchModels(context.Background()); err != nil { + t.Fatalf("fetchModels() error = %v", err) + } + if authorization != "Bearer test-key" { + t.Fatalf("expected bearer authorization header, got %q", authorization) + } +} + +func TestFetchModelsDecodeError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{invalid-json")) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL, "decode-key")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + _, err = p.fetchModels(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode models response") { + t.Fatalf("expected decode error, got %v", err) + } +} + +func TestDiscoverModelsSkipsInvalidEntriesAndDedupes(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "gpt-4.1", "name": "GPT-4.1"}, + {"foo": "bar"}, + {"id": "gpt-4.1", "name": "GPT-4.1 Duplicate"}, + }, + }) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL, "discover-key")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err != nil { + t.Fatalf("DiscoverModels() error = %v", err) + } + if len(models) != 1 { + t.Fatalf("expected invalid and duplicate models to be filtered, got %+v", models) + } + if models[0].ID != "gpt-4.1" { + t.Fatalf("expected remaining model to be gpt-4.1, got %+v", models[0]) + } +} + // --- mergeToolCallDelta 边界测试 --- func TestMergeToolCallDelta_MultipleIndices(t *testing.T) { diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 02b7b9b5..3b59d880 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -117,7 +117,13 @@ func (p *Provider) consumeStream( if flushErr := flushPendingData(); flushErr != nil { return flushErr } - return finishStream() + if done { + return finishStream() + } + if ctx.Err() != nil { + return ctx.Err() + } + return provider.ErrStreamInterrupted } } } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 8a33edb4..b49344a1 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -73,13 +73,13 @@ func (r *Registry) Supports(driverType string) bool { return err == nil } -// DriverCapabilities 返回指定 driver 的能力声明,不存在时返回零值。 -func (r *Registry) DriverCapabilities(driverType string) DriverCapabilities { +// DriverCapabilities 返回指定 driver 的能力声明;driver 不存在时返回对应错误。 +func (r *Registry) DriverCapabilities(driverType string) (DriverCapabilities, error) { driver, err := r.driver(driverType) if err != nil { - return DriverCapabilities{} + return DriverCapabilities{}, err } - return driver.Capabilities + return driver.Capabilities, nil } func (r *Registry) driver(driverType string) (DriverDefinition, error) { diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index f8f19bf6..5d597823 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -191,7 +191,10 @@ func TestRegistryDriverCapabilities(t *testing.T) { t.Parallel() registry := newTestRegistry(t) - got := registry.DriverCapabilities("OPENAI") + got, err := registry.DriverCapabilities("OPENAI") + if err != nil { + t.Fatalf("DriverCapabilities() error = %v", err) + } if !got.Streaming { t.Fatalf("expected openai driver to support streaming, got %+v", got) } @@ -202,9 +205,9 @@ func TestRegistryDriverCapabilities(t *testing.T) { t.Fatalf("expected openai driver to support model discovery, got %+v", got) } - missing := registry.DriverCapabilities("missing") - if missing != (provider.DriverCapabilities{}) { - t.Fatalf("expected zero-value capabilities for missing driver, got %+v", missing) + _, err = registry.DriverCapabilities("missing") + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound for missing driver, got %v", err) } } diff --git a/internal/provider/types/types_test.go b/internal/provider/types/types_test.go index e9a07c15..a30d41da 100644 --- a/internal/provider/types/types_test.go +++ b/internal/provider/types/types_test.go @@ -265,3 +265,88 @@ func TestStreamEventValueAccessorsRejectMissingPayload(t *testing.T) { t.Fatal("expected TextDeltaValue() to reject missing payload") } } + +func TestStreamEventValueAccessorsRejectWrongType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func() error + }{ + { + name: "text delta accessor", + run: func() error { + _, err := NewToolCallStartStreamEvent(0, "call-1", "edit").TextDeltaValue() + return err + }, + }, + { + name: "tool call start accessor", + run: func() error { + _, err := NewTextDeltaStreamEvent("hello").ToolCallStartValue() + return err + }, + }, + { + name: "tool call delta accessor", + run: func() error { + _, err := NewMessageDoneStreamEvent("stop", nil).ToolCallDeltaValue() + return err + }, + }, + { + name: "message done accessor", + run: func() error { + _, err := NewToolCallDeltaStreamEvent(1, "call-2", "{}").MessageDoneValue() + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.run(); err == nil { + t.Fatal("expected wrong-type accessor to return error") + } + }) + } +} + +func TestStreamEventValueAccessorsRejectMissingPayloadForAllTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func() error + }{ + { + name: "tool call start payload missing", + run: func() error { + _, err := (StreamEvent{Type: StreamEventToolCallStart}).ToolCallStartValue() + return err + }, + }, + { + name: "tool call delta payload missing", + run: func() error { + _, err := (StreamEvent{Type: StreamEventToolCallDelta}).ToolCallDeltaValue() + return err + }, + }, + { + name: "message done payload missing", + run: func() error { + _, err := (StreamEvent{Type: StreamEventMessageDone}).MessageDoneValue() + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.run(); err == nil { + t.Fatal("expected missing payload to return error") + } + }) + } +} diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index bb63190e..2e643f04 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "errors" "strings" "testing" "time" @@ -237,3 +238,30 @@ func TestCompactSummaryGeneratorRejectsDriverWithoutStreaming(t *testing.T) { t.Fatalf("expected provider Generate() to be skipped, got %d", scripted.callCount) } } + +func TestCompactSummaryGeneratorPropagatesDriverNotFoundFromCapabilities(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + resolvedProvider, err := resolvedProviderForTests(manager.Get(), config.OpenAIName) + if err != nil { + t.Fatalf("resolve provider: %v", err) + } + + factory := &scriptedProviderFactory{ + provider: &scriptedProvider{}, + capabilitiesErr: provider.ErrDriverNotFound, + } + generator := newCompactSummaryGenerator(factory, resolvedProvider, "session-model") + + _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ + Mode: contextcompact.ModeManual, + Config: manager.Get().Context.Compact, + }) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) + } + if factory.calls != 0 { + t.Fatalf("expected provider build to be skipped, got %d", factory.calls) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 24fedd75..8efef734 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -130,7 +130,7 @@ type UserInput struct { type ProviderFactory interface { Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) - DriverCapabilities(driverType string) provider.DriverCapabilities + DriverCapabilities(driverType string) (provider.DriverCapabilities, error) } type Service struct { @@ -679,7 +679,10 @@ func ensureProviderDriverCapabilities( } driverType := strings.TrimSpace(cfg.Driver) - caps := factory.DriverCapabilities(driverType) + caps, err := factory.DriverCapabilities(driverType) + if err != nil { + return err + } if requireStreaming && !caps.Streaming { return fmt.Errorf("runtime: provider driver %q does not support streaming", driverType) } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 1bad5069..5a8771e0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -172,11 +172,12 @@ func streamContainsMessageDone(events []providertypes.StreamEvent) bool { } type scriptedProviderFactory struct { - provider provider.Provider - calls int - configs []config.ResolvedProviderConfig - err error - capabilities provider.DriverCapabilities + provider provider.Provider + calls int + configs []config.ResolvedProviderConfig + err error + capabilities provider.DriverCapabilities + capabilitiesErr error } func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { @@ -188,14 +189,17 @@ func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.Resolved return f.provider, nil } -func (f *scriptedProviderFactory) DriverCapabilities(driverType string) provider.DriverCapabilities { +func (f *scriptedProviderFactory) DriverCapabilities(driverType string) (provider.DriverCapabilities, error) { + if f.capabilitiesErr != nil { + return provider.DriverCapabilities{}, f.capabilitiesErr + } if f.capabilities == (provider.DriverCapabilities{}) { return provider.DriverCapabilities{ Streaming: true, ToolTransport: true, - } + }, nil } - return f.capabilities + return f.capabilities, nil } type stubTool struct { @@ -3143,6 +3147,32 @@ func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { } } +func TestServiceRunPropagatesDriverNotFoundFromCapabilities(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + factory := &scriptedProviderFactory{ + provider: &scriptedProvider{}, + capabilitiesErr: fmt.Errorf("%w: missing", provider.ErrDriverNotFound), + } + + service := NewWithFactory(manager, registry, store, factory, &stubContextBuilder{}) + err := service.Run(context.Background(), UserInput{ + RunID: "run-driver-missing", + Content: "hello", + }) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) + } + if factory.calls != 0 { + t.Fatalf("expected provider build to be skipped, got %d", factory.calls) + } +} + func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { t.Parallel() From b134397651514a912b3690e5116b0e703fc7b781 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 9 Apr 2026 23:19:30 +0800 Subject: [PATCH 03/13] =?UTF-8?q?pref(provider):=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=A3=85=E9=85=8D=E3=80=81=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=BC=93=E5=AD=98=E4=B8=8E=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=B1=BB=E5=9E=8B=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap.go | 2 +- internal/app/bootstrap_test.go | 45 ++ internal/config/builtin_providers.go | 14 +- internal/config/builtin_providers_test.go | 12 +- internal/config/config_test.go | 344 +++++--------- internal/config/loader.go | 9 + internal/config/loader_test.go | 434 ++++++++++++++++++ internal/config/manager.go | 4 + internal/config/model.go | 169 ++++++- internal/config/provider_adapter.go | 71 +++ internal/config/provider_adapter_test.go | 129 ++++++ internal/config/provider_identity.go | 76 --- internal/config/provider_loader.go | 173 +++++++ internal/config/selection.go | 412 ++++++----------- internal/config/selection_test.go | 313 +++++++++++-- internal/provider/builtin/builtin.go | 4 +- internal/provider/builtin/builtin_test.go | 10 +- internal/provider/capabilities.go | 43 -- internal/provider/catalog/service.go | 119 ++--- internal/provider/catalog/service_test.go | 195 ++++++-- internal/provider/catalog/store.go | 25 +- internal/provider/catalog/store_test.go | 90 +++- internal/provider/contracts.go | 40 ++ internal/provider/identity.go | 149 ++++++ internal/provider/identity_test.go | 133 ++++++ internal/provider/openai/driver.go | 41 -- .../{openai => openaicompat}/discovery.go | 2 +- internal/provider/openaicompat/driver.go | 46 ++ .../{openai => openaicompat}/events.go | 2 +- .../openaicompat_test.go} | 61 +-- .../{openai => openaicompat}/provider.go | 24 +- .../{openai => openaicompat}/request.go | 4 +- .../{openai => openaicompat}/response.go | 2 +- .../{openai => openaicompat}/sse_reader.go | 2 +- .../sse_reader_test.go | 2 +- .../{openai => openaicompat}/toolcall.go | 2 +- .../{openai => openaicompat}/types.go | 2 +- internal/provider/provider.go | 12 - internal/provider/registry.go | 27 +- internal/provider/registry_test.go | 75 ++- internal/provider/types/model.go | 298 ++++++++++++ internal/provider/types/model_test.go | 285 ++++++++++++ internal/runtime/compact.go | 2 +- internal/runtime/compact_generator.go | 12 +- internal/runtime/compact_generator_test.go | 47 +- internal/runtime/runtime.go | 18 +- internal/runtime/runtime_test.go | 70 ++- internal/tui/bootstrap/builder.go | 5 +- internal/tui/bootstrap/builder_test.go | 5 +- internal/tui/core/app/app.go | 4 +- internal/tui/core/app/commands.go | 10 +- internal/tui/services/provider_service.go | 9 +- internal/tui/services/services_test.go | 11 +- internal/tui/state/messages.go | 4 +- 54 files changed, 3074 insertions(+), 1025 deletions(-) create mode 100644 internal/config/provider_adapter.go create mode 100644 internal/config/provider_adapter_test.go delete mode 100644 internal/config/provider_identity.go create mode 100644 internal/config/provider_loader.go delete mode 100644 internal/provider/capabilities.go create mode 100644 internal/provider/contracts.go create mode 100644 internal/provider/identity.go create mode 100644 internal/provider/identity_test.go delete mode 100644 internal/provider/openai/driver.go rename internal/provider/{openai => openaicompat}/discovery.go (98%) create mode 100644 internal/provider/openaicompat/driver.go rename internal/provider/{openai => openaicompat}/events.go (99%) rename internal/provider/{openai/openai_test.go => openaicompat/openaicompat_test.go} (97%) rename internal/provider/{openai => openaicompat}/provider.go (78%) rename internal/provider/{openai => openaicompat}/request.go (97%) rename internal/provider/{openai => openaicompat}/response.go (99%) rename internal/provider/{openai => openaicompat}/sse_reader.go (99%) rename internal/provider/{openai => openaicompat}/sse_reader_test.go (99%) rename internal/provider/{openai => openaicompat}/toolcall.go (98%) rename internal/provider/{openai => openaicompat}/types.go (99%) delete mode 100644 internal/provider/provider.go create mode 100644 internal/provider/types/model.go create mode 100644 internal/provider/types/model_test.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 29fa456a..63b3a51e 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -70,7 +70,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) - providerSelection := config.NewSelectionService(manager, providerRegistry, providerRegistry, modelCatalogs) + providerSelection := config.NewSelectionService(manager, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { return RuntimeBundle{}, err } diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index c896cc8a..d66ac018 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -78,6 +78,51 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { } } +func TestBuildRuntimeRejectsUnsupportedSelectedProviderDriverOnStartup(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configDir := filepath.Join(home, ".neocode") + providerDir := filepath.Join(configDir, "providers", "company-gateway") + if err := os.MkdirAll(providerDir, 0o755); err != nil { + t.Fatalf("mkdir provider dir: %v", err) + } + + configPath := filepath.Join(configDir, "config.yaml") + rawConfig := []byte("selected_provider: company-gateway\ncurrent_model: claude-3-7-sonnet\nshell: powershell\n") + if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + rawProvider := []byte( + "name: company-gateway\n" + + "driver: anthropic\n" + + "api_key_env: COMPANY_GATEWAY_API_KEY\n" + + "anthropic:\n" + + " base_url: https://api.anthropic.example/v1\n" + + " api_version: 2023-06-01\n", + ) + if err := os.WriteFile(filepath.Join(providerDir, "provider.yaml"), rawProvider, 0o644); err != nil { + t.Fatalf("write provider config: %v", err) + } + + _, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if !errors.Is(err, config.ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } + + data, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatalf("read config: %v", readErr) + } + if !strings.Contains(string(data), "selected_provider: company-gateway") { + t.Fatalf("expected selected_provider to remain unchanged, got:\n%s", string(data)) + } +} + func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) { t.Parallel() diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go index 57d1a2eb..4a53fade 100644 --- a/internal/config/builtin_providers.go +++ b/internal/config/builtin_providers.go @@ -22,14 +22,17 @@ const ( QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" ) +const OpenaiCompatDriver = "openaicompat" + // OpenAIProvider returns the builtin OpenAI provider definition. func OpenAIProvider() ProviderConfig { return ProviderConfig{ Name: OpenAIName, - Driver: "openai", + Driver: OpenaiCompatDriver, BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -37,10 +40,11 @@ func OpenAIProvider() ProviderConfig { func GeminiProvider() ProviderConfig { return ProviderConfig{ Name: GeminiName, - Driver: "openai", + Driver: OpenaiCompatDriver, BaseURL: GeminiDefaultBaseURL, Model: GeminiDefaultModel, APIKeyEnv: GeminiDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -48,10 +52,11 @@ func GeminiProvider() ProviderConfig { func OpenLLProvider() ProviderConfig { return ProviderConfig{ Name: OpenLLName, - Driver: "openai", + Driver: OpenaiCompatDriver, BaseURL: OpenLLDefaultBaseURL, Model: OpenLLDefaultModel, APIKeyEnv: OpenLLDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -59,10 +64,11 @@ func OpenLLProvider() ProviderConfig { func QiniuProvider() ProviderConfig { return ProviderConfig{ Name: QiniuName, - Driver: "openai", + Driver: OpenaiCompatDriver, BaseURL: QiniuDefaultBaseURL, Model: QiniuDefaultModel, APIKeyEnv: QiniuDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } diff --git a/internal/config/builtin_providers_test.go b/internal/config/builtin_providers_test.go index 6309a671..b4ec7976 100644 --- a/internal/config/builtin_providers_test.go +++ b/internal/config/builtin_providers_test.go @@ -28,8 +28,8 @@ func TestOpenAIProviderConfig(t *testing.T) { if provider.Name != OpenAIName { t.Fatalf("expected name %q, got %q", OpenAIName, provider.Name) } - if provider.Driver != "openai" { - t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) } if provider.BaseURL != OpenAIDefaultBaseURL { t.Fatalf("expected base URL %q, got %q", OpenAIDefaultBaseURL, provider.BaseURL) @@ -50,8 +50,8 @@ func TestGeminiProviderConfig(t *testing.T) { if provider.Name != GeminiName { t.Fatalf("expected name %q, got %q", GeminiName, provider.Name) } - if provider.Driver != "openai" { - t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) } if provider.BaseURL != GeminiDefaultBaseURL { t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL) @@ -72,8 +72,8 @@ func TestOpenLLProviderConfig(t *testing.T) { if provider.Name != OpenLLName { t.Fatalf("expected name %q, got %q", OpenLLName, provider.Name) } - if provider.Driver != "openai" { - t.Fatalf("expected driver %q, got %q", "openai", provider.Driver) + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver) } if provider.BaseURL != OpenLLDefaultBaseURL { t.Fatalf("expected base URL %q, got %q", OpenLLDefaultBaseURL, provider.BaseURL) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20dc6c24..6b0e0cc3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,10 +19,11 @@ const ( func testDefaultProviderConfig() ProviderConfig { return ProviderConfig{ Name: testProviderName, - Driver: testProviderName, + Driver: "openaicompat", BaseURL: testBaseURL, Model: testModel, APIKeyEnv: testAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -232,7 +233,7 @@ func TestProviderConfigResolveAPIKey(t *testing.T) { provider := ProviderConfig{ Name: testProviderName, - Driver: testProviderName, + Driver: "openaicompat", BaseURL: testBaseURL, Model: testModel, APIKeyEnv: tt.envKey, @@ -462,7 +463,7 @@ func TestConfigValidateFailures(t *testing.T) { cfg := validConfig.Clone() cfg.Providers = append(cfg.Providers, ProviderConfig{ Name: "openai-shadow", - Driver: "OPENAI", + Driver: "OPENAICOMPAT", BaseURL: "https://API.OPENAI.COM/v1/", Model: "shadow-model", APIKeyEnv: "OPENAI_SHADOW_KEY", @@ -574,11 +575,35 @@ 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", + BaseURL: "https://example.com/v1", + Model: "gpt-4.1", + APIKeyEnv: "CUSTOM_API_KEY", + Source: ProviderSourceCustom, + }, + expectErr: "must not define model", + }, { name: "missing base url", provider: ProviderConfig{ Name: testProviderName, - Driver: testProviderName, + Driver: "openaicompat", }, expectErr: "base_url is empty", }, @@ -586,7 +611,7 @@ func TestProviderConfigValidateFailures(t *testing.T) { name: "missing model", provider: ProviderConfig{ Name: testProviderName, - Driver: testProviderName, + Driver: "openaicompat", BaseURL: testBaseURL, }, expectErr: "model is empty", @@ -595,7 +620,7 @@ func TestProviderConfigValidateFailures(t *testing.T) { name: "missing api key env", provider: ProviderConfig{ Name: testProviderName, - Driver: testProviderName, + Driver: "openaicompat", BaseURL: testBaseURL, Model: testModel, }, @@ -735,7 +760,7 @@ func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { } } -func TestApplyDefaultsReplacesProvidersWithBuiltinSnapshot(t *testing.T) { +func TestApplyDefaultsPreservesCustomProvidersAlongsideBuiltinSnapshot(t *testing.T) { t.Parallel() current := Config{ @@ -743,20 +768,24 @@ func TestApplyDefaultsReplacesProvidersWithBuiltinSnapshot(t *testing.T) { Name: "openai-alt", Driver: "custom", BaseURL: "https://example.com/v1", - Model: "custom-model", APIKeyEnv: "CUSTOM_API_KEY", + Source: ProviderSourceCustom, }}, SelectedProvider: "openai-alt", - CurrentModel: "custom-model", + CurrentModel: "server-discovered-model", } current.ApplyDefaultsFrom(*testDefaultConfig()) - if len(current.Providers) != 1 { - t.Fatalf("expected builtin provider snapshot, got %+v", current.Providers) + if len(current.Providers) != 2 { + t.Fatalf("expected builtin and custom providers to coexist, got %+v", current.Providers) } - if _, err := current.ProviderByName("openai-alt"); err == nil { - t.Fatalf("expected custom provider to be dropped, got %+v", current.Providers) + customProvider, err := current.ProviderByName("openai-alt") + if err != nil { + t.Fatalf("expected custom provider to be preserved, got %+v", current.Providers) + } + if customProvider.Source != ProviderSourceCustom { + t.Fatalf("expected custom provider source, got %+v", customProvider) } provider, err := current.ProviderByName(testProviderName) if err != nil { @@ -765,11 +794,78 @@ func TestApplyDefaultsReplacesProvidersWithBuiltinSnapshot(t *testing.T) { if provider.BaseURL != testBaseURL || provider.Model != testModel || provider.APIKeyEnv != testAPIKeyEnv { t.Fatalf("expected builtin provider metadata, got %+v", provider) } - if current.SelectedProvider != testProviderName { - t.Fatalf("expected selected provider to reset to builtin %q, got %q", testProviderName, current.SelectedProvider) + if provider.Source != ProviderSourceBuiltin { + t.Fatalf("expected builtin provider source, got %+v", provider) + } + if current.SelectedProvider != "openai-alt" { + t.Fatalf("expected selected provider to stay on custom provider, got %q", current.SelectedProvider) + } + if current.CurrentModel != "server-discovered-model" { + t.Fatalf("expected current model to preserve discovered model, got %q", current.CurrentModel) + } +} + +func TestApplyDefaultsKeepsDuplicateCustomProviderNamesForValidation(t *testing.T) { + t.Parallel() + + current := Config{ + Providers: []ProviderConfig{ + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example-a.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_A_API_KEY", + Source: ProviderSourceCustom, + }, + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example-b.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_B_API_KEY", + Source: ProviderSourceCustom, + }, + }, + SelectedProvider: testProviderName, + CurrentModel: testModel, } - if current.CurrentModel != testModel { - t.Fatalf("expected current model to reset with selected builtin provider, got %q", current.CurrentModel) + + current.ApplyDefaultsFrom(*testDefaultConfig()) + + if err := current.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + t.Fatalf("expected duplicate custom provider name error, got %v", err) + } +} + +func TestApplyDefaultsKeepsIdenticalDuplicateCustomProviderNamesForValidation(t *testing.T) { + t.Parallel() + + current := Config{ + Providers: []ProviderConfig{ + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + APIStyle: "responses", + Source: ProviderSourceCustom, + }, + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + APIStyle: "responses", + Source: ProviderSourceCustom, + }, + }, + SelectedProvider: testProviderName, + CurrentModel: testModel, + } + + current.ApplyDefaultsFrom(*testDefaultConfig()) + + if err := current.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + t.Fatalf("expected duplicate custom provider name error, got %v", err) } } @@ -881,6 +977,9 @@ func TestManagerHelperMethodsAndReloads(t *testing.T) { if got := manager.ConfigPath(); got != filepath.Join(tempDir, configName) { t.Fatalf("expected config path %q, got %q", filepath.Join(tempDir, configName), got) } + if got := manager.BaseDir(); got != tempDir { + t.Fatalf("expected base dir %q, got %q", tempDir, got) + } } func TestLoaderDefaultsAndProviderDefaults(t *testing.T) { @@ -1224,214 +1323,3 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) { t.Fatalf("expected error to contain 'auto_compact', got %v", err) } } - -// ---- selection.go 工具函数覆盖 ---- - -func TestDescriptorFromRawModel(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw map[string]any - want ModelDescriptor - wantOK bool - }{ - { - name: "empty map returns false", - raw: map[string]any{}, - wantOK: false, - }, - { - name: "id from model field", - raw: map[string]any{ - "model": "gpt-4.1", - }, - want: ModelDescriptor{ID: "gpt-4.1", Name: "gpt-4.1"}, - wantOK: true, - }, - { - name: "full descriptor", - raw: map[string]any{ - "id": "gpt-4.1", - "display_name": "GPT-4.1", - "description": "desc", - "context_window": 128000, - "max_output_tokens": 16384, - }, - want: ModelDescriptor{ - ID: "gpt-4.1", - Name: "GPT-4.1", - Description: "desc", - ContextWindow: 128000, - MaxOutputTokens: 16384, - }, - wantOK: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, ok := DescriptorFromRawModel(tt.raw) - if ok != tt.wantOK { - t.Fatalf("expected ok=%v, got ok=%v", tt.wantOK, ok) - } - if !tt.wantOK { - return - } - if got.ID != tt.want.ID { - t.Fatalf("expected ID=%q, got %q", tt.want.ID, got.ID) - } - if got.Name != tt.want.Name { - t.Fatalf("expected Name=%q, got %q", tt.want.Name, got.Name) - } - if got.Description != tt.want.Description { - t.Fatalf("expected Description=%q, got %q", tt.want.Description, got.Description) - } - if got.ContextWindow != tt.want.ContextWindow { - t.Fatalf("expected ContextWindow=%d, got %d", tt.want.ContextWindow, got.ContextWindow) - } - if got.MaxOutputTokens != tt.want.MaxOutputTokens { - t.Fatalf("expected MaxOutputTokens=%d, got %d", tt.want.MaxOutputTokens, got.MaxOutputTokens) - } - }) - } -} - -func TestMergeModelDescriptors(t *testing.T) { - t.Parallel() - - a := []ModelDescriptor{{ID: "m1", Name: "Model1"}} - b := []ModelDescriptor{{ID: "m2", Name: "Model2"}, {ID: "m1", Description: "fallback"}} - - merged := MergeModelDescriptors(a, b) - if len(merged) != 2 { - t.Fatalf("expected 2 descriptors, got %d", len(merged)) - } - - // m1 from first source should keep its Name, get description from second - var m1 *ModelDescriptor - for i := range merged { - if merged[i].ID == "m1" { - m1 = &merged[i] - break - } - } - if m1 == nil { - t.Fatalf("expected m1 to be present") - } - if m1.Name != "Model1" { - t.Fatalf("expected Name=Model1 from first source, got %q", m1.Name) - } - if m1.Description != "fallback" { - t.Fatalf("expected Description=fallback from second source, got %q", m1.Description) - } -} - -func TestDescriptorsFromIDs(t *testing.T) { - t.Parallel() - - result := DescriptorsFromIDs([]string{"gpt-4.1", "gpt-4.1-mini"}) - if len(result) != 2 { - t.Fatalf("expected 2 descriptors, got %d", len(result)) - } - if result[0].ID != "gpt-4.1" { - t.Fatalf("expected first ID=gpt-4.1, got %q", result[0].ID) - } - if result[1].Name != "gpt-4.1-mini" { - t.Fatalf("expected second Name=gpt-4.1-mini, got %q", result[1].Name) - } -} - -func TestFirstNonEmptyString(t *testing.T) { - t.Parallel() - - if got := firstNonEmptyString("", " ", "hello", "world"); got != "hello" { - t.Fatalf("expected hello, got %q", got) - } - if got := firstNonEmptyString("", " "); got != "" { - t.Fatalf("expected empty, got %q", got) - } -} - -func TestFirstPositiveInt(t *testing.T) { - t.Parallel() - - if got := firstPositiveInt(0, -1, 42, 100); got != 42 { - t.Fatalf("expected 42, got %d", got) - } - if got := firstPositiveInt(int32(5)); got != 5 { - t.Fatalf("expected 5, got %d", got) - } - if got := firstPositiveInt(int64(10)); got != 10 { - t.Fatalf("expected 10, got %d", got) - } - if got := firstPositiveInt(float64(3.14)); got != 3 { - t.Fatalf("expected 3, got %d", got) - } - if got := firstPositiveInt(0, -5); got != 0 { - t.Fatalf("expected 0 when none positive, got %d", got) - } -} - -func TestBoolMapValue(t *testing.T) { - t.Parallel() - - result := boolMapValue(map[string]any{"a": true, "b": "notbool", "c": false}) - if len(result) != 2 { - t.Fatalf("expected 2 entries, got %d", len(result)) - } - if !result["a"] { - t.Fatalf("expected a=true") - } - if result["c"] { - t.Fatalf("expected c=false") - } - - if result := boolMapValue("not a map"); result != nil { - t.Fatalf("expected nil for non-map, got %v", result) - } -} - -func TestMergeStringBoolMaps(t *testing.T) { - t.Parallel() - - primary := map[string]bool{"a": true} - secondary := map[string]bool{"b": false, "a": false} - - result := mergeStringBoolMaps(primary, secondary) - if !result["a"] { - t.Fatalf("expected a=true (primary should win)") - } - if result["b"] { - t.Fatalf("expected b=false") - } - - if result := mergeStringBoolMaps(nil, nil); result != nil { - t.Fatalf("expected nil for both empty") - } -} - -func TestMergeModelDescriptorFallback(t *testing.T) { - t.Parallel() - - primary := ModelDescriptor{ID: "m1"} - secondary := ModelDescriptor{ - Name: "Fallback", - Description: "desc", - ContextWindow: 8000, - MaxOutputTokens: 4096, - } - - result := mergeModelDescriptor(primary, secondary) - if result.Name != "Fallback" { - t.Fatalf("expected Name=Fallback from secondary, got %q", result.Name) - } - if result.ContextWindow != 8000 { - t.Fatalf("expected ContextWindow=8000 from secondary, got %d", result.ContextWindow) - } - if result.MaxOutputTokens != 4096 { - t.Fatalf("expected MaxOutputTokens=4096 from secondary, got %d", result.MaxOutputTokens) - } -} diff --git a/internal/config/loader.go b/internal/config/loader.go index 2cf6e693..c053f930 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -107,6 +107,11 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { if err != nil { return nil, fmt.Errorf("config: parse config file: %w", err) } + customProviders, err := loadCustomProviders(l.baseDir) + if err != nil { + return nil, err + } + cfg.Providers = customProviders cfg.ApplyDefaultsFrom(l.defaults) if err := cfg.Validate(); err != nil { return nil, err @@ -133,7 +138,11 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } snapshot := cfg.Clone() + providersSnapshot := cloneProviders(snapshot.Providers) snapshot.ApplyDefaultsFrom(l.defaults) + if len(providersSnapshot) > 0 { + snapshot.Providers = providersSnapshot + } if err := snapshot.Validate(); err != nil { return err } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index bfbf7513..ab8466cd 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -240,3 +240,437 @@ shell: powershell t.Fatalf("expected rewritten config to persist current model, got:\n%s", text) } } + +func TestLoaderLoadsCustomProvidersFromProvidersDirectory(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + if err := os.MkdirAll(filepath.Join(loader.BaseDir(), providersDirName, "company-gateway"), 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + rawConfig := ` +selected_provider: company-gateway +current_model: deepseek-coder +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(rawConfig)+"\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +openai_compatible: + profile: generic + base_url: https://llm.example.com/v1 + api_style: chat_completions +` + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.SelectedProvider != "company-gateway" { + t.Fatalf("expected selected provider company-gateway, got %q", cfg.SelectedProvider) + } + if cfg.CurrentModel != "deepseek-coder" { + t.Fatalf("expected current model deepseek-coder, got %q", cfg.CurrentModel) + } + + customProvider, err := cfg.ProviderByName("company-gateway") + if err != nil { + t.Fatalf("ProviderByName(company-gateway) error = %v", err) + } + if customProvider.Source != ProviderSourceCustom { + t.Fatalf("expected custom provider source, got %+v", customProvider) + } + 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.Model != "" { + t.Fatalf("expected custom provider default model to be empty, got %q", customProvider.Model) + } + if len(customProvider.Models) != 0 { + t.Fatalf("expected custom provider models to come only from remote discovery, got %+v", customProvider.Models) + } +} + +func TestLoaderRejectsCustomProviderDefaultModel(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +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) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "does not support default_model") { + t.Fatalf("expected default_model rejection, got %v", err) + } +} + +func TestLoaderIgnoresCustomProviderModelsYAML(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +openai_compatible: + base_url: https://llm.example.com/v1 + api_style: chat_completions +` + modelsYAML := ` +models: + - name: deepseek-coder +` + 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 := os.WriteFile(filepath.Join(customDir, "models.yaml"), []byte(strings.TrimSpace(modelsYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write models.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 len(customProvider.Models) != 0 { + t.Fatalf("expected models.yaml to be ignored, got %+v", customProvider.Models) + } +} + +func TestLoaderRejectsCustomProviderNameConflictingWithBuiltin(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "openai") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: openai +driver: openaicompat +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) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + t.Fatalf("expected duplicate provider name error, got %v", err) + } +} + +func TestLoaderRejectsDuplicateCustomProviderEndpointIdentity(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customA := filepath.Join(loader.BaseDir(), providersDirName, "gateway-a") + customB := filepath.Join(loader.BaseDir(), providersDirName, "gateway-b") + for _, dir := range []string{customA, customB} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + } + + providerA := ` +name: gateway-a +driver: openaicompat +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 +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) + } + if err := os.WriteFile(filepath.Join(customB, customProviderConfigName), []byte(strings.TrimSpace(providerB)+"\n"), 0o644); err != nil { + t.Fatalf("write provider b: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "duplicate provider endpoint") { + t.Fatalf("expected duplicate provider endpoint error, got %v", err) + } +} + +func TestLoaderRejectsCustomProviderLegacyOpenAIDriver(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "legacy-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: legacy-gateway +driver: openai +api_key_env: LEGACY_GATEWAY_API_KEY +base_url: https://legacy.example.com/v1 +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "no longer supported") { + t.Fatalf("expected legacy driver rejection, got %v", err) + } +} + +func TestLoaderUsesOnlyDriverSpecificCustomProviderFields(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + rawConfig := ` +selected_provider: company-gateway +current_model: server-model +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(rawConfig)+"\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +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 +anthropic: + base_url: https://anthropic.example.com/v1 + api_version: 2023-06-01 +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + 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) + } +} + +func TestLoaderRejectsCrossDriverBaseURLFallback(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + rawConfig := ` +selected_provider: company-gateway +current_model: server-model +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(rawConfig)+"\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + providerYAML := ` +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) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "base_url is empty") { + t.Fatalf("expected missing openai-compatible base_url error, got %v", err) + } +} + +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 deployment mode 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://gateway.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 falls back across known blocks", + file: customProviderFile{ + Driver: "custom-driver", + Gemini: customGeminiProviderFile{ + BaseURL: " https://gemini.example.com/v1beta ", + }, + Anthropic: customAnthropicProviderFile{ + BaseURL: "https://anthropic.example.com/v1", + }, + }, + want: customProviderSettings{ + BaseURL: "https://gemini.example.com/v1beta", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := resolveCustomProviderSettings(tt.file) + if got != tt.want { + t.Fatalf("resolveCustomProviderSettings() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestResolveFallbackCustomProviderBaseURL(t *testing.T) { + t.Parallel() + + got := resolveFallbackCustomProviderBaseURL(customProviderFile{ + OpenAICompatible: customOpenAICompatibleFile{ + BaseURL: " ", + }, + Gemini: customGeminiProviderFile{ + BaseURL: " https://gemini.example.com/v1beta ", + }, + Anthropic: customAnthropicProviderFile{ + BaseURL: "https://anthropic.example.com/v1", + }, + }) + if got != "https://gemini.example.com/v1beta" { + t.Fatalf("expected gemini fallback base URL, got %q", got) + } + + if got := resolveFallbackCustomProviderBaseURL(customProviderFile{}); got != "" { + t.Fatalf("expected empty fallback base URL, got %q", got) + } +} diff --git a/internal/config/manager.go b/internal/config/manager.go index 743a9758..d083652a 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -73,7 +73,11 @@ func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error return err } + providersSnapshot := cloneProviders(next.Providers) next.ApplyDefaultsFrom(m.loader.DefaultConfig()) + if len(providersSnapshot) > 0 { + next.Providers = providersSnapshot + } if err := next.Validate(); err != nil { return err } diff --git a/internal/config/model.go b/internal/config/model.go index 4281b4b1..e5a0717f 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -8,6 +8,9 @@ import ( "path/filepath" goruntime "runtime" "strings" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const ( @@ -46,12 +49,24 @@ type Config struct { Tools ToolsConfig `yaml:"tools,omitempty"` } +type ProviderSource string + +const ( + ProviderSourceBuiltin ProviderSource = "builtin" + ProviderSourceCustom ProviderSource = "custom" +) + 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"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` + APIStyle string `yaml:"-"` + DeploymentMode string `yaml:"-"` + APIVersion string `yaml:"-"` + Models []providertypes.ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` } type ResolvedProviderConfig struct { @@ -150,9 +165,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { return } - if len(defaults.Providers) > 0 { - c.Providers = cloneProviders(defaults.Providers) - } + c.Providers = mergeProviders(defaults.Providers, c.Providers) fallbackProvider := defaultSelectedProviderName(c.Providers, defaults.SelectedProvider) selectedReset := false @@ -168,7 +181,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { } if strings.TrimSpace(c.CurrentModel) == "" || selectedReset { if selected, err := c.SelectedProviderConfig(); err == nil { - c.CurrentModel = selected.Model + c.CurrentModel = strings.TrimSpace(selected.Model) } else if strings.TrimSpace(defaults.CurrentModel) != "" { c.CurrentModel = defaults.CurrentModel } @@ -206,7 +219,7 @@ func (c *Config) Validate() error { return fmt.Errorf("config: provider[%d]: %w", i, err) } - key := NormalizeProviderName(provider.Name) + key := normalizeProviderName(provider.Name) if _, exists := seen[key]; exists { return fmt.Errorf("config: duplicate provider name %q", provider.Name) } @@ -219,7 +232,7 @@ func (c *Config) Validate() error { if existingName, exists := seenEndpoints[identity.Key()]; exists { return fmt.Errorf( "config: duplicate provider endpoint %q for providers %q and %q", - identity.BaseURL, + identity.String(), existingName, provider.Name, ) @@ -243,7 +256,7 @@ func (c *Config) Validate() error { if !filepath.IsAbs(c.Workdir) { return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) } - if strings.TrimSpace(selected.Model) == "" { + if selected.Source != ProviderSourceCustom && strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } if err := c.Tools.Validate(); err != nil { @@ -268,9 +281,9 @@ func (c *Config) ProviderByName(name string) (ProviderConfig, error) { return ProviderConfig{}, errors.New("config: config is nil") } - target := NormalizeProviderName(name) + target := normalizeProviderName(name) for _, provider := range c.Providers { - if NormalizeProviderName(provider.Name) == target { + if normalizeProviderName(provider.Name) == target { return provider, nil } } @@ -285,10 +298,16 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Driver) == "" { return fmt.Errorf("provider %q driver is empty", p.Name) } + if normalizeProviderDriver(p.Driver) == "openai" { + return fmt.Errorf("provider %q driver %q is no longer supported", p.Name, p.Driver) + } if strings.TrimSpace(p.BaseURL) == "" { return fmt.Errorf("provider %q base_url is empty", p.Name) } - if strings.TrimSpace(p.Model) == "" { + if p.Source == ProviderSourceCustom && strings.TrimSpace(p.Model) != "" { + return fmt.Errorf("provider %q custom providers must not define model", p.Name) + } + if p.Source != ProviderSourceCustom && strings.TrimSpace(p.Model) == "" { return fmt.Errorf("provider %q model is empty", p.Name) } if strings.TrimSpace(p.APIKeyEnv) == "" { @@ -300,8 +319,8 @@ func (p ProviderConfig) Validate() error { return nil } -func (p ProviderConfig) Identity() (ProviderIdentity, error) { - return NewProviderIdentity(p.Driver, p.BaseURL) +func (p ProviderConfig) Identity() (provider.ProviderIdentity, error) { + return providerIdentityFromConfig(p) } func (p ProviderConfig) ResolveAPIKey() (string, error) { @@ -689,11 +708,121 @@ func cloneProviders(providers []ProviderConfig) []ProviderConfig { cloned := make([]ProviderConfig, 0, len(providers)) for _, provider := range providers { - cloned = append(cloned, provider) + cloned = append(cloned, cloneProviderConfig(provider)) } return cloned } +// cloneProviderConfig 返回 provider 配置的深拷贝,避免模型元数据等切片在不同快照间共享。 +func cloneProviderConfig(provider ProviderConfig) ProviderConfig { + cloned := provider + cloned.Models = providertypes.CloneModelDescriptors(provider.Models) + return cloned +} + +// mergeProviders 将 builtin defaults 与当前运行时 providers 合并,保留自定义 provider, +// 并用 builtin 定义为同名 provider 补齐缺失字段。 +func mergeProviders(defaults []ProviderConfig, current []ProviderConfig) []ProviderConfig { + if len(defaults) == 0 && len(current) == 0 { + return nil + } + + merged := make([]ProviderConfig, 0, len(defaults)+len(current)) + indexByName := make(map[string]int, len(defaults)+len(current)) + + appendProvider := func(provider ProviderConfig) { + key := normalizeProviderName(provider.Name) + indexByName[key] = len(merged) + merged = append(merged, cloneProviderConfig(provider)) + } + + for _, provider := range defaults { + builtin := cloneProviderConfig(provider) + if builtin.Source == "" { + builtin.Source = ProviderSourceBuiltin + } + appendProvider(builtin) + } + + for _, provider := range current { + candidate := cloneProviderConfig(provider) + key := normalizeProviderName(candidate.Name) + if key == "" { + merged = append(merged, candidate) + continue + } + + if index, exists := indexByName[key]; exists { + if !shouldMergeProviderDefinition(merged[index], candidate) { + merged = append(merged, candidate) + continue + } + merged[index] = mergeProviderConfig(merged[index], candidate) + continue + } + + if candidate.Source == "" { + candidate.Source = ProviderSourceCustom + } + appendProvider(candidate) + } + + return merged +} + +// mergeProviderConfig 将 override 中的显式字段覆盖到 base 之上,用于 builtin 缺省值回填。 +func mergeProviderConfig(base ProviderConfig, override ProviderConfig) ProviderConfig { + merged := cloneProviderConfig(base) + + if strings.TrimSpace(override.Name) != "" { + merged.Name = strings.TrimSpace(override.Name) + } + if strings.TrimSpace(override.Driver) != "" { + merged.Driver = strings.TrimSpace(override.Driver) + } + if strings.TrimSpace(override.BaseURL) != "" { + merged.BaseURL = strings.TrimSpace(override.BaseURL) + } + if strings.TrimSpace(override.Model) != "" { + merged.Model = strings.TrimSpace(override.Model) + } + if strings.TrimSpace(override.APIKeyEnv) != "" { + merged.APIKeyEnv = strings.TrimSpace(override.APIKeyEnv) + } + if strings.TrimSpace(override.APIStyle) != "" { + merged.APIStyle = strings.TrimSpace(override.APIStyle) + } + if strings.TrimSpace(override.DeploymentMode) != "" { + merged.DeploymentMode = strings.TrimSpace(override.DeploymentMode) + } + if strings.TrimSpace(override.APIVersion) != "" { + merged.APIVersion = strings.TrimSpace(override.APIVersion) + } + if len(override.Models) > 0 { + merged.Models = providertypes.CloneModelDescriptors(override.Models) + } + if override.Source != "" { + merged.Source = override.Source + } + + return merged +} + +// shouldMergeProviderDefinition 判断同名 provider 是否允许做 builtin 默认值回填。 +// custom provider 的同名冲突必须保留下来,交给后续校验明确报错。 +func shouldMergeProviderDefinition(existing ProviderConfig, candidate ProviderConfig) bool { + if normalizeProviderName(existing.Name) != normalizeProviderName(candidate.Name) { + return false + } + if existing.Source == ProviderSourceBuiltin { + return candidate.Source != ProviderSourceCustom + } + if candidate.Source == ProviderSourceBuiltin { + return false + } + return false +} + func defaultSelectedProviderName(providers []ProviderConfig, fallback string) string { if containsProviderName(providers, fallback) { return strings.TrimSpace(fallback) @@ -705,12 +834,12 @@ func defaultSelectedProviderName(providers []ProviderConfig, fallback string) st } func containsProviderName(providers []ProviderConfig, name string) bool { - target := NormalizeProviderName(name) + target := normalizeProviderName(name) if target == "" { return false } for _, provider := range providers { - if NormalizeProviderName(provider.Name) == target { + if normalizeProviderName(provider.Name) == target { return true } } diff --git a/internal/config/provider_adapter.go b/internal/config/provider_adapter.go new file mode 100644 index 00000000..b653c348 --- /dev/null +++ b/internal/config/provider_adapter.go @@ -0,0 +1,71 @@ +package config + +import ( + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// normalizeConfigKey 统一规范化 config 层比较使用的字符串键,避免大小写和空白造成分支漂移。 +func normalizeConfigKey(value string) string { + return provider.NormalizeKey(value) +} + +// normalizeProviderName 统一规范化 provider 名称,供 config 层查找、去重与比较逻辑复用。 +func normalizeProviderName(name string) string { + return provider.NormalizeKey(name) +} + +// normalizeProviderDriver 统一规范化 driver 名称,供 config 层校验和配置解析分支复用。 +func normalizeProviderDriver(driver string) string { + return provider.NormalizeProviderDriver(driver) +} + +// providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 +func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { + return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ + Driver: cfg.Driver, + BaseURL: cfg.BaseURL, + APIStyle: cfg.APIStyle, + DeploymentMode: cfg.DeploymentMode, + APIVersion: cfg.APIVersion, + }) +} + +// ToRuntimeConfig 将解析后的 provider 配置收敛为 provider 层使用的最小运行时输入。 +func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { + return provider.RuntimeConfig{ + Name: p.Name, + Driver: p.Driver, + BaseURL: p.BaseURL, + DefaultModel: p.Model, + APIKey: p.APIKey, + APIStyle: p.APIStyle, + DeploymentMode: p.DeploymentMode, + APIVersion: p.APIVersion, + } +} + +// NewProviderCatalogInput 基于 provider 配置构造 catalog 查询输入,避免由 ProviderConfig 直接暴露 provider 契约。 +func NewProviderCatalogInput(cfg ProviderConfig) (provider.CatalogInput, error) { + cloned := cloneProviderConfig(cfg) + identity, err := providerIdentityFromConfig(cloned) + if err != nil { + return provider.CatalogInput{}, err + } + + input := provider.CatalogInput{ + Identity: identity, + ConfiguredModels: providertypes.CloneModelDescriptors(cloned.Models), + ResolveDiscoveryConfig: func() (provider.RuntimeConfig, error) { + resolved, err := cloned.Resolve() + if err != nil { + return provider.RuntimeConfig{}, err + } + return resolved.ToRuntimeConfig(), nil + }, + } + if cloned.Source != ProviderSourceCustom { + input.DefaultModels = providertypes.DescriptorsFromIDs([]string{cloned.Model}) + } + return input, nil +} diff --git a/internal/config/provider_adapter_test.go b/internal/config/provider_adapter_test.go new file mode 100644 index 00000000..705a1981 --- /dev/null +++ b/internal/config/provider_adapter_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "strings" + "testing" + + providerpkg "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + Model: "server-default", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + }, + 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", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + } + + if got != want { + t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) + } +} + +func TestNewProviderCatalogInputBuiltinIncludesDefaultsAndLazyDiscovery(t *testing.T) { + t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") + + cfg := ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + 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 "}, + }, + Source: ProviderSourceBuiltin, + } + + input, err := NewProviderCatalogInput(cfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + + if input.Identity.Driver != "openaicompat" { + t.Fatalf("expected normalized driver, got %+v", input.Identity) + } + if input.Identity.BaseURL != "https://api.example.com/v1" { + t.Fatalf("expected normalized base URL, got %+v", input.Identity) + } + if input.Identity.APIStyle != "responses" { + t.Fatalf("expected normalized api_style, got %+v", input.Identity) + } + if len(input.DefaultModels) != 1 || input.DefaultModels[0].ID != "server-default" { + t.Fatalf("expected builtin default model, got %+v", input.DefaultModels) + } + if len(input.ConfiguredModels) != 1 || input.ConfiguredModels[0].ID != "model-a" { + t.Fatalf("expected configured models to be normalized, got %+v", input.ConfiguredModels) + } + + cfg.Models[0].ID = "mutated" + if input.ConfiguredModels[0].ID != "model-a" { + t.Fatalf("expected configured models to be cloned, got %+v", input.ConfiguredModels) + } + + runtimeConfig, err := input.ResolveDiscoveryConfig() + if err != nil { + t.Fatalf("ResolveDiscoveryConfig() error = %v", err) + } + if runtimeConfig.DefaultModel != "server-default" || runtimeConfig.APIKey != "secret-key" { + t.Fatalf("expected runtime config to resolve model and api key, got %+v", runtimeConfig) + } + if runtimeConfig.APIStyle != " Responses " { + t.Fatalf("expected runtime config to preserve configured api_style, got %+v", runtimeConfig) + } +} + +func TestNewProviderCatalogInputCustomOmitsDefaultModels(t *testing.T) { + t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") + + input, err := NewProviderCatalogInput(ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "CATALOG_PROVIDER_API_KEY", + Source: ProviderSourceCustom, + }) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + if input.DefaultModels != nil { + t.Fatalf("expected custom provider to omit default models, got %+v", input.DefaultModels) + } +} + +func TestNewProviderCatalogInputPropagatesIdentityErrors(t *testing.T) { + t.Parallel() + + _, err := NewProviderCatalogInput(ProviderConfig{ + Name: "broken-provider", + Driver: "openaicompat", + BaseURL: " ", + APIKeyEnv: "CATALOG_PROVIDER_API_KEY", + Source: ProviderSourceBuiltin, + }) + if err == nil || !strings.Contains(err.Error(), "base_url is empty") { + t.Fatalf("expected base_url validation error, got %v", err) + } +} diff --git a/internal/config/provider_identity.go b/internal/config/provider_identity.go deleted file mode 100644 index 93f01207..00000000 --- a/internal/config/provider_identity.go +++ /dev/null @@ -1,76 +0,0 @@ -package config - -import ( - "fmt" - "net/url" - "path" - "strings" -) - -// ProviderIdentity identifies a concrete provider endpoint for discovery and caching. -type ProviderIdentity struct { - Driver string `json:"driver"` - BaseURL string `json:"base_url"` -} - -func (i ProviderIdentity) Key() string { - return i.Driver + "|" + i.BaseURL -} - -func NormalizeKey(s string) string { - return strings.ToLower(strings.TrimSpace(s)) -} - -func NormalizeProviderName(name string) string { - return NormalizeKey(name) -} - -func NormalizeProviderDriver(driver string) string { - return NormalizeKey(driver) -} - -func NormalizeProviderBaseURL(raw string) (string, error) { - value := strings.TrimSpace(raw) - if value == "" { - return "", fmt.Errorf("provider base_url is empty") - } - - parsed, err := url.Parse(value) - if err != nil { - return "", fmt.Errorf("provider base_url %q is invalid: %w", raw, err) - } - if parsed.Scheme == "" || parsed.Host == "" { - return "", fmt.Errorf("provider base_url %q must include scheme and host", raw) - } - - parsed.Scheme = strings.ToLower(parsed.Scheme) - parsed.Host = strings.ToLower(parsed.Host) - parsed.RawQuery = "" - parsed.Fragment = "" - parsed.User = nil - - if cleaned := path.Clean(strings.TrimSpace(parsed.Path)); cleaned == "." { - parsed.Path = "" - } else { - parsed.Path = strings.TrimRight(cleaned, "/") - } - - return parsed.String(), nil -} - -func NewProviderIdentity(driver string, baseURL string) (ProviderIdentity, error) { - normalizedDriver := NormalizeProviderDriver(driver) - if normalizedDriver == "" { - return ProviderIdentity{}, fmt.Errorf("provider driver is empty") - } - - normalizedBaseURL, err := NormalizeProviderBaseURL(baseURL) - if err != nil { - return ProviderIdentity{}, err - } - - return ProviderIdentity{ - Driver: normalizedDriver, - BaseURL: normalizedBaseURL, - }, nil -} diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go new file mode 100644 index 00000000..75d6da18 --- /dev/null +++ b/internal/config/provider_loader.go @@ -0,0 +1,173 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + providersDirName = "providers" + customProviderConfigName = "provider.yaml" + defaultOpenAICompatibleAPIStyle = "chat_completions" +) + +type customProviderFile struct { + Name string `yaml:"name"` + Driver string `yaml:"driver"` + DefaultModel string `yaml:"default_model"` + APIKeyEnv string `yaml:"api_key_env"` + BaseURL string `yaml:"base_url,omitempty"` + OpenAICompatible customOpenAICompatibleFile `yaml:"openai_compatible,omitempty"` + Gemini customGeminiProviderFile `yaml:"gemini,omitempty"` + Anthropic customAnthropicProviderFile `yaml:"anthropic,omitempty"` +} + +type customOpenAICompatibleFile struct { + Profile string `yaml:"profile,omitempty"` + BaseURL string `yaml:"base_url"` + APIStyle string `yaml:"api_style,omitempty"` +} + +type customGeminiProviderFile struct { + BaseURL string `yaml:"base_url,omitempty"` + DeploymentMode string `yaml:"deployment_mode,omitempty"` +} + +type customAnthropicProviderFile struct { + BaseURL string `yaml:"base_url,omitempty"` + APIVersion string `yaml:"api_version,omitempty"` +} + +type customProviderSettings struct { + BaseURL string + APIStyle string + DeploymentMode string + APIVersion string +} + +// loadCustomProviders 扫描 baseDir/providers 下的一级子目录,并将其中的 provider.yaml 解析为运行时配置。 +func loadCustomProviders(baseDir string) ([]ProviderConfig, error) { + providersDir := filepath.Join(strings.TrimSpace(baseDir), providersDirName) + entries, err := os.ReadDir(providersDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("config: read providers dir: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name()) + }) + + providers := make([]ProviderConfig, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + providerDir := filepath.Join(providersDir, entry.Name()) + providerCfg, err := loadCustomProvider(providerDir) + if err != nil { + return nil, err + } + providers = append(providers, providerCfg) + } + + return providers, nil +} + +// loadCustomProvider 读取单个 provider 目录,并将 provider.yaml 转为 ProviderConfig。 +func loadCustomProvider(providerDir string) (ProviderConfig, error) { + providerPath := filepath.Join(providerDir, customProviderConfigName) + data, err := os.ReadFile(providerPath) + if err != nil { + if os.IsNotExist(err) { + return ProviderConfig{}, fmt.Errorf("config: custom provider %q missing %s", filepath.Base(providerDir), customProviderConfigName) + } + return ProviderConfig{}, fmt.Errorf("config: read %s: %w", providerPath, err) + } + + var file customProviderFile + if err := yaml.Unmarshal(data, &file); err != nil { + return ProviderConfig{}, fmt.Errorf("config: parse %s: %w", providerPath, err) + } + if strings.TrimSpace(file.DefaultModel) != "" { + return ProviderConfig{}, fmt.Errorf( + "config: custom provider %q does not support default_model; models must come from remote discovery", + filepath.Base(providerDir), + ) + } + + settings := resolveCustomProviderSettings(file) + cfg := ProviderConfig{ + Name: strings.TrimSpace(file.Name), + Driver: strings.TrimSpace(file.Driver), + BaseURL: settings.BaseURL, + APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), + APIStyle: settings.APIStyle, + DeploymentMode: settings.DeploymentMode, + APIVersion: settings.APIVersion, + Source: ProviderSourceCustom, + } + + if normalizeProviderDriver(cfg.Driver) == "openaicompat" && strings.TrimSpace(cfg.APIStyle) == "" { + cfg.APIStyle = defaultOpenAICompatibleAPIStyle + } + + if err := cfg.Validate(); err != nil { + return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) + } + + return cfg, nil +} + +// resolveCustomProviderSettings 根据 driver 只提取当前协议真正生效的配置字段,避免误吃其他协议块的值。 +func resolveCustomProviderSettings(file customProviderFile) customProviderSettings { + settings := customProviderSettings{ + BaseURL: strings.TrimSpace(file.BaseURL), + } + + switch normalizeProviderDriver(file.Driver) { + case "openaicompat": + if settings.BaseURL == "" { + settings.BaseURL = strings.TrimSpace(file.OpenAICompatible.BaseURL) + } + settings.APIStyle = strings.TrimSpace(file.OpenAICompatible.APIStyle) + case "gemini": + if settings.BaseURL == "" { + settings.BaseURL = strings.TrimSpace(file.Gemini.BaseURL) + } + settings.DeploymentMode = strings.TrimSpace(file.Gemini.DeploymentMode) + case "anthropic": + if settings.BaseURL == "" { + settings.BaseURL = strings.TrimSpace(file.Anthropic.BaseURL) + } + settings.APIVersion = strings.TrimSpace(file.Anthropic.APIVersion) + default: + if settings.BaseURL == "" { + settings.BaseURL = resolveFallbackCustomProviderBaseURL(file) + } + } + + return settings +} + +// resolveFallbackCustomProviderBaseURL 为未知 driver 保留兼容兜底顺序,避免切断既有自定义接入配置。 +func resolveFallbackCustomProviderBaseURL(file customProviderFile) string { + for _, value := range []string{ + file.OpenAICompatible.BaseURL, + file.Gemini.BaseURL, + file.Anthropic.BaseURL, + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/config/selection.go b/internal/config/selection.go index 54963402..f6885980 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -3,34 +3,26 @@ package config import ( "context" "errors" + "fmt" "strings" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // Selection 领域错误。 var ( ErrProviderNotFound = errors.New("provider not found") ErrModelNotFound = errors.New("model not found") + ErrNoModelsAvailable = errors.New("provider has no available models") ErrDriverUnsupported = errors.New("provider driver not supported by current runtime") ) -// ---- 数据类型定义 ---- - -// ModelDescriptor 表示模型元数据描述符。 -type ModelDescriptor struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ContextWindow int `json:"context_window,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - Capabilities map[string]bool `json:"capabilities,omitempty"` -} - -// ProviderCatalogItem 表示一个已配置的 provider 及其可用模型列表(用于 UI 展示)。 +// ProviderCatalogItem 表示一个已配置的 provider 及其可用模型列表,用于 UI 展示。 type ProviderCatalogItem struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Models []ModelDescriptor `json:"models,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Models []providertypes.ModelDescriptor `json:"models,omitempty"` } // ProviderSelection 表示当前选中的 provider 和 model。 @@ -39,130 +31,35 @@ type ProviderSelection struct { ModelID string `json:"model_id"` } -// ---- 接口定义(避免循环依赖)---- - -// DriverSupporter 检查给定驱动类型是否被支持。 +// DriverSupporter 用于检查给定 driver 是否被当前运行时支持。 type DriverSupporter interface { Supports(driverType string) bool } -// ModelDiscovery 定义模型发现能力,由 provider.Registry 提供。 -type ModelDiscovery interface { - DiscoverModels(ctx context.Context, cfg ResolvedProviderConfig) ([]ModelDescriptor, error) -} - -// ---- 模型描述符工具函数 ---- - -// DescriptorFromRawModel 将原始 provider 模型对象标准化为 ModelDescriptor。 -func DescriptorFromRawModel(raw map[string]any) (ModelDescriptor, bool) { - id := firstNonEmptyString( - stringValue(raw["id"]), - stringValue(raw["model"]), - stringValue(raw["name"]), - ) - if id == "" { - return ModelDescriptor{}, false - } - - descriptor := ModelDescriptor{ - ID: id, - Name: firstNonEmptyString(stringValue(raw["name"]), stringValue(raw["display_name"]), id), - Description: stringValue(raw["description"]), - ContextWindow: firstPositiveInt(raw["context_window"], raw["contextLength"], raw["input_token_limit"], raw["max_context_tokens"]), - MaxOutputTokens: firstPositiveInt(raw["max_output_tokens"], raw["output_token_limit"], raw["max_tokens"]), - Capabilities: boolMapValue(raw["capabilities"]), - } - return normalizeModelDescriptor(descriptor), true -} - -// MergeModelDescriptors 合并多个 ModelDescriptor 来源,按 ID 去重, -// 优先保留较早来源的字段值,后续来源用于回填空字段。 -func MergeModelDescriptors(sources ...[]ModelDescriptor) []ModelDescriptor { - if len(sources) == 0 { - return nil - } - - merged := make([]ModelDescriptor, 0) - indexByID := make(map[string]int) - - for _, source := range sources { - for _, candidate := range source { - normalized := normalizeModelDescriptor(candidate) - key := NormalizeKey(normalized.ID) - if key == "" { - continue - } - - if index, exists := indexByID[key]; exists { - merged[index] = mergeModelDescriptor(merged[index], normalized) - continue - } - - indexByID[key] = len(merged) - merged = append(merged, normalized) - } - } - - if len(merged) == 0 { - return nil - } - return merged -} - -// DescriptorsFromIDs 从模型 ID 字符串列表构建最小化的 ModelDescriptor 列表。 -func DescriptorsFromIDs(modelIDs []string) []ModelDescriptor { - if len(modelIDs) == 0 { - return nil - } - - descriptors := make([]ModelDescriptor, 0, len(modelIDs)) - for _, modelID := range modelIDs { - id := strings.TrimSpace(modelID) - if id == "" { - continue - } - descriptors = append(descriptors, ModelDescriptor{ - ID: id, - Name: id, - }) - } - if len(descriptors) == 0 { - return nil - } - return descriptors -} - -// ---- 选择服务 ---- - -// ModelCatalog 定义模型目录查询接口,用于获取 provider 可用模型列表。 +// ModelCatalog 定义模型目录查询接口,用于获取 provider 的可用模型列表。 type ModelCatalog interface { - ListProviderModels(ctx context.Context, providerCfg ProviderConfig) ([]ModelDescriptor, error) - ListProviderModelsSnapshot(ctx context.Context, providerCfg ProviderConfig) ([]ModelDescriptor, error) - ListProviderModelsCached(ctx context.Context, providerCfg ProviderConfig) ([]ModelDescriptor, error) + ListProviderModels(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) + ListProviderModelsSnapshot(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) + ListProviderModelsCached(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) } -// SelectionService 管理 provider/模型选择状态,负责切换当前 provider、切换当前模型、 -// 校验和修复无效的 selection 等。所有变更通过 ConfigManager 持久化到配置中。 -// -// 通过 DriverSupporter 和 ModelDiscovery 接口注入 provider 能力,避免 config -> provider 循环依赖。 +// SelectionService 管理 provider 和模型选择状态,并通过 ConfigManager 持久化变更。 type SelectionService struct { manager *Manager supporters DriverSupporter - discovery ModelDiscovery catalogs ModelCatalog } // NewSelectionService 创建选择服务实例。 -func NewSelectionService(manager *Manager, supporters DriverSupporter, discovery ModelDiscovery, catalogs ModelCatalog) *SelectionService { +func NewSelectionService(manager *Manager, supporters DriverSupporter, catalogs ModelCatalog) *SelectionService { return &SelectionService{ manager: manager, supporters: supporters, - discovery: discovery, catalogs: catalogs, } } -// ListProviders 枚举所有已配置且驱动支持的 provider 及其可用模型。 +// ListProviders 枚举所有已配置且当前运行时支持的 provider 及其缓存模型列表。 func (s *SelectionService) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { if err := s.validate(); err != nil { return nil, err @@ -181,7 +78,11 @@ func (s *SelectionService) ListProviders(ctx context.Context) ([]ProviderCatalog continue } - models, err := s.catalogs.ListProviderModelsCached(ctx, providerCfg) + input, err := catalogInputFromProvider(providerCfg) + if err != nil { + return nil, err + } + models, err := s.catalogs.ListProviderModelsCached(ctx, input) if err != nil { return nil, err } @@ -191,7 +92,7 @@ func (s *SelectionService) ListProviders(ctx context.Context) ([]ProviderCatalog return items, nil } -// SelectProvider 切换当前选中 provider,同时将 current_model 解析为有效值。 +// SelectProvider 切换当前 provider,并将 current_model 修正为该 provider 下的有效模型。 func (s *SelectionService) SelectProvider(ctx context.Context, providerName string) (ProviderSelection, error) { if err := s.validate(); err != nil { return ProviderSelection{}, err @@ -206,10 +107,17 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri return ProviderSelection{}, ErrDriverUnsupported } - models, err := s.catalogs.ListProviderModelsCached(ctx, providerCfg) + input, err := catalogInputFromProvider(providerCfg) if err != nil { return ProviderSelection{}, err } + models, err := s.catalogs.ListProviderModels(ctx, input) + if err != nil { + return ProviderSelection{}, err + } + if len(models) == 0 { + return ProviderSelection{}, ErrNoModelsAvailable + } var selection ProviderSelection err = s.manager.Update(ctx, func(cfg *Config) error { @@ -234,8 +142,8 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri return selection, nil } -// ListModels 获取当前选中 provider 的模型列表(可触发远程刷新)。 -func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, error) { +// ListModels 获取当前选中 provider 的模型列表,必要时会同步触发远程发现。 +func (s *SelectionService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -247,11 +155,18 @@ func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, e if err != nil { return nil, err } - return s.catalogs.ListProviderModels(ctx, selected) + if !s.supporters.Supports(selected.Driver) { + return nil, ErrDriverUnsupported + } + input, err := catalogInputFromProvider(selected) + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModels(ctx, input) } -// ListModelsSnapshot 获取当前选中 provider 的缓存模型列表。 -func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]ModelDescriptor, error) { +// ListModelsSnapshot 获取当前选中 provider 的快照模型列表,不阻塞等待同步发现。 +func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -263,10 +178,17 @@ func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]ModelDescr if err != nil { return nil, err } - return s.catalogs.ListProviderModelsSnapshot(ctx, selected) + if !s.supporters.Supports(selected.Driver) { + return nil, ErrDriverUnsupported + } + input, err := catalogInputFromProvider(selected) + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModelsSnapshot(ctx, input) } -// SetCurrentModel 切换当前使用的模型(必须在当前 provider 的可用模型列表中)。 +// SetCurrentModel 切换当前模型,目标模型必须出现在当前 provider 的可用模型列表中。 func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { if err := s.validate(); err != nil { return ProviderSelection{}, err @@ -282,11 +204,31 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) return ProviderSelection{}, err } - models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + input, err := catalogInputFromProvider(selected) if err != nil { return ProviderSelection{}, err } + models, err := s.catalogs.ListProviderModels(ctx, input) + if err != nil { + return ProviderSelection{}, err + } + if len(models) == 0 { + return ProviderSelection{}, ErrNoModelsAvailable + } if !containsModelDescriptorID(models, modelID) { + fallbackID := strings.TrimSpace(selected.Model) + if fallbackID != "" && containsModelDescriptorID(models, fallbackID) { + selection, fallbackErr := s.fallbackToProviderDefaultModel(ctx, selected, fallbackID) + if fallbackErr != nil { + return ProviderSelection{}, fallbackErr + } + return selection, fmt.Errorf( + "selection: model %q not found; fell back to default model %q: %w", + modelID, + fallbackID, + ErrModelNotFound, + ) + } return ProviderSelection{}, ErrModelNotFound } @@ -306,7 +248,33 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) return selection, nil } -// EnsureSelection 确保当前 selection 有效:如果 current_model 不在可用模型列表中则自动修正。 +// fallbackToProviderDefaultModel 在切换模型失败时回退到当前 provider 的默认模型,并持久化修正后的选择状态。 +func (s *SelectionService) fallbackToProviderDefaultModel(ctx context.Context, selected ProviderConfig, modelID string) (ProviderSelection, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return ProviderSelection{}, ErrModelNotFound + } + + var selection ProviderSelection + err := s.manager.Update(ctx, func(cfg *Config) error { + currentSelected, err := cfg.SelectedProviderConfig() + if err != nil { + return err + } + if normalizeProviderName(currentSelected.Name) != normalizeProviderName(selected.Name) { + return ErrProviderNotFound + } + cfg.CurrentModel = modelID + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return ProviderSelection{}, err + } + return selection, nil +} + +// EnsureSelection 确保当前 provider 和 model 仍然有效,必要时自动修正。 func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelection, error) { if err := s.validate(); err != nil { return ProviderSelection{}, err @@ -320,22 +288,41 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti if err != nil { return ProviderSelection{}, err } + if !s.supporters.Supports(selected.Driver) { + return ProviderSelection{}, fmt.Errorf( + "selection: provider %q driver %q: %w", + selected.Name, + selected.Driver, + ErrDriverUnsupported, + ) + } - models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + input, err := catalogInputFromProvider(selected) if err != nil { return ProviderSelection{}, err } - nextModel, changed := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) - if !changed { + models, err := s.catalogs.ListProviderModels(ctx, input) + if err != nil { + return ProviderSelection{}, err + } + if len(models) == 0 { + return ProviderSelection{}, ErrNoModelsAvailable + } + _, modelChanged := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) + if !modelChanged { return selectionFromConfig(cfgSnapshot), nil } var selection ProviderSelection err = s.manager.Update(ctx, func(cfg *Config) error { - if _, err := cfg.SelectedProviderConfig(); err != nil { + currentSelected, err := cfg.SelectedProviderConfig() + if err != nil { return err } - cfg.CurrentModel = nextModel + if normalizeProviderName(currentSelected.Name) != normalizeProviderName(selected.Name) { + return ErrProviderNotFound + } + cfg.CurrentModel, _ = resolveCurrentModel(cfg.CurrentModel, models, currentSelected.Model) selection = selectionFromConfig(*cfg) return nil }) @@ -367,8 +354,6 @@ func (s *SelectionService) selectedProviderConfig() (ProviderConfig, error) { return cfg.SelectedProviderConfig() } -// ---- 内部辅助函数 ---- - func selectionFromConfig(cfg Config) ProviderSelection { return ProviderSelection{ ProviderID: cfg.SelectedProvider, @@ -376,7 +361,7 @@ func selectionFromConfig(cfg Config) ProviderSelection { } } -func resolveCurrentModel(currentModel string, models []ModelDescriptor, fallback string) (string, bool) { +func resolveCurrentModel(currentModel string, models []providertypes.ModelDescriptor, fallback string) (string, bool) { currentModel = strings.TrimSpace(currentModel) if containsModelDescriptorID(models, currentModel) { return currentModel, false @@ -387,152 +372,39 @@ func resolveCurrentModel(currentModel string, models []ModelDescriptor, fallback return fallback, currentModel != fallback } + for _, model := range models { + id := strings.TrimSpace(model.ID) + if id != "" { + return id, currentModel != id + } + } + return currentModel, false } -func providerCatalogItem(cfg ProviderConfig, models []ModelDescriptor) ProviderCatalogItem { +func providerCatalogItem(cfg ProviderConfig, models []providertypes.ModelDescriptor) ProviderCatalogItem { return ProviderCatalogItem{ ID: strings.TrimSpace(cfg.Name), Name: strings.TrimSpace(cfg.Name), - Models: MergeModelDescriptors(models), + Models: providertypes.MergeModelDescriptors(models), } } -func containsModelDescriptorID(models []ModelDescriptor, modelID string) bool { - target := NormalizeKey(modelID) +// catalogInputFromProvider 将配置层 provider 定义转换为 catalog 查询输入,统一收敛适配错误处理。 +func catalogInputFromProvider(cfg ProviderConfig) (provider.CatalogInput, error) { + return NewProviderCatalogInput(cfg) +} + +func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { + target := normalizeConfigKey(modelID) if target == "" { return false } for _, model := range models { - if NormalizeKey(model.ID) == target { + if normalizeConfigKey(model.ID) == target { return true } } return false } - -func normalizeModelDescriptor(descriptor ModelDescriptor) ModelDescriptor { - descriptor.ID = strings.TrimSpace(descriptor.ID) - descriptor.Name = strings.TrimSpace(descriptor.Name) - descriptor.Description = strings.TrimSpace(descriptor.Description) - if descriptor.Name == "" { - descriptor.Name = descriptor.ID - } - descriptor.Capabilities = cloneStringBoolMap(descriptor.Capabilities) - return descriptor -} - -func mergeModelDescriptor(primary ModelDescriptor, secondary ModelDescriptor) ModelDescriptor { - if strings.TrimSpace(primary.Name) == "" { - primary.Name = secondary.Name - } - if strings.TrimSpace(primary.Description) == "" { - primary.Description = secondary.Description - } - if primary.ContextWindow <= 0 { - primary.ContextWindow = secondary.ContextWindow - } - if primary.MaxOutputTokens <= 0 { - primary.MaxOutputTokens = secondary.MaxOutputTokens - } - primary.Capabilities = mergeStringBoolMaps(primary.Capabilities, secondary.Capabilities) - return normalizeModelDescriptor(primary) -} - -func mergeStringBoolMaps(primary map[string]bool, secondary map[string]bool) map[string]bool { - if len(primary) == 0 && len(secondary) == 0 { - return nil - } - - merged := cloneStringBoolMap(primary) - if merged == nil { - merged = make(map[string]bool, len(secondary)) - } - for key, value := range secondary { - if _, exists := merged[key]; exists { - continue - } - merged[key] = value - } - return merged -} - -func cloneStringBoolMap(source map[string]bool) map[string]bool { - if len(source) == 0 { - return nil - } - cloned := make(map[string]bool, len(source)) - for key, value := range source { - cloned[key] = value - } - return cloned -} - -// ---- 通用工具函数 ---- - -func boolMapValue(value any) map[string]bool { - raw, ok := value.(map[string]any) - if !ok || len(raw) == 0 { - return nil - } - - normalized := make(map[string]bool, len(raw)) - for key, value := range raw { - boolValue, ok := value.(bool) - if !ok { - continue - } - normalized[strings.TrimSpace(key)] = boolValue - } - if len(normalized) == 0 { - return nil - } - return normalized -} - -func stringValue(value any) string { - switch typed := value.(type) { - case string: - return strings.TrimSpace(typed) - default: - return "" - } -} - -func firstNonEmptyString(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func firstPositiveInt(values ...any) int { - for _, value := range values { - switch typed := value.(type) { - case int: - if typed > 0 { - return typed - } - case int32: - if typed > 0 { - return int(typed) - } - case int64: - if typed > 0 { - return int(typed) - } - case float32: - if typed > 0 { - return int(typed) - } - case float64: - if typed > 0 { - return int(typed) - } - } - } - return 0 -} diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index 4dd40946..4c078de6 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -5,13 +5,16 @@ import ( "errors" "strings" "testing" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) -func TestSelectionServiceListProvidersFallsBackToProviderDefaultModel(t *testing.T) { +func TestSelectionServiceListProvidersUsesCatalogModels(t *testing.T) { t.Parallel() manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), nil, newCatalogStub()) + service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) items, err := service.ListProviders(context.Background()) if err != nil { @@ -42,6 +45,82 @@ func TestSelectionServiceListProvidersFallsBackToProviderDefaultModel(t *testing } } +func TestSelectionServiceListModelsUsesCurrentSelectedProvider(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{ + {ID: "server-coder", Name: "Server Coder"}, + }, + }) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != "server-coder" { + t.Fatalf("expected selected provider models, got %+v", models) + } +} + +func TestSelectionServiceListModelsSnapshotUsesSnapshotCatalog(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, DefaultConfig()) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{ + {ID: "sync-model", Name: "Sync Model"}, + }, + snapshotModels: []providertypes.ModelDescriptor{ + {ID: "snapshot-model", Name: "Snapshot Model"}, + }, + }) + + models, err := service.ListModelsSnapshot(context.Background()) + if err != nil { + t.Fatalf("ListModelsSnapshot() error = %v", err) + } + if len(models) != 1 || models[0].ID != "snapshot-model" { + t.Fatalf("expected snapshot models, got %+v", models) + } +} + +func TestSelectionServiceListModelsSnapshotRejectsUnsupportedDriver(t *testing.T) { + t.Parallel() + + defaults := DefaultConfig() + defaults.Providers = []ProviderConfig{{ + Name: OpenAIName, + Driver: "missing-driver", + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, + }} + defaults.SelectedProvider = OpenAIName + defaults.CurrentModel = OpenAIDefaultModel + + manager := newSelectionTestManager(t, defaults) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + service := NewSelectionService(manager, supporters, newCatalogStub()) + + _, err := service.ListModelsSnapshot(context.Background()) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } +} + func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { manager := newSelectionTestManager(t, DefaultConfig()) @@ -52,7 +131,7 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { t.Fatalf("seed current model: %v", err) } - service := NewSelectionService(manager, newDriverSupporterStub(), nil, newCatalogStub()) + service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) selection, err := service.SelectProvider(context.Background(), QiniuName) if err != nil { @@ -62,10 +141,6 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { t.Fatalf("unexpected selection after switch: %+v", selection) } - if _, err := service.SetCurrentModel(context.Background(), "missing-model"); !errors.Is(err, ErrModelNotFound) { - t.Fatalf("expected ErrModelNotFound, got %v", err) - } - selection, err = service.SetCurrentModel(context.Background(), QiniuDefaultModel+"-alt") if err != nil { t.Fatalf("SetCurrentModel() error = %v", err) @@ -74,6 +149,14 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { t.Fatalf("expected selected model %q, got %+v", QiniuDefaultModel+"-alt", selection) } + selection, err = service.SetCurrentModel(context.Background(), "missing-model") + if !errors.Is(err, ErrModelNotFound) { + t.Fatalf("expected ErrModelNotFound, got %v", err) + } + if selection.ModelID != QiniuDefaultModel { + t.Fatalf("expected fallback selection to default model %q, got %+v", QiniuDefaultModel, selection) + } + cfg := manager.Get() selected, err := cfg.SelectedProviderConfig() if err != nil { @@ -82,8 +165,28 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { if selected.Name != QiniuName { t.Fatalf("expected selected provider %q, got %+v", QiniuName, selected) } - if selected.Model != QiniuDefaultModel || cfg.CurrentModel != QiniuDefaultModel+"-alt" { - t.Fatalf("expected provider default and current model to diverge safely, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + if selected.Model != QiniuDefaultModel || cfg.CurrentModel != QiniuDefaultModel { + t.Fatalf("expected failed switch to fall back to provider default, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + } +} + +func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, DefaultConfig()) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{ + {ID: QiniuDefaultModel, Name: QiniuDefaultModel}, + {ID: QiniuDefaultModel + "-alt", Name: QiniuDefaultModel + "-alt"}, + }, + }) + + selection, err := service.SelectProvider(context.Background(), QiniuName) + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != QiniuName || selection.ModelID != QiniuDefaultModel { + t.Fatalf("expected sync discovery-backed selection, got %+v", selection) } } @@ -98,7 +201,7 @@ func TestSelectionServiceEnsureSelectionRepairsInvalidCurrentModel(t *testing.T) t.Fatalf("seed invalid current model: %v", err) } - service := NewSelectionService(manager, newDriverSupporterStub(), nil, newCatalogStub()) + service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) selection, err := service.EnsureSelection(context.Background()) if err != nil { @@ -117,6 +220,113 @@ func TestSelectionServiceEnsureSelectionRepairsInvalidCurrentModel(t *testing.T) } } +func TestSelectionServiceEnsureSelectionRejectsUnsupportedSelectedProvider(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "anthropic", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + defaults.CurrentModel = "deepseek-coder" + + manager := newSelectionTestManager(t, defaults) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + service := NewSelectionService(manager, supporters, newCatalogStub()) + + _, err := service.EnsureSelection(context.Background()) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } + + cfg := manager.Get() + if cfg.SelectedProvider != "company-gateway" { + t.Fatalf("expected selected provider to stay on company-gateway, got %q", cfg.SelectedProvider) + } + if cfg.CurrentModel != "deepseek-coder" { + t.Fatalf("expected current model to stay unchanged, got %q", cfg.CurrentModel) + } +} + +func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + defaults.CurrentModel = "unknown-model" + + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{ + {ID: "server-coder", Name: "Server Coder"}, + {ID: "server-chat", Name: "Server Chat"}, + }, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "server-coder" { + t.Fatalf("expected first discovered model fallback, got %+v", selection) + } +} + +func TestSelectionServiceEnsureSelectionRejectsEmptyDiscoveredModelList(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.CurrentModel = "unsupported-current" + + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{}) + + _, err := service.EnsureSelection(context.Background()) + if !errors.Is(err, ErrNoModelsAvailable) { + t.Fatalf("expected ErrNoModelsAvailable, got %v", err) + } +} + +func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t *testing.T) { + t.Parallel() + + defaults := DefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), errorCatalogStub{err: errors.New("discover failed")}) + + _, err := service.SelectProvider(context.Background(), "company-gateway") + if err == nil || !strings.Contains(err.Error(), "discover failed") { + t.Fatalf("expected discovery failure, got %v", err) + } + + cfg := manager.Get() + if cfg.SelectedProvider != OpenAIName { + t.Fatalf("expected selected provider to stay on %q, got %q", OpenAIName, cfg.SelectedProvider) + } + if cfg.CurrentModel != OpenAIDefaultModel { + t.Fatalf("expected current model to stay on %q, got %q", OpenAIDefaultModel, cfg.CurrentModel) + } +} + func TestSelectionServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { t.Parallel() @@ -127,13 +337,14 @@ func TestSelectionServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, }} defaults.SelectedProvider = OpenAIName defaults.CurrentModel = OpenAIDefaultModel manager := newSelectionTestManager(t, defaults) - supporters := &selectiveDriverSupporter{supported: map[string]bool{"openai": true}} - service := NewSelectionService(manager, supporters, nil, newCatalogStub()) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + service := NewSelectionService(manager, supporters, newCatalogStub()) if _, err := service.SelectProvider(context.Background(), OpenAIName); !errors.Is(err, ErrDriverUnsupported) { t.Fatalf("expected SelectProvider() to preserve driver error, got %v", err) @@ -198,7 +409,7 @@ func TestSelectionServiceOperationsWithCanceledContext(t *testing.T) { t.Parallel() manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), nil, newCatalogStub()) + service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -256,7 +467,7 @@ func TestSelectionServiceSetCurrentModelEmptyModelID(t *testing.T) { t.Parallel() manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), nil, newCatalogStub()) + service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) _, err := service.SetCurrentModel(context.Background(), "") if !errors.Is(err, ErrModelNotFound) { @@ -275,7 +486,7 @@ func TestResolveCurrentModelHelper(t *testing.T) { tests := []struct { name string currentModel string - models []ModelDescriptor + models []providertypes.ModelDescriptor fallback string expected string changed bool @@ -283,7 +494,7 @@ func TestResolveCurrentModelHelper(t *testing.T) { { name: "current model in list", currentModel: "gpt-4o", - models: []ModelDescriptor{ + models: []providertypes.ModelDescriptor{ {ID: "gpt-4.1"}, {ID: "gpt-4o"}, {ID: "gpt-5.4"}, @@ -295,7 +506,7 @@ func TestResolveCurrentModelHelper(t *testing.T) { { name: "current model falls back to provider default", currentModel: "unknown-model", - models: []ModelDescriptor{ + models: []providertypes.ModelDescriptor{ {ID: "gpt-4.1"}, {ID: "gpt-4o"}, {ID: "gpt-5.4"}, @@ -305,14 +516,14 @@ func TestResolveCurrentModelHelper(t *testing.T) { changed: true, }, { - name: "missing fallback keeps current model unchanged", + name: "missing fallback uses first discovered model", currentModel: "unknown-model", - models: []ModelDescriptor{ + models: []providertypes.ModelDescriptor{ {ID: "gpt-4o"}, }, fallback: "gpt-4.1", - expected: "unknown-model", - changed: false, + expected: "gpt-4o", + changed: true, }, } @@ -339,30 +550,68 @@ type selectiveDriverSupporter struct { } func (s *selectiveDriverSupporter) Supports(driverType string) bool { - return s.supported[NormalizeKey(driverType)] + return s.supported[provider.NormalizeKey(driverType)] } type catalogStub struct{} -func (catalogStub) ListProviderModels(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { - return defaultModelsForProvider(cfg), nil +func (catalogStub) ListProviderModels(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return defaultModelsForInput(input), nil +} + +func (catalogStub) ListProviderModelsSnapshot(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return defaultModelsForInput(input), nil } -func (catalogStub) ListProviderModelsSnapshot(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { - return defaultModelsForProvider(cfg), nil +func (catalogStub) ListProviderModelsCached(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return defaultModelsForInput(input), nil } -func (catalogStub) ListProviderModelsCached(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { - return defaultModelsForProvider(cfg), nil +type catalogMethodsStub struct { + listModels []providertypes.ModelDescriptor + snapshotModels []providertypes.ModelDescriptor + cachedModels []providertypes.ModelDescriptor } -// defaultModelsForProvider 为给定 provider 返回包含默认模型和额外测试模型的列表。 -func defaultModelsForProvider(cfg ProviderConfig) []ModelDescriptor { - model := strings.TrimSpace(cfg.Model) +func (s catalogMethodsStub) ListProviderModels(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.listModels), nil +} + +func (s catalogMethodsStub) ListProviderModelsSnapshot(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.snapshotModels), nil +} + +func (s catalogMethodsStub) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.cachedModels), nil +} + +type errorCatalogStub struct { + err error +} + +func (s errorCatalogStub) ListProviderModels(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorCatalogStub) ListProviderModelsSnapshot(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorCatalogStub) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, s.err +} + +// defaultModelsForInput 为给定 catalog 输入返回默认模型及其变体,便于选择逻辑测试复用。 +func defaultModelsForInput(input provider.CatalogInput) []providertypes.ModelDescriptor { + defaults := providertypes.MergeModelDescriptors(input.DefaultModels) + if len(defaults) == 0 { + return nil + } + model := strings.TrimSpace(defaults[0].ID) if model == "" { return nil } - return []ModelDescriptor{ + return []providertypes.ModelDescriptor{ {ID: model, Name: model}, {ID: model + "-alt", Name: model + "-alt"}, } diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 33ab5b5e..a53d9830 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -4,7 +4,7 @@ import ( "errors" "neo-code/internal/provider" - "neo-code/internal/provider/openai" + "neo-code/internal/provider/openaicompat" ) func NewRegistry() (*provider.Registry, error) { @@ -19,5 +19,5 @@ func register(registry *provider.Registry) error { if registry == nil { return errors.New("builtin provider registry is nil") } - return registry.Register(openai.Driver()) + return registry.Register(openaicompat.Driver()) } diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index a078b3da..1b164b0b 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -4,7 +4,7 @@ import ( "testing" "neo-code/internal/provider" - "neo-code/internal/provider/openai" + "neo-code/internal/provider/openaicompat" ) func TestNewRegistry(t *testing.T) { @@ -17,8 +17,8 @@ func TestNewRegistry(t *testing.T) { if registry == nil { t.Fatal("expected non-nil registry") } - if !registry.Supports(openai.DriverName) { - t.Fatalf("expected registry to include %q driver", openai.DriverName) + if !registry.Supports(openaicompat.DriverName) { + t.Fatalf("expected registry to include %q driver", openaicompat.DriverName) } } @@ -43,8 +43,8 @@ func TestRegister(t *testing.T) { if err != nil { t.Fatalf("Register() error = %v", err) } - if !registry.Supports(openai.DriverName) { - t.Fatalf("expected registry to include %q driver", openai.DriverName) + if !registry.Supports(openaicompat.DriverName) { + t.Fatalf("expected registry to include %q driver", openaicompat.DriverName) } }) } diff --git a/internal/provider/capabilities.go b/internal/provider/capabilities.go deleted file mode 100644 index 2218d227..00000000 --- a/internal/provider/capabilities.go +++ /dev/null @@ -1,43 +0,0 @@ -package provider - -// CapabilitySupport 表示模型能力的确认状态。 -type CapabilitySupport string - -const ( - CapabilitySupportUnknown CapabilitySupport = "unknown" - CapabilitySupportUnsupported CapabilitySupport = "unsupported" - CapabilitySupportSupported CapabilitySupport = "supported" -) - -// ReasoningMode 表示模型推理能力的形态。 -type ReasoningMode string - -const ( - ReasoningModeUnknown ReasoningMode = "unknown" - ReasoningModeNone ReasoningMode = "none" - ReasoningModeNative ReasoningMode = "native" - ReasoningModeConfigurable ReasoningMode = "configurable" -) - -// DriverCapabilities 描述 driver 层本身是否能传输某类能力。 -type DriverCapabilities struct { - Streaming bool - ToolTransport bool - ModelDiscovery bool - ImageInputTransport bool -} - -// ModelCapabilities 描述具体模型的能力声明。 -type ModelCapabilities struct { - ToolCalling CapabilitySupport - ImageInput CapabilitySupport - ReasoningMode ReasoningMode - MaxContextTokens int - MaxOutputTokens int -} - -// ResolvedCapabilities 是 driver 能力与模型能力的合并视图。 -type ResolvedCapabilities struct { - Driver DriverCapabilities - Model ModelCapabilities -} diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index 6fd9a2ee..d3d54d1c 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -7,8 +7,8 @@ import ( "sync" "time" - "neo-code/internal/config" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const ( @@ -42,28 +42,28 @@ func NewService(baseDir string, registry *provider.Registry, store Store) *Servi } } -func (s *Service) ListProviderModels(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { - return s.listProviderModels(ctx, providerCfg, queryOptions{ +func (s *Service) ListProviderModels(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return s.listProviderModels(ctx, input, queryOptions{ allowSyncRefresh: true, queueRefresh: true, }) } -func (s *Service) ListProviderModelsSnapshot(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { - return s.listProviderModels(ctx, providerCfg, queryOptions{ +func (s *Service) ListProviderModelsSnapshot(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return s.listProviderModels(ctx, input, queryOptions{ queueRefresh: true, }) } -func (s *Service) ListProviderModelsCached(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { - return s.listProviderModels(ctx, providerCfg, queryOptions{}) +func (s *Service) ListProviderModelsCached(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return s.listProviderModels(ctx, input, queryOptions{}) } func (s *Service) listProviderModels( ctx context.Context, - providerCfg config.ProviderConfig, + input provider.CatalogInput, options queryOptions, -) ([]config.ModelDescriptor, error) { +) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -71,7 +71,7 @@ func (s *Service) listProviderModels( return nil, err } - return s.modelsForProvider(ctx, providerCfg, options), nil + return s.modelsForProvider(ctx, input, options) } func (s *Service) validate() error { @@ -90,40 +90,52 @@ type queryOptions struct { } type catalogSnapshot struct { - models []config.ModelDescriptor + models []providertypes.ModelDescriptor ok bool expired bool - identity config.ProviderIdentity + identity provider.ProviderIdentity } -func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) []config.ModelDescriptor { - defaultModels := config.DescriptorsFromIDs([]string{providerCfg.Model}) - snapshot := s.catalogSnapshot(ctx, providerCfg) +func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogInput, options queryOptions) ([]providertypes.ModelDescriptor, error) { + configuredModels := providertypes.MergeModelDescriptors(input.ConfiguredModels) + defaultModels := providertypes.MergeModelDescriptors(input.DefaultModels) + snapshot := s.catalogSnapshot(ctx, input) models := snapshot.models - modelsOK := snapshot.ok - if !modelsOK && options.allowSyncRefresh { - discovered, ok := s.discoverAndPersist(ctx, providerCfg) - if ok { + catalogOK := snapshot.ok + performedSyncRefresh := false + if !catalogOK && options.allowSyncRefresh { + discovered, err := s.discoverAndPersist(ctx, input) + if err != nil { + if len(defaultModels) == 0 { + return nil, err + } + } else { models = discovered - modelsOK = true + catalogOK = true + performedSyncRefresh = true } } - if options.queueRefresh && (!modelsOK || snapshot.expired) { - s.queueRefresh(providerCfg, snapshot.identity) + if options.queueRefresh && snapshot.expired { + s.queueRefresh(input) + } + if options.queueRefresh && !snapshot.ok && !performedSyncRefresh { + s.queueRefresh(input) } - return config.MergeModelDescriptors(models, defaultModels) -} - -func (s *Service) catalogSnapshot(ctx context.Context, providerCfg config.ProviderConfig) catalogSnapshot { - identity, err := providerCfg.Identity() - if err != nil { - return catalogSnapshot{} + if !catalogOK { + if len(defaultModels) == 0 { + return nil, nil + } + return providertypes.MergeModelDescriptors(configuredModels, defaultModels), nil } + return providertypes.MergeModelDescriptors(configuredModels, models, defaultModels), nil +} - modelCatalog, err := s.loadCatalog(ctx, providerCfg) +func (s *Service) catalogSnapshot(ctx context.Context, input provider.CatalogInput) catalogSnapshot { + identity := input.Identity + modelCatalog, err := s.loadCatalog(ctx, identity) if err != nil { return catalogSnapshot{identity: identity} } @@ -135,58 +147,55 @@ func (s *Service) catalogSnapshot(ctx context.Context, providerCfg config.Provid } } -func (s *Service) loadCatalog(ctx context.Context, providerCfg config.ProviderConfig) (ModelCatalog, error) { +func (s *Service) loadCatalog(ctx context.Context, identity provider.ProviderIdentity) (ModelCatalog, error) { if s.store == nil { return ModelCatalog{}, ErrCatalogNotFound } - - identity, err := providerCfg.Identity() - if err != nil { - return ModelCatalog{}, err - } return s.store.Load(ctx, identity) } -func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, bool) { - if !s.registry.Supports(providerCfg.Driver) { - return nil, false +func (s *Service) discoverAndPersist(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + driverType := strings.TrimSpace(input.Identity.Driver) + if !s.registry.Supports(driverType) { + return nil, nil } - resolved, err := providerCfg.Resolve() - if err != nil { - return nil, false + if input.ResolveDiscoveryConfig == nil { + return nil, errors.New("provider catalog: discovery config resolver is nil") } - discovered, err := s.registry.DiscoverModels(ctx, resolved) + runtimeCfg, err := input.ResolveDiscoveryConfig() if err != nil { - return nil, false + return nil, err } - discovered = config.MergeModelDescriptors(discovered) - if s.store == nil { - return discovered, true + discovered, err := s.registry.DiscoverModels(ctx, runtimeCfg) + if err != nil { + return nil, err } - identity, err := providerCfg.Identity() - if err != nil { - return discovered, true + discovered = providertypes.MergeModelDescriptors(discovered) + if s.store == nil { + return discovered, nil } now := s.now() _ = s.store.Save(ctx, ModelCatalog{ SchemaVersion: schemaVersion, - Identity: identity, + Identity: input.Identity, FetchedAt: now, ExpiresAt: now.Add(s.catalogTTL), Models: discovered, }) - return discovered, true + return discovered, nil } -func (s *Service) queueRefresh(providerCfg config.ProviderConfig, identity config.ProviderIdentity) { - if s.store == nil || !s.registry.Supports(providerCfg.Driver) { +func (s *Service) queueRefresh(input provider.CatalogInput) { + driverType := strings.TrimSpace(input.Identity.Driver) + if s.store == nil || !s.registry.Supports(driverType) { return } + identity := input.Identity if identity.Driver == "" || identity.BaseURL == "" { return } @@ -209,6 +218,6 @@ func (s *Service) queueRefresh(providerCfg config.ProviderConfig, identity confi ctx, cancel := context.WithTimeout(context.Background(), s.backgroundTimeout) defer cancel() - _, _ = s.discoverAndPersist(ctx, providerCfg) + _, _ = s.discoverAndPersist(ctx, input) }() } diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index cbf3a854..85c55db6 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "strings" "sync" "sync/atomic" "testing" @@ -10,6 +11,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + "neo-code/internal/provider/openaicompat" providertypes "neo-code/internal/provider/types" ) @@ -32,7 +34,7 @@ func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) t.Parallel() service := NewService("", provider.NewRegistry(), newMemoryStore()) - models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + models, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) } @@ -41,23 +43,86 @@ func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) } } +func TestListProviderModelsCustomProviderDoesNotFallbackWithoutDiscovery(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), newMemoryStore()) + models, err := service.ListProviderModels(context.Background(), customGatewayProviderSource()) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 0 { + t.Fatalf("expected no models for custom provider without cache or discovery, got %+v", models) + } +} + +func TestListProviderModelsMergesConfiguredMetadataAfterDiscovery(t *testing.T) { + t.Setenv(testAPIKeyEnv, "test-key") + + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ + ID: "deepseek-coder", + Name: "Server DeepSeek", + ContextWindow: 32768, + MaxOutputTokens: 4096, + }}, nil + }) + + service := NewService("", registry, newMemoryStore()) + providerCfg := config.OpenAIProvider() + providerCfg.Models = []providertypes.ModelDescriptor{{ + ID: "deepseek-coder", + Name: "DeepSeek Coder", + ContextWindow: 131072, + MaxOutputTokens: 8192, + CapabilityHints: providertypes.ModelCapabilityHints{ + ToolCalling: providertypes.ModelCapabilityStateSupported, + }, + }} + providerCfg.Model = "deepseek-coder" + + input, err := config.NewProviderCatalogInput(providerCfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + models, err := service.ListProviderModels(context.Background(), input) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 1 { + t.Fatalf("expected merged configured/discovered result, got %+v", models) + } + if models[0].Name != "DeepSeek Coder" { + t.Fatalf("expected configured model name to win, got %+v", models[0]) + } + if models[0].ContextWindow != 131072 { + t.Fatalf("expected configured context window to win, got %+v", models[0]) + } + if models[0].MaxOutputTokens != 8192 { + t.Fatalf("expected configured max output tokens to win, got %+v", models[0]) + } + if models[0].CapabilityHints.ToolCalling != providertypes.ModelCapabilityStateSupported { + t.Fatalf("expected configured capability hints to win, got %+v", models[0].CapabilityHints) + } +} + func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") refreshed := make(chan struct{}, 1) - registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { select { case refreshed <- struct{}{}: default: } - return []config.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil + return []providertypes.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil }) store := newMemoryStore() service := NewService("", registry, store) service.backgroundTimeout = time.Second - models, err := service.ListProviderModelsSnapshot(context.Background(), config.OpenAIProvider()) + models, err := service.ListProviderModelsSnapshot(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModelsSnapshot() error = %v", err) } @@ -92,11 +157,24 @@ func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss( t.Fatalf("expected refreshed catalog to contain gpt-4o, got %+v", modelCatalog.Models) } +func TestListProviderModelsReturnsDiscoveryErrorOnCacheMiss(t *testing.T) { + t.Setenv(testAPIKeyEnv, "") + + service := NewService("", newRegistry(t, "openaicompat", func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return nil, nil + }), newMemoryStore()) + + _, err := service.ListProviderModels(context.Background(), customGatewayProviderSource()) + if err == nil || !strings.Contains(err.Error(), "OPENAI_API_KEY") { + t.Fatalf("expected discovery-time api key error, got %v", err) + } +} + func TestListProviderModelsDiscoversAndCachesOnMiss(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") - registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { - return []config.ModelDescriptor{{ + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ ID: "server-model", Name: "Server Model", ContextWindow: 32000, @@ -107,7 +185,7 @@ func TestListProviderModelsDiscoversAndCachesOnMiss(t *testing.T) { store := newMemoryStore() service := NewService("", registry, store) - models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + models, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) } @@ -143,7 +221,7 @@ func TestListProviderModelsReturnsStaleCacheAndRefreshesInBackground(t *testing. Identity: identity, FetchedAt: now.Add(-48 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour), - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "stale-model", Name: "Stale Model"}, }, }); err != nil { @@ -151,19 +229,19 @@ func TestListProviderModelsReturnsStaleCacheAndRefreshesInBackground(t *testing. } refreshed := make(chan struct{}, 1) - registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { select { case refreshed <- struct{}{}: default: } - return []config.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil + return []providertypes.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil }) service := NewService("", registry, store) service.now = func() time.Time { return now } service.backgroundTimeout = time.Second - models, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + models, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) } @@ -196,7 +274,7 @@ func TestListProviderModelsReturnsStaleCacheAndRefreshesInBackground(t *testing. func TestDescriptorsFromIDsHelper(t *testing.T) { t.Parallel() - models := config.DescriptorsFromIDs([]string{"gpt-4.1", "", "gpt-4o"}) + models := providertypes.DescriptorsFromIDs([]string{"gpt-4.1", "", "gpt-4o"}) if len(models) != 2 { t.Fatalf("expected 2 descriptors, got %d", len(models)) } @@ -212,7 +290,7 @@ func TestServiceValidateErrors(t *testing.T) { t.Parallel() var service *Service - _, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + _, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err == nil || err.Error() != "provider catalog: service is nil" { t.Fatalf("expected nil service error, got %v", err) } @@ -222,7 +300,7 @@ func TestServiceValidateErrors(t *testing.T) { t.Parallel() service := NewService("", nil, newMemoryStore()) - _, err := service.ListProviderModels(context.Background(), config.OpenAIProvider()) + _, err := service.ListProviderModels(context.Background(), openAIProviderSource()) if err == nil || err.Error() != "provider catalog: registry is nil" { t.Fatalf("expected nil registry error, got %v", err) } @@ -236,7 +314,7 @@ func TestListProviderModelsHonorsContextError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := service.ListProviderModels(ctx, config.OpenAIProvider()) + _, err := service.ListProviderModels(ctx, openAIProviderSource()) if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled, got %v", err) } @@ -257,7 +335,7 @@ func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) Identity: identity, FetchedAt: now.Add(-time.Hour), ExpiresAt: now.Add(time.Hour), - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "cached-model", Name: "Cached Model"}, }, }); err != nil { @@ -265,15 +343,15 @@ func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) } var discoverCalls int32 - registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { atomic.AddInt32(&discoverCalls, 1) - return []config.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil + return []providertypes.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil }) service := NewService("", registry, store) service.now = func() time.Time { return now } - models, err := service.ListProviderModelsCached(context.Background(), config.OpenAIProvider()) + models, err := service.ListProviderModelsCached(context.Background(), openAIProviderSource()) if err != nil { t.Fatalf("ListProviderModelsCached() error = %v", err) } @@ -288,49 +366,56 @@ func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) func TestDiscoverAndPersistFailurePaths(t *testing.T) { t.Run("unsupported driver", func(t *testing.T) { service := NewService("", provider.NewRegistry(), newMemoryStore()) - if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil { - t.Fatalf("expected unsupported driver to skip discovery, got ok=%v models=%+v", ok, discovered) + discovered, err := service.discoverAndPersist(context.Background(), openAIProviderSource()) + if err != nil || discovered != nil { + t.Fatalf("expected unsupported driver to skip discovery, got err=%v models=%+v", err, discovered) } }) t.Run("resolve provider config failure", func(t *testing.T) { - service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + service := NewService("", newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return nil, nil }), newMemoryStore()) providerCfg := config.ProviderConfig{ Name: "broken-openai", - Driver: config.OpenAIName, + Driver: openaicompat.DriverName, BaseURL: config.OpenAIDefaultBaseURL, Model: config.OpenAIDefaultModel, APIKeyEnv: "", } - if discovered, ok := service.discoverAndPersist(context.Background(), providerCfg); ok || discovered != nil { - t.Fatalf("expected resolve failure to skip discovery, got ok=%v models=%+v", ok, discovered) + input, err := config.NewProviderCatalogInput(providerCfg) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + discovered, err := service.discoverAndPersist(context.Background(), input) + if err == nil || discovered != nil { + t.Fatalf("expected resolve failure to surface as error, got err=%v models=%+v", err, discovered) } }) t.Run("discovery error", func(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") - service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + service := NewService("", newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return nil, errors.New("discover failed") }), newMemoryStore()) - if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil { - t.Fatalf("expected discovery error to skip persistence, got ok=%v models=%+v", ok, discovered) + discovered, err := service.discoverAndPersist(context.Background(), openAIProviderSource()) + if err == nil || discovered != nil { + t.Fatalf("expected discovery error to skip persistence, got err=%v models=%+v", err, discovered) } }) t.Run("store nil still returns discovered models", func(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") - service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { - return []config.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil + service := NewService("", newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil }), nil) - discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()) - if !ok { - t.Fatal("expected discovery without store to succeed") + discovered, err := service.discoverAndPersist(context.Background(), openAIProviderSource()) + if err != nil { + t.Fatalf("expected discovery without store to succeed, got %v", err) } if !containsModelDescriptorID(discovered, "gpt-4.1") { t.Fatalf("expected discovered model to be returned, got %+v", discovered) @@ -349,7 +434,7 @@ func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { started := make(chan struct{}, 1) release := make(chan struct{}) var discoverCalls int32 - registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + registry := newRegistry(t, openaicompat.DriverName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { atomic.AddInt32(&discoverCalls, 1) select { case started <- struct{}{}: @@ -359,15 +444,15 @@ func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { case <-release: case <-ctx.Done(): } - return []config.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil + return []providertypes.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil }) service := NewService("", registry, newMemoryStore()) service.backgroundTimeout = time.Second - service.queueRefresh(config.OpenAIProvider(), identity) + service.queueRefresh(openAIProviderSource()) <-started - service.queueRefresh(config.OpenAIProvider(), identity) + service.queueRefresh(openAIProviderSource()) time.Sleep(50 * time.Millisecond) if calls := atomic.LoadInt32(&discoverCalls); calls != 1 { @@ -396,7 +481,7 @@ func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *pr if err := registry.Register(provider.DriverDefinition{ Name: name, Discover: discover, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return catalogTestProvider{}, nil }, }); err != nil { @@ -405,14 +490,40 @@ func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *pr return registry } -func containsModelDescriptorID(models []config.ModelDescriptor, modelID string) bool { - target := config.NormalizeKey(modelID) +func openAIProviderSource() provider.CatalogInput { + input, err := config.NewProviderCatalogInput(config.OpenAIProvider()) + if err != nil { + panic(err) + } + return input +} + +func customGatewayProviderSource() provider.CatalogInput { + input, err := config.NewProviderCatalogInput(customGatewayProvider()) + if err != nil { + panic(err) + } + return input +} + +func customGatewayProvider() config.ProviderConfig { + return config.ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: testAPIKeyEnv, + Source: config.ProviderSourceCustom, + } +} + +func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { + target := provider.NormalizeKey(modelID) if target == "" { return false } for _, model := range models { - if config.NormalizeKey(model.ID) == target { + if provider.NormalizeKey(model.ID) == target { return true } } @@ -436,7 +547,7 @@ func newMemoryStore() *memoryStore { } } -func (s *memoryStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { +func (s *memoryStore) Load(ctx context.Context, identity provider.ProviderIdentity) (ModelCatalog, error) { if err := ctx.Err(); err != nil { return ModelCatalog{}, err } diff --git a/internal/provider/catalog/store.go b/internal/provider/catalog/store.go index 96ce6f7b..1a7c00a8 100644 --- a/internal/provider/catalog/store.go +++ b/internal/provider/catalog/store.go @@ -13,7 +13,8 @@ import ( "sync" "time" - "neo-code/internal/config" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const schemaVersion = 1 @@ -22,11 +23,11 @@ var ErrCatalogNotFound = errors.New("provider: model catalog not found") // ModelCatalog stores discovered models for a concrete provider endpoint. type ModelCatalog struct { - SchemaVersion int `json:"schema_version"` - Identity config.ProviderIdentity `json:"identity"` - FetchedAt time.Time `json:"fetched_at"` - ExpiresAt time.Time `json:"expires_at"` - Models []config.ModelDescriptor `json:"models"` + SchemaVersion int `json:"schema_version"` + Identity provider.ProviderIdentity `json:"identity"` + FetchedAt time.Time `json:"fetched_at"` + ExpiresAt time.Time `json:"expires_at"` + Models []providertypes.ModelDescriptor `json:"models"` } func (c ModelCatalog) Expired(now time.Time) bool { @@ -35,7 +36,7 @@ func (c ModelCatalog) Expired(now time.Time) bool { // Store persists model catalogs keyed by normalized provider identity. type Store interface { - Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) + Load(ctx context.Context, identity provider.ProviderIdentity) (ModelCatalog, error) Save(ctx context.Context, catalog ModelCatalog) error } @@ -50,12 +51,12 @@ func newJSONStore(baseDir string) *jsonStore { } } -func (s *jsonStore) Load(ctx context.Context, identity config.ProviderIdentity) (ModelCatalog, error) { +func (s *jsonStore) Load(ctx context.Context, identity provider.ProviderIdentity) (ModelCatalog, error) { if err := ctx.Err(); err != nil { return ModelCatalog{}, err } - normalized, err := config.NewProviderIdentity(identity.Driver, identity.BaseURL) + normalized, err := provider.NormalizeProviderIdentity(identity) if err != nil { return ModelCatalog{}, fmt.Errorf("provider: normalize model catalog key: %w", err) } @@ -86,7 +87,7 @@ func (s *jsonStore) Save(ctx context.Context, catalog ModelCatalog) error { } normalized := normalizeCatalog(catalog) - identity, err := config.NewProviderIdentity(normalized.Identity.Driver, normalized.Identity.BaseURL) + identity, err := provider.NormalizeProviderIdentity(normalized.Identity) if err != nil { return fmt.Errorf("provider: normalize model catalog key: %w", err) } @@ -113,11 +114,11 @@ func normalizeCatalog(modelCatalog ModelCatalog) ModelCatalog { if modelCatalog.SchemaVersion == 0 { modelCatalog.SchemaVersion = schemaVersion } - modelCatalog.Models = config.MergeModelDescriptors(modelCatalog.Models) + modelCatalog.Models = providertypes.MergeModelDescriptors(modelCatalog.Models) return modelCatalog } -func (s *jsonStore) catalogPath(identity config.ProviderIdentity) string { +func (s *jsonStore) catalogPath(identity provider.ProviderIdentity) string { sum := sha256.Sum256([]byte(identity.Key())) return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".json") } diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index 82024d28..83de59b3 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -9,14 +9,15 @@ import ( "testing" "time" - "neo-code/internal/config" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestJSONStoreRoundTrip(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -26,15 +27,15 @@ func TestJSONStoreRoundTrip(t *testing.T) { Identity: identity, FetchedAt: time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC), - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ { ID: "gpt-4.1", Name: "GPT-4.1", Description: "Fast flagship", ContextWindow: 128000, MaxOutputTokens: 16384, - Capabilities: map[string]bool{ - "tool_call": true, + CapabilityHints: providertypes.ModelCapabilityHints{ + ToolCalling: providertypes.ModelCapabilityStateSupported, }, }, }, @@ -63,8 +64,63 @@ func TestJSONStoreRoundTrip(t *testing.T) { if got.Models[0].ID != expected.Models[0].ID || got.Models[0].Name != expected.Models[0].Name { t.Fatalf("expected model %+v, got %+v", expected.Models[0], got.Models[0]) } - if !got.Models[0].Capabilities["tool_call"] { - t.Fatalf("expected capabilities to round-trip, got %+v", got.Models[0].Capabilities) + if got.Models[0].CapabilityHints.ToolCalling != providertypes.ModelCapabilityStateSupported { + t.Fatalf("expected capability hints to round-trip, got %+v", got.Models[0].CapabilityHints) + } +} + +func TestJSONStoreSeparatesDriverSpecificIdentityKeys(t *testing.T) { + t.Parallel() + + store := newJSONStore(t.TempDir()) + responsesIdentity := provider.ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: " Responses ", + } + chatIdentity := provider.ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIStyle: "chat_completions", + } + + if err := store.Save(context.Background(), ModelCatalog{ + Identity: responsesIdentity, + Models: []providertypes.ModelDescriptor{ + {ID: "responses-model", Name: "Responses Model"}, + }, + }); err != nil { + t.Fatalf("save responses catalog: %v", err) + } + if err := store.Save(context.Background(), ModelCatalog{ + Identity: chatIdentity, + Models: []providertypes.ModelDescriptor{ + {ID: "chat-model", Name: "Chat Model"}, + }, + }); err != nil { + t.Fatalf("save chat catalog: %v", err) + } + + responsesCatalog, err := store.Load(context.Background(), responsesIdentity) + if err != nil { + t.Fatalf("load responses catalog: %v", err) + } + if len(responsesCatalog.Models) != 1 || responsesCatalog.Models[0].ID != "responses-model" { + t.Fatalf("expected responses catalog to stay isolated, got %+v", responsesCatalog.Models) + } + if responsesCatalog.Identity.APIStyle != "responses" { + t.Fatalf("expected normalized api_style=responses, got %+v", responsesCatalog.Identity) + } + + chatCatalog, err := store.Load(context.Background(), chatIdentity) + if err != nil { + t.Fatalf("load chat catalog: %v", err) + } + if len(chatCatalog.Models) != 1 || chatCatalog.Models[0].ID != "chat-model" { + t.Fatalf("expected chat catalog to stay isolated, got %+v", chatCatalog.Models) + } + if chatCatalog.Identity.APIStyle != "chat_completions" { + t.Fatalf("expected normalized api_style=chat_completions, got %+v", chatCatalog.Identity) } } @@ -72,7 +128,7 @@ func TestJSONStoreMissingCatalog(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -88,7 +144,7 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { baseDir := t.TempDir() store := newJSONStore(baseDir) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -98,7 +154,7 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { Identity: identity, FetchedAt: time.Date(2026, 4, 2, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC), - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "gpt-old", Name: "GPT Old"}, }, } @@ -107,7 +163,7 @@ func TestJSONStoreSaveReplacesExistingCatalogWithoutTempLeak(t *testing.T) { Identity: identity, FetchedAt: time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 4, 5, 10, 0, 0, 0, time.UTC), - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "gpt-new", Name: "GPT New"}, }, } @@ -163,7 +219,7 @@ func TestJSONStoreLoadHonorsContextError(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -181,7 +237,7 @@ func TestJSONStoreLoadRejectsInvalidIdentity(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - _, err := store.Load(context.Background(), config.ProviderIdentity{ + _, err := store.Load(context.Background(), provider.ProviderIdentity{ Driver: "openai", BaseURL: "://bad", }) @@ -194,7 +250,7 @@ func TestJSONStoreLoadRejectsInvalidJSON(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -216,7 +272,7 @@ func TestJSONStoreSaveHonorsContextError(t *testing.T) { t.Parallel() store := newJSONStore(t.TempDir()) - identity, err := config.NewProviderIdentity("openai", "https://api.openai.com/v1") + identity, err := provider.NewProviderIdentity("openai", "https://api.openai.com/v1") if err != nil { t.Fatalf("NewProviderIdentity() error = %v", err) } @@ -235,7 +291,7 @@ func TestJSONStoreSaveRejectsInvalidIdentity(t *testing.T) { store := newJSONStore(t.TempDir()) err := store.Save(context.Background(), ModelCatalog{ - Identity: config.ProviderIdentity{ + Identity: provider.ProviderIdentity{ Driver: "", BaseURL: "https://api.openai.com/v1", }, @@ -249,7 +305,7 @@ func TestNormalizeCatalogDefaultsSchemaAndDedupesModels(t *testing.T) { t.Parallel() modelCatalog := normalizeCatalog(ModelCatalog{ - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "gpt-4o", Name: "GPT-4o"}, {ID: "gpt-4o", Name: "GPT-4o Duplicate"}, }, diff --git a/internal/provider/contracts.go b/internal/provider/contracts.go new file mode 100644 index 00000000..42fb33a8 --- /dev/null +++ b/internal/provider/contracts.go @@ -0,0 +1,40 @@ +package provider + +import ( + "context" + + providertypes "neo-code/internal/provider/types" +) + +// RuntimeConfig 表示 provider 构建与模型发现使用的最小运行时输入。 +type RuntimeConfig struct { + Name string + Driver string + BaseURL string + DefaultModel string + APIKey string + APIStyle string + DeploymentMode string + APIVersion string +} + +// Provider 定义模型生成能力,通过 channel 推送流式事件给上层消费。 +type Provider interface { + Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error +} + +// DriverTransportCapabilities 描述 driver 层本身是否能传输某类运行时能力。 +type DriverTransportCapabilities struct { + Streaming bool + ToolTransport bool + ModelDiscovery bool + ImageInputTransport bool +} + +// CatalogInput 汇总 provider/catalog 查询、发现与缓存所需的最小输入。 +type CatalogInput struct { + Identity ProviderIdentity + ConfiguredModels []providertypes.ModelDescriptor + DefaultModels []providertypes.ModelDescriptor + ResolveDiscoveryConfig func() (RuntimeConfig, error) +} diff --git a/internal/provider/identity.go b/internal/provider/identity.go new file mode 100644 index 00000000..838fb21e --- /dev/null +++ b/internal/provider/identity.go @@ -0,0 +1,149 @@ +package provider + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +// ProviderIdentity 标识 discovery、缓存与去重所使用的具体 provider 连接身份。 +type ProviderIdentity struct { + Driver string `json:"driver"` + BaseURL string `json:"base_url"` + APIStyle string `json:"api_style,omitempty"` + DeploymentMode string `json:"deployment_mode,omitempty"` + APIVersion string `json:"api_version,omitempty"` +} + +// Key 返回稳定的 provider 身份键,供缓存文件命名与去重逻辑复用。 +func (i ProviderIdentity) Key() string { + parts := []string{i.Driver, i.BaseURL} + if strings.TrimSpace(i.APIStyle) != "" { + parts = append(parts, i.APIStyle) + } + if strings.TrimSpace(i.DeploymentMode) != "" { + parts = append(parts, i.DeploymentMode) + } + if strings.TrimSpace(i.APIVersion) != "" { + parts = append(parts, i.APIVersion) + } + return strings.Join(parts, "|") +} + +// String 返回可读的 provider 身份字符串,便于错误信息与日志复用。 +func (i ProviderIdentity) String() string { + return i.Key() +} + +// NormalizeKey 统一执行大小写折叠与空白裁剪,保证跨层比较稳定。 +func NormalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// NormalizeProviderDriver 规范化 driver 名称,避免大小写与空白导致身份漂移。 +func NormalizeProviderDriver(driver string) string { + return NormalizeKey(driver) +} + +// NormalizeProviderAPIStyle 规范化 openaicompat 的 api_style,用于稳定生成连接身份。 +func NormalizeProviderAPIStyle(apiStyle string) string { + return NormalizeKey(apiStyle) +} + +// NormalizeProviderDeploymentMode 规范化 Gemini deployment_mode,避免大小写与空白导致误判。 +func NormalizeProviderDeploymentMode(mode string) string { + return NormalizeKey(mode) +} + +// NormalizeProviderAPIVersion 规范化 Anthropic api_version,用于稳定生成连接身份。 +func NormalizeProviderAPIVersion(version string) string { + return NormalizeKey(version) +} + +// NormalizeProviderBaseURL 将 provider 接入地址规整为可比较的稳定形式。 +func NormalizeProviderBaseURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("provider base_url is empty") + } + + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("provider base_url %q is invalid: %w", raw, err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("provider base_url %q must include scheme and host", raw) + } + + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.User = nil + + if cleaned := path.Clean(strings.TrimSpace(parsed.Path)); cleaned == "." { + parsed.Path = "" + } else { + parsed.Path = strings.TrimRight(cleaned, "/") + } + + return parsed.String(), nil +} + +// NewProviderIdentity 根据 driver 与 base_url 构造基础 provider 身份。 +func NewProviderIdentity(driver string, baseURL string) (ProviderIdentity, error) { + normalizedDriver := NormalizeProviderDriver(driver) + if normalizedDriver == "" { + return ProviderIdentity{}, fmt.Errorf("provider driver is empty") + } + + normalizedBaseURL, err := NormalizeProviderBaseURL(baseURL) + if err != nil { + return ProviderIdentity{}, err + } + + return ProviderIdentity{ + Driver: normalizedDriver, + BaseURL: normalizedBaseURL, + }, nil +} + +// NormalizeProviderIdentity 根据 driver 规则规范化连接身份,保留参与缓存去重的专用字段。 +func NormalizeProviderIdentity(identity ProviderIdentity) (ProviderIdentity, error) { + normalizedDriver := NormalizeProviderDriver(identity.Driver) + switch normalizedDriver { + case "openaicompat": + baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) + if err != nil { + return ProviderIdentity{}, err + } + return ProviderIdentity{ + Driver: normalizedDriver, + BaseURL: baseURL, + APIStyle: NormalizeProviderAPIStyle(identity.APIStyle), + }, nil + case "gemini": + baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) + if err != nil { + return ProviderIdentity{}, err + } + return ProviderIdentity{ + Driver: normalizedDriver, + BaseURL: baseURL, + DeploymentMode: NormalizeProviderDeploymentMode(identity.DeploymentMode), + }, nil + case "anthropic": + baseURL, err := NormalizeProviderBaseURL(identity.BaseURL) + if err != nil { + return ProviderIdentity{}, err + } + return ProviderIdentity{ + Driver: normalizedDriver, + BaseURL: baseURL, + APIVersion: NormalizeProviderAPIVersion(identity.APIVersion), + }, nil + default: + return NewProviderIdentity(identity.Driver, identity.BaseURL) + } +} diff --git a/internal/provider/identity_test.go b/internal/provider/identity_test.go new file mode 100644 index 00000000..26639d2d --- /dev/null +++ b/internal/provider/identity_test.go @@ -0,0 +1,133 @@ +package provider + +import "testing" + +func TestProviderIdentityKeyIncludesDriverSpecificFields(t *testing.T) { + t.Parallel() + + identity := ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + } + + if got, want := identity.Key(), "openaicompat|https://api.example.com/v1|responses|ignored|ignored"; got != want { + t.Fatalf("expected identity key %q, got %q", want, got) + } +} + +func TestNormalizeProviderIdentityUsesDriverSpecificNormalization(t *testing.T) { + t.Parallel() + + identity, err := NormalizeProviderIdentity(ProviderIdentity{ + Driver: " OpenAICompat ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: " Responses ", + }) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } + + if identity.Driver != "openaicompat" { + t.Fatalf("expected normalized driver %q, got %q", "openaicompat", identity.Driver) + } + if identity.BaseURL != "https://api.example.com/v1" { + t.Fatalf("expected normalized base url %q, got %q", "https://api.example.com/v1", identity.BaseURL) + } + if identity.APIStyle != "responses" { + t.Fatalf("expected normalized api_style %q, got %q", "responses", identity.APIStyle) + } +} + +func TestNormalizeProviderIdentityPreservesDriverSpecificFields(t *testing.T) { + t.Parallel() + + identity, err := NormalizeProviderIdentity(ProviderIdentity{ + Driver: " Gemini ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + DeploymentMode: " Vertex ", + }) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() error = %v", err) + } + + if identity.Driver != "gemini" { + t.Fatalf("expected normalized driver %q, got %q", "gemini", identity.Driver) + } + if identity.BaseURL != "https://api.example.com/v1" { + t.Fatalf("expected normalized base url %q, got %q", "https://api.example.com/v1", identity.BaseURL) + } + if identity.DeploymentMode != "vertex" { + t.Fatalf("expected normalized deployment_mode %q, got %q", "vertex", identity.DeploymentMode) + } +} + +func TestProviderIdentityStringMatchesKey(t *testing.T) { + t.Parallel() + + identity := ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIStyle: "responses", + } + if identity.String() != identity.Key() { + t.Fatalf("expected String() to match Key(), got %q vs %q", identity.String(), identity.Key()) + } +} + +func TestNewProviderIdentityValidatesInputs(t *testing.T) { + t.Parallel() + + identity, err := NewProviderIdentity(" OpenAICompat ", "https://API.EXAMPLE.COM/v1/") + if err != nil { + t.Fatalf("NewProviderIdentity() error = %v", err) + } + if identity.Driver != "openaicompat" || identity.BaseURL != "https://api.example.com/v1" { + t.Fatalf("unexpected identity: %+v", identity) + } + + if _, err := NewProviderIdentity(" ", "https://api.example.com/v1"); err == nil { + t.Fatalf("expected empty driver to fail") + } + if _, err := NewProviderIdentity("openaicompat", "not-a-url"); err == nil { + t.Fatalf("expected invalid base URL to fail") + } +} + +func TestNormalizeProviderIdentityAnthropicAndFallback(t *testing.T) { + t.Parallel() + + anthropicIdentity, err := NormalizeProviderIdentity(ProviderIdentity{ + Driver: " Anthropic ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIVersion: " 2023-06-01 ", + }) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() anthropic error = %v", err) + } + if anthropicIdentity.Driver != "anthropic" { + t.Fatalf("expected anthropic driver, got %+v", anthropicIdentity) + } + if anthropicIdentity.APIVersion != "2023-06-01" { + t.Fatalf("expected normalized api version, got %+v", anthropicIdentity) + } + + fallbackIdentity, err := NormalizeProviderIdentity(ProviderIdentity{ + Driver: " custom ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: "responses", + DeploymentMode: "vertex", + APIVersion: "2023-06-01", + }) + if err != nil { + t.Fatalf("NormalizeProviderIdentity() fallback error = %v", err) + } + if fallbackIdentity.Driver != "custom" || fallbackIdentity.BaseURL != "https://api.example.com/v1" { + t.Fatalf("expected fallback identity to normalize driver and base URL, got %+v", fallbackIdentity) + } + if fallbackIdentity.APIStyle != "" || fallbackIdentity.DeploymentMode != "" || fallbackIdentity.APIVersion != "" { + t.Fatalf("expected fallback identity to drop protocol-specific fields, got %+v", fallbackIdentity) + } +} diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go deleted file mode 100644 index 5b41a4d9..00000000 --- a/internal/provider/openai/driver.go +++ /dev/null @@ -1,41 +0,0 @@ -package openai - -import ( - "context" - "net/http" - - "neo-code/internal/config" - "neo-code/internal/provider" - "neo-code/internal/provider/transport" -) - -// DriverName 是 OpenAI 驱动的注册标识。 -const DriverName = "openai" - -// defaultRetryTransport 返回内置的带重试的 HTTP Transport。 -func defaultRetryTransport() http.RoundTripper { - return transport.NewRetryTransport(http.DefaultTransport, transport.DefaultRetryConfig()) -} - -// Driver 返回 OpenAI 协议驱动的定义,供 Registry 注册使用。 -func Driver() provider.DriverDefinition { - return provider.DriverDefinition{ - Name: DriverName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { - return New(cfg, withTransport(defaultRetryTransport())) - }, - Discover: func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { - p, err := New(cfg, withTransport(defaultRetryTransport())) - if err != nil { - return nil, err - } - return p.DiscoverModels(ctx) - }, - Capabilities: provider.DriverCapabilities{ - Streaming: true, - ToolTransport: true, - ModelDiscovery: true, - ImageInputTransport: false, - }, - } -} diff --git a/internal/provider/openai/discovery.go b/internal/provider/openaicompat/discovery.go similarity index 98% rename from internal/provider/openai/discovery.go rename to internal/provider/openaicompat/discovery.go index b20929d8..0266883a 100644 --- a/internal/provider/openai/discovery.go +++ b/internal/provider/openaicompat/discovery.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "context" diff --git a/internal/provider/openaicompat/driver.go b/internal/provider/openaicompat/driver.go new file mode 100644 index 00000000..25f5a870 --- /dev/null +++ b/internal/provider/openaicompat/driver.go @@ -0,0 +1,46 @@ +package openaicompat + +import ( + "context" + "net/http" + + "neo-code/internal/provider" + "neo-code/internal/provider/transport" + providertypes "neo-code/internal/provider/types" +) + +// DriverName 是当前 OpenAI-compatible 协议驱动的唯一标识。 +const DriverName = "openaicompat" + +// defaultRetryTransport 返回内置的带重试 HTTP Transport。 +func defaultRetryTransport() http.RoundTripper { + return transport.NewRetryTransport(http.DefaultTransport, transport.DefaultRetryConfig()) +} + +// Driver 返回 OpenAI-compatible 协议驱动定义。 +func Driver() provider.DriverDefinition { + return driverDefinition(DriverName) +} + +// driverDefinition 根据驱动名构造共享的 OpenAI-compatible 协议驱动定义。 +func driverDefinition(name string) provider.DriverDefinition { + return provider.DriverDefinition{ + Name: name, + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { + return New(cfg, withTransport(defaultRetryTransport())) + }, + Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + p, err := New(cfg, withTransport(defaultRetryTransport())) + if err != nil { + return nil, err + } + return p.DiscoverModels(ctx) + }, + Capabilities: provider.DriverTransportCapabilities{ + Streaming: true, + ToolTransport: true, + ModelDiscovery: true, + ImageInputTransport: false, + }, + } +} diff --git a/internal/provider/openai/events.go b/internal/provider/openaicompat/events.go similarity index 99% rename from internal/provider/openai/events.go rename to internal/provider/openaicompat/events.go index 50e13338..4df04b02 100644 --- a/internal/provider/openai/events.go +++ b/internal/provider/openaicompat/events.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "context" diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openaicompat/openaicompat_test.go similarity index 97% rename from internal/provider/openai/openai_test.go rename to internal/provider/openaicompat/openaicompat_test.go index aa3d4414..cbdbac65 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "context" @@ -75,18 +75,19 @@ func TestNewValidationErrors(t *testing.T) { t.Run("invalid config validate fails", func(t *testing.T) { t.Parallel() - cfg := config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Driver: DriverName, - BaseURL: "", - Model: "", - APIKeyEnv: "NONEXISTENT_ENV_VAR_" + t.Name(), - }, - APIKey: "test-key", + cfg := provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: "", + DefaultModel: config.OpenAIDefaultModel, + APIKey: "test-key", } _, err := New(cfg) - if err != nil { - return + if err == nil { + t.Fatal("expected error for empty base url") + } + if !strings.Contains(err.Error(), "base url is empty") { + t.Fatalf("expected base url error, got: %v", err) } }) } @@ -274,15 +275,12 @@ func TestBuildRequest_EmptyModelReturnsError(t *testing.T) { // 直接构造 Provider 跳过 New() 的 Validate 校验, // 以便测试 buildRequest 对空 model 的独立校验。 p := &Provider{ - cfg: config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: config.OpenAIDefaultBaseURL, - Model: "", - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }, - APIKey: "test-key", + cfg: provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: config.OpenAIDefaultBaseURL, + DefaultModel: "", + APIKey: "test-key", }, client: &http.Client{}, } @@ -876,9 +874,9 @@ func TestMergeToolCallDelta_IDUpdateOnly(t *testing.T) { } } -// --- Chat 集成测试 --- +// --- Generate 集成测试 --- -func TestChat_BaseURLTrailingSlashHandled(t *testing.T) { +func TestGenerate_BaseURLTrailingSlashHandled(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -951,7 +949,7 @@ func TestParseError_InvalidJSONBody(t *testing.T) { // --- 原有保留的集成测试(保持兼容) --- -func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { +func TestProviderGenerateConsumesSSEAndMergesToolCalls(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1052,7 +1050,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { } } -func TestProviderChatHTTPErrorResponses(t *testing.T) { +func TestProviderGenerateHTTPErrorResponses(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") tests := []struct { @@ -1195,16 +1193,19 @@ func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { // --- 辅助函数 --- -func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { +func resolvedConfig(baseURL string, model string) provider.RuntimeConfig { if strings.TrimSpace(baseURL) == "" { baseURL = config.OpenAIDefaultBaseURL } if strings.TrimSpace(model) == "" { model = config.OpenAIDefaultModel } - return config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{Name: DriverName, Driver: DriverName, BaseURL: baseURL, Model: model, APIKeyEnv: config.OpenAIDefaultAPIKeyEnv}, - APIKey: "test-key", + return provider.RuntimeConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: baseURL, + DefaultModel: model, + APIKey: "test-key", } } @@ -1430,7 +1431,7 @@ func TestMergeToolCallDeltaEmitsStartWhenNameArrivesLater(t *testing.T) { } } -func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { +func TestProviderGenerateEmitsToolCallStartEvent(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1473,7 +1474,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { } } -func TestProviderChatEmitsFullEventStream(t *testing.T) { +func TestProviderGenerateEmitsFullEventStream(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/provider/openai/provider.go b/internal/provider/openaicompat/provider.go similarity index 78% rename from internal/provider/openai/provider.go rename to internal/provider/openaicompat/provider.go index acf48233..935c23eb 100644 --- a/internal/provider/openai/provider.go +++ b/internal/provider/openaicompat/provider.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "bytes" @@ -12,13 +12,13 @@ import ( "strings" "time" - "neo-code/internal/config" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" ) // Provider 封装 OpenAI 兼容 API 的客户端配置和 HTTP 连接。 type Provider struct { - cfg config.ResolvedProviderConfig + cfg provider.RuntimeConfig client *http.Client } @@ -37,10 +37,10 @@ func withTransport(rt http.RoundTripper) buildOption { } } -// New 创建 OpenAI provider 实例。cfg 必须 Validate 通过且包含有效 API Key。 -func New(cfg config.ResolvedProviderConfig, opts ...buildOption) (*Provider, error) { - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("openai provider: %w", err) +// New 创建 OpenAI provider 实例。cfg 必须包含有效的接入地址和 API Key。 +func New(cfg provider.RuntimeConfig, opts ...buildOption) (*Provider, error) { + if strings.TrimSpace(cfg.BaseURL) == "" { + return nil, errors.New("openai provider: base url is empty") } if strings.TrimSpace(cfg.APIKey) == "" { return nil, errors.New("openai provider: api key is empty") @@ -63,24 +63,24 @@ func New(cfg config.ResolvedProviderConfig, opts ...buildOption) (*Provider, err } // DiscoverModels 通过 /models 端点查询可用模型列表。 -func (p *Provider) DiscoverModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (p *Provider) DiscoverModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { rawModels, err := p.fetchModels(ctx) if err != nil { return nil, err } - descriptors := make([]config.ModelDescriptor, 0, len(rawModels)) + descriptors := make([]providertypes.ModelDescriptor, 0, len(rawModels)) for _, raw := range rawModels { - descriptor, ok := config.DescriptorFromRawModel(raw) + descriptor, ok := providertypes.DescriptorFromRawModel(raw) if !ok { continue } descriptors = append(descriptors, descriptor) } - return config.MergeModelDescriptors(descriptors), nil + return providertypes.MergeModelDescriptors(descriptors), nil } -// Chat 发起 SSE 流式对话请求。 +// Generate 发起 SSE 流式生成请求。 // 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 func (p *Provider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { payload, err := p.buildRequest(req) diff --git a/internal/provider/openai/request.go b/internal/provider/openaicompat/request.go similarity index 97% rename from internal/provider/openai/request.go rename to internal/provider/openaicompat/request.go index 9a9b8d2b..c3a3938c 100644 --- a/internal/provider/openai/request.go +++ b/internal/provider/openaicompat/request.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "encoding/json" @@ -17,7 +17,7 @@ import ( func (p *Provider) buildRequest(req providertypes.GenerateRequest) (chatCompletionRequest, error) { model := strings.TrimSpace(req.Model) if model == "" { - model = strings.TrimSpace(p.cfg.Model) + model = strings.TrimSpace(p.cfg.DefaultModel) } if model == "" { return chatCompletionRequest{}, errors.New("openai provider: model is empty") diff --git a/internal/provider/openai/response.go b/internal/provider/openaicompat/response.go similarity index 99% rename from internal/provider/openai/response.go rename to internal/provider/openaicompat/response.go index 3b59d880..dff1a1e1 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openaicompat/response.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "context" diff --git a/internal/provider/openai/sse_reader.go b/internal/provider/openaicompat/sse_reader.go similarity index 99% rename from internal/provider/openai/sse_reader.go rename to internal/provider/openaicompat/sse_reader.go index 3070feec..e0affa96 100644 --- a/internal/provider/openai/sse_reader.go +++ b/internal/provider/openaicompat/sse_reader.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "bufio" diff --git a/internal/provider/openai/sse_reader_test.go b/internal/provider/openaicompat/sse_reader_test.go similarity index 99% rename from internal/provider/openai/sse_reader_test.go rename to internal/provider/openaicompat/sse_reader_test.go index eb7a1803..3aae7b02 100644 --- a/internal/provider/openai/sse_reader_test.go +++ b/internal/provider/openaicompat/sse_reader_test.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "errors" diff --git a/internal/provider/openai/toolcall.go b/internal/provider/openaicompat/toolcall.go similarity index 98% rename from internal/provider/openai/toolcall.go rename to internal/provider/openaicompat/toolcall.go index 339d86c9..e3b7a748 100644 --- a/internal/provider/openai/toolcall.go +++ b/internal/provider/openaicompat/toolcall.go @@ -1,4 +1,4 @@ -package openai +package openaicompat import ( "context" diff --git a/internal/provider/openai/types.go b/internal/provider/openaicompat/types.go similarity index 99% rename from internal/provider/openai/types.go rename to internal/provider/openaicompat/types.go index f943e1e3..95dc24dc 100644 --- a/internal/provider/openai/types.go +++ b/internal/provider/openaicompat/types.go @@ -1,4 +1,4 @@ -package openai +package openaicompat // 以下类型定义了 OpenAI Chat Completions API 的请求和响应结构体, // 仅在 openai 子包内部使用,不对外暴露。 diff --git a/internal/provider/provider.go b/internal/provider/provider.go deleted file mode 100644 index 0d84bc00..00000000 --- a/internal/provider/provider.go +++ /dev/null @@ -1,12 +0,0 @@ -package provider - -import ( - "context" - - "neo-code/internal/provider/types" -) - -// Provider 定义模型生成能力,通过 channel 推送流式事件给上层消费。 -type Provider interface { - Generate(ctx context.Context, req types.GenerateRequest, events chan<- types.StreamEvent) error -} diff --git a/internal/provider/registry.go b/internal/provider/registry.go index b49344a1..e7fb32ba 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -6,17 +6,17 @@ import ( "fmt" "strings" - "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" ) -type Builder func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) -type DiscoveryFunc func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) +type Builder func(ctx context.Context, cfg RuntimeConfig) (Provider, error) +type DiscoveryFunc func(ctx context.Context, cfg RuntimeConfig) ([]providertypes.ModelDescriptor, error) type DriverDefinition struct { Name string Build Builder Discover DiscoveryFunc - Capabilities DriverCapabilities + Capabilities DriverTransportCapabilities } type Registry struct { @@ -35,7 +35,7 @@ func (r *Registry) Register(driver DriverDefinition) error { r.ensureDrivers() driver.Name = strings.TrimSpace(driver.Name) - driverType := config.NormalizeKey(driver.Name) + driverType := normalizeDriverKey(driver.Name) if driverType == "" { return errors.New("provider: driver name is empty") } @@ -49,7 +49,7 @@ func (r *Registry) Register(driver DriverDefinition) error { return nil } -func (r *Registry) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { +func (r *Registry) Build(ctx context.Context, cfg RuntimeConfig) (Provider, error) { driver, err := r.driver(cfg.Driver) if err != nil { return nil, err @@ -57,7 +57,7 @@ func (r *Registry) Build(ctx context.Context, cfg config.ResolvedProviderConfig) return driver.Build(ctx, cfg) } -func (r *Registry) DiscoverModels(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { +func (r *Registry) DiscoverModels(ctx context.Context, cfg RuntimeConfig) ([]providertypes.ModelDescriptor, error) { driver, err := r.driver(cfg.Driver) if err != nil { return nil, err @@ -73,11 +73,11 @@ func (r *Registry) Supports(driverType string) bool { return err == nil } -// DriverCapabilities 返回指定 driver 的能力声明;driver 不存在时返回对应错误。 -func (r *Registry) DriverCapabilities(driverType string) (DriverCapabilities, error) { +// DriverTransportCapabilities 返回指定 driver 的传输能力声明;driver 不存在时返回对应错误。 +func (r *Registry) DriverTransportCapabilities(driverType string) (DriverTransportCapabilities, error) { driver, err := r.driver(driverType) if err != nil { - return DriverCapabilities{}, err + return DriverTransportCapabilities{}, err } return driver.Capabilities, nil } @@ -86,7 +86,7 @@ func (r *Registry) driver(driverType string) (DriverDefinition, error) { if r == nil { return DriverDefinition{}, ErrDriverNotFound } - driver, ok := r.drivers[config.NormalizeKey(driverType)] + driver, ok := r.drivers[normalizeDriverKey(driverType)] if !ok { return DriverDefinition{}, fmt.Errorf("%w: %s", ErrDriverNotFound, strings.TrimSpace(driverType)) } @@ -98,3 +98,8 @@ func (r *Registry) ensureDrivers() { r.drivers = map[string]DriverDefinition{} } } + +// normalizeDriverKey 统一规范化 driver 名称,保证注册与查询稳定匹配。 +func normalizeDriverKey(driverType string) string { + return strings.ToLower(strings.TrimSpace(driverType)) +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 5d597823..85b8b081 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -7,7 +7,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" - "neo-code/internal/provider/openai" + "neo-code/internal/provider/openaicompat" providertypes "neo-code/internal/provider/types" ) @@ -20,7 +20,7 @@ func (stubProvider) Generate(ctx context.Context, req providertypes.GenerateRequ func stubDriver(driverType string) provider.DriverDefinition { return provider.DriverDefinition{ Name: driverType, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return stubProvider{}, nil }, } @@ -30,8 +30,8 @@ func newTestRegistry(t *testing.T) *provider.Registry { t.Helper() registry := provider.NewRegistry() - if err := registry.Register(openai.Driver()); err != nil { - t.Fatalf("register openai driver: %v", err) + if err := registry.Register(openaicompat.Driver()); err != nil { + t.Fatalf("register openaicompat driver: %v", err) } return registry } @@ -40,21 +40,18 @@ func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { t.Parallel() registry := newTestRegistry(t) - got, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: "openai-main", - Driver: "OPENAI", - BaseURL: config.OpenAIDefaultBaseURL, - Model: config.OpenAIDefaultModel, - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }, - APIKey: "test-key", + got, err := registry.Build(context.Background(), provider.RuntimeConfig{ + Name: "openai-main", + Driver: "OPENAICOMPAT", + BaseURL: config.OpenAIDefaultBaseURL, + DefaultModel: config.OpenAIDefaultModel, + APIKey: "test-key", }) if err != nil { t.Fatalf("Build() error = %v", err) } - if _, ok := got.(*openai.Provider); !ok { - t.Fatalf("expected openai.Provider, got %T", got) + if _, ok := got.(*openaicompat.Provider); !ok { + t.Fatalf("expected openaicompat.Provider, got %T", got) } } @@ -62,9 +59,7 @@ func TestRegistryUnknownDriverReturnsTypedError(t *testing.T) { t.Parallel() registry := provider.NewRegistry() - _, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{Driver: "missing"}, - }) + _, err := registry.Build(context.Background(), provider.RuntimeConfig{Driver: "missing"}) if !errors.Is(err, provider.ErrDriverNotFound) { t.Fatalf("expected ErrDriverNotFound, got %v", err) } @@ -88,7 +83,7 @@ func TestRegistryDiscoverModels(t *testing.T) { t.Run("driver with discovery function", func(t *testing.T) { t.Parallel() - expectedModels := []config.ModelDescriptor{ + expectedModels := []providertypes.ModelDescriptor{ {ID: "model-1", Name: "Model 1"}, {ID: "model-2", Name: "Model 2"}, } @@ -96,10 +91,10 @@ func TestRegistryDiscoverModels(t *testing.T) { registry := provider.NewRegistry() driver := provider.DriverDefinition{ Name: "test-driver", - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return stubProvider{}, nil }, - Discover: func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return expectedModels, nil }, } @@ -107,9 +102,7 @@ func TestRegistryDiscoverModels(t *testing.T) { t.Fatalf("Register() error = %v", err) } - got, err := registry.DiscoverModels(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{Driver: "test-driver"}, - }) + got, err := registry.DiscoverModels(context.Background(), provider.RuntimeConfig{Driver: "test-driver"}) if err != nil { t.Fatalf("DiscoverModels() error = %v", err) } @@ -124,7 +117,7 @@ func TestRegistryDiscoverModels(t *testing.T) { registry := provider.NewRegistry() driver := provider.DriverDefinition{ Name: "test-driver", - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return stubProvider{}, nil }, Discover: nil, @@ -133,9 +126,7 @@ func TestRegistryDiscoverModels(t *testing.T) { t.Fatalf("Register() error = %v", err) } - got, err := registry.DiscoverModels(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{Driver: "test-driver"}, - }) + got, err := registry.DiscoverModels(context.Background(), provider.RuntimeConfig{Driver: "test-driver"}) if err != nil { t.Fatalf("DiscoverModels() error = %v", err) } @@ -148,9 +139,7 @@ func TestRegistryDiscoverModels(t *testing.T) { t.Parallel() registry := provider.NewRegistry() - _, err := registry.DiscoverModels(context.Background(), config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{Driver: "missing"}, - }) + _, err := registry.DiscoverModels(context.Background(), provider.RuntimeConfig{Driver: "missing"}) if !errors.Is(err, provider.ErrDriverNotFound) { t.Fatalf("expected ErrDriverNotFound, got %v", err) } @@ -161,7 +150,7 @@ func TestRegistrySupports(t *testing.T) { t.Parallel() registry := provider.NewRegistry() - if err := registry.Register(stubDriver("openai")); err != nil { + if err := registry.Register(stubDriver("openaicompat")); err != nil { t.Fatalf("Register() error = %v", err) } @@ -169,9 +158,9 @@ func TestRegistrySupports(t *testing.T) { driverType string want bool }{ - {"openai", true}, - {"OPENAI", true}, - {"OpenAI", true}, + {"openaicompat", true}, + {"OPENAICOMPAT", true}, + {"OpenAICompat", true}, {"missing", false}, {"MISSING", false}, {"", false}, @@ -187,25 +176,25 @@ func TestRegistrySupports(t *testing.T) { } } -func TestRegistryDriverCapabilities(t *testing.T) { +func TestRegistryDriverTransportCapabilities(t *testing.T) { t.Parallel() registry := newTestRegistry(t) - got, err := registry.DriverCapabilities("OPENAI") + got, err := registry.DriverTransportCapabilities("OPENAICOMPAT") if err != nil { - t.Fatalf("DriverCapabilities() error = %v", err) + t.Fatalf("DriverTransportCapabilities() error = %v", err) } if !got.Streaming { - t.Fatalf("expected openai driver to support streaming, got %+v", got) + t.Fatalf("expected openaicompat driver to support streaming, got %+v", got) } if !got.ToolTransport { - t.Fatalf("expected openai driver to support tool transport, got %+v", got) + t.Fatalf("expected openaicompat driver to support tool transport, got %+v", got) } if !got.ModelDiscovery { - t.Fatalf("expected openai driver to support model discovery, got %+v", got) + t.Fatalf("expected openaicompat driver to support model discovery, got %+v", got) } - _, err = registry.DriverCapabilities("missing") + _, err = registry.DriverTransportCapabilities("missing") if !errors.Is(err, provider.ErrDriverNotFound) { t.Fatalf("expected ErrDriverNotFound for missing driver, got %v", err) } @@ -233,7 +222,7 @@ func TestRegistryRegisterErrors(t *testing.T) { registry := provider.NewRegistry() err := registry.Register(provider.DriverDefinition{ Name: " ", - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return nil, nil }, }) diff --git a/internal/provider/types/model.go b/internal/provider/types/model.go new file mode 100644 index 00000000..bff9b3ba --- /dev/null +++ b/internal/provider/types/model.go @@ -0,0 +1,298 @@ +package types + +import "strings" + +// ModelCapabilityState 表示模型能力提示的三态枚举。 +type ModelCapabilityState string + +const ( + ModelCapabilityStateUnknown ModelCapabilityState = "unknown" + ModelCapabilityStateUnsupported ModelCapabilityState = "unsupported" + ModelCapabilityStateSupported ModelCapabilityState = "supported" +) + +// ModelReasoningMode 表示模型推理能力的模式提示。 +type ModelReasoningMode string + +const ( + ModelReasoningModeUnknown ModelReasoningMode = "unknown" + ModelReasoningModeNone ModelReasoningMode = "none" + ModelReasoningModeNative ModelReasoningMode = "native" + ModelReasoningModeConfigurable ModelReasoningMode = "configurable" +) + +// ModelCapabilityHints 描述 discovery/catalog 链路共享的模型能力提示。 +type ModelCapabilityHints struct { + ToolCalling ModelCapabilityState `json:"tool_calling,omitempty"` + ImageInput ModelCapabilityState `json:"image_input,omitempty"` + ReasoningMode ModelReasoningMode `json:"reasoning_mode,omitempty"` +} + +// ModelDescriptor 表示 discovery/catalog 链路共享的模型元数据描述符。 +type ModelDescriptor struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + CapabilityHints ModelCapabilityHints `json:"capability_hints,omitempty"` +} + +// DescriptorFromRawModel 将原始 provider 模型对象标准化为 ModelDescriptor。 +func DescriptorFromRawModel(raw map[string]any) (ModelDescriptor, bool) { + id := firstNonEmptyString( + stringValue(raw["id"]), + stringValue(raw["model"]), + stringValue(raw["name"]), + ) + if id == "" { + return ModelDescriptor{}, false + } + + descriptor := ModelDescriptor{ + ID: id, + Name: firstNonEmptyString(stringValue(raw["name"]), stringValue(raw["display_name"]), id), + Description: stringValue(raw["description"]), + ContextWindow: firstPositiveInt(raw["context_window"], raw["contextLength"], raw["input_token_limit"], raw["max_context_tokens"]), + MaxOutputTokens: firstPositiveInt(raw["max_output_tokens"], raw["output_token_limit"], raw["max_tokens"]), + CapabilityHints: modelCapabilityHintsFromValue(raw["capabilities"]), + } + return normalizeModelDescriptor(descriptor), true +} + +// MergeModelDescriptors 合并多个 ModelDescriptor 来源,按 ID 去重并回填缺失字段。 +func MergeModelDescriptors(sources ...[]ModelDescriptor) []ModelDescriptor { + if len(sources) == 0 { + return nil + } + + merged := make([]ModelDescriptor, 0) + indexByID := make(map[string]int) + + for _, source := range sources { + for _, candidate := range source { + normalized := normalizeModelDescriptor(candidate) + key := normalizeKey(normalized.ID) + if key == "" { + continue + } + + if index, exists := indexByID[key]; exists { + merged[index] = mergeModelDescriptor(merged[index], normalized) + continue + } + + indexByID[key] = len(merged) + merged = append(merged, normalized) + } + } + + if len(merged) == 0 { + return nil + } + return merged +} + +// DescriptorsFromIDs 从模型 ID 列表构建最小化的 ModelDescriptor 列表。 +func DescriptorsFromIDs(modelIDs []string) []ModelDescriptor { + if len(modelIDs) == 0 { + return nil + } + + descriptors := make([]ModelDescriptor, 0, len(modelIDs)) + for _, modelID := range modelIDs { + id := strings.TrimSpace(modelID) + if id == "" { + continue + } + descriptors = append(descriptors, ModelDescriptor{ + ID: id, + Name: id, + }) + } + if len(descriptors) == 0 { + return nil + } + return descriptors +} + +// CloneModelDescriptors 返回模型描述列表的深拷贝,避免不同快照共享底层切片。 +func CloneModelDescriptors(source []ModelDescriptor) []ModelDescriptor { + if len(source) == 0 { + return nil + } + + cloned := make([]ModelDescriptor, 0, len(source)) + for _, descriptor := range source { + cloned = append(cloned, normalizeModelDescriptor(descriptor)) + } + return cloned +} + +// normalizeModelDescriptor 统一清理模型描述中的字符串和能力提示字段。 +func normalizeModelDescriptor(descriptor ModelDescriptor) ModelDescriptor { + descriptor.ID = strings.TrimSpace(descriptor.ID) + descriptor.Name = strings.TrimSpace(descriptor.Name) + descriptor.Description = strings.TrimSpace(descriptor.Description) + if descriptor.Name == "" { + descriptor.Name = descriptor.ID + } + descriptor.CapabilityHints = normalizeModelCapabilityHints(descriptor.CapabilityHints) + return descriptor +} + +// mergeModelDescriptor 以 primary 为准合并模型描述,仅用 secondary 回填缺失字段。 +func mergeModelDescriptor(primary ModelDescriptor, secondary ModelDescriptor) ModelDescriptor { + if strings.TrimSpace(primary.Name) == "" { + primary.Name = secondary.Name + } + if strings.TrimSpace(primary.Description) == "" { + primary.Description = secondary.Description + } + if primary.ContextWindow <= 0 { + primary.ContextWindow = secondary.ContextWindow + } + if primary.MaxOutputTokens <= 0 { + primary.MaxOutputTokens = secondary.MaxOutputTokens + } + primary.CapabilityHints = mergeModelCapabilityHints(primary.CapabilityHints, secondary.CapabilityHints) + return normalizeModelDescriptor(primary) +} + +// mergeModelCapabilityHints 以 primary 为准合并模型能力提示,仅用 secondary 回填缺失值。 +func mergeModelCapabilityHints(primary ModelCapabilityHints, secondary ModelCapabilityHints) ModelCapabilityHints { + if primary.ToolCalling == "" { + primary.ToolCalling = secondary.ToolCalling + } + if primary.ImageInput == "" { + primary.ImageInput = secondary.ImageInput + } + if primary.ReasoningMode == "" { + primary.ReasoningMode = secondary.ReasoningMode + } + return normalizeModelCapabilityHints(primary) +} + +// normalizeModelCapabilityHints 规范化模型能力提示中的枚举字符串。 +func normalizeModelCapabilityHints(hints ModelCapabilityHints) ModelCapabilityHints { + hints.ToolCalling = normalizeModelCapabilityState(string(hints.ToolCalling)) + hints.ImageInput = normalizeModelCapabilityState(string(hints.ImageInput)) + hints.ReasoningMode = normalizeModelReasoningMode(string(hints.ReasoningMode)) + return hints +} + +// modelCapabilityHintsFromValue 将原始 capabilities 值收敛为统一的三态能力提示。 +func modelCapabilityHintsFromValue(value any) ModelCapabilityHints { + raw, ok := value.(map[string]any) + if !ok || len(raw) == 0 { + return ModelCapabilityHints{} + } + + hints := ModelCapabilityHints{} + for key, item := range raw { + boolValue, ok := item.(bool) + if !ok { + continue + } + + switch normalizeKey(key) { + case "tool_calling", "tool_call": + hints.ToolCalling = modelCapabilityStateFromBool(boolValue) + case "image_input": + hints.ImageInput = modelCapabilityStateFromBool(boolValue) + } + } + return normalizeModelCapabilityHints(hints) +} + +// modelCapabilityStateFromBool 将旧式 bool 能力值映射为三态能力提示。 +func modelCapabilityStateFromBool(value bool) ModelCapabilityState { + if value { + return ModelCapabilityStateSupported + } + return ModelCapabilityStateUnsupported +} + +// normalizeModelCapabilityState 将能力状态字符串收敛为受支持的三态枚举。 +func normalizeModelCapabilityState(value string) ModelCapabilityState { + switch ModelCapabilityState(normalizeKey(value)) { + case ModelCapabilityStateSupported: + return ModelCapabilityStateSupported + case ModelCapabilityStateUnsupported: + return ModelCapabilityStateUnsupported + case ModelCapabilityStateUnknown: + return ModelCapabilityStateUnknown + default: + return "" + } +} + +// normalizeModelReasoningMode 将 reasoning_mode 字符串规范化为受支持的枚举值。 +func normalizeModelReasoningMode(value string) ModelReasoningMode { + switch ModelReasoningMode(normalizeKey(value)) { + case ModelReasoningModeNone: + return ModelReasoningModeNone + case ModelReasoningModeNative: + return ModelReasoningModeNative + case ModelReasoningModeConfigurable: + return ModelReasoningModeConfigurable + case ModelReasoningModeUnknown: + return ModelReasoningModeUnknown + default: + return "" + } +} + +// normalizeKey 统一执行大小写折叠与空白清理,保证跨层比较稳定。 +func normalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// stringValue 从通用值中提取字符串并做空白裁剪。 +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return "" + } +} + +// firstNonEmptyString 返回首个非空字符串。 +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +// firstPositiveInt 返回首个大于零的整数值。 +func firstPositiveInt(values ...any) int { + for _, value := range values { + switch typed := value.(type) { + case int: + if typed > 0 { + return typed + } + case int32: + if typed > 0 { + return int(typed) + } + case int64: + if typed > 0 { + return int(typed) + } + case float32: + if typed > 0 { + return int(typed) + } + case float64: + if typed > 0 { + return int(typed) + } + } + } + return 0 +} diff --git a/internal/provider/types/model_test.go b/internal/provider/types/model_test.go new file mode 100644 index 00000000..1e2a725d --- /dev/null +++ b/internal/provider/types/model_test.go @@ -0,0 +1,285 @@ +package types + +import "testing" + +func TestDescriptorFromRawModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw map[string]any + want ModelDescriptor + wantOK bool + }{ + { + name: "empty map returns false", + raw: map[string]any{}, + wantOK: false, + }, + { + name: "id from model field", + raw: map[string]any{ + "model": "gpt-4.1", + }, + want: ModelDescriptor{ID: "gpt-4.1", Name: "gpt-4.1"}, + wantOK: true, + }, + { + name: "full descriptor", + raw: map[string]any{ + "id": "gpt-4.1", + "display_name": "GPT-4.1", + "description": "desc", + "context_window": 128000, + "max_output_tokens": 16384, + }, + want: ModelDescriptor{ + ID: "gpt-4.1", + Name: "GPT-4.1", + Description: "desc", + ContextWindow: 128000, + MaxOutputTokens: 16384, + }, + wantOK: true, + }, + { + name: "capabilities map becomes hints", + raw: map[string]any{ + "id": "gpt-4o-mini", + "max_context_tokens": 64000, + "capabilities": map[string]any{ + "tool_call": true, + "image_input": false, + }, + }, + want: ModelDescriptor{ + ID: "gpt-4o-mini", + Name: "gpt-4o-mini", + ContextWindow: 64000, + CapabilityHints: ModelCapabilityHints{ + ToolCalling: ModelCapabilityStateSupported, + ImageInput: ModelCapabilityStateUnsupported, + }, + }, + wantOK: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := DescriptorFromRawModel(tt.raw) + if ok != tt.wantOK { + t.Fatalf("expected ok=%v, got ok=%v", tt.wantOK, ok) + } + if !tt.wantOK { + return + } + if got != tt.want { + t.Fatalf("expected descriptor %+v, got %+v", tt.want, got) + } + }) + } +} + +func TestMergeModelDescriptors(t *testing.T) { + t.Parallel() + + a := []ModelDescriptor{{ID: "m1", Name: "Model1"}} + b := []ModelDescriptor{{ID: "m2", Name: "Model2"}, {ID: "m1", Description: "fallback"}} + + merged := MergeModelDescriptors(a, b) + if len(merged) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(merged)) + } + + var m1 *ModelDescriptor + for i := range merged { + if merged[i].ID == "m1" { + m1 = &merged[i] + break + } + } + if m1 == nil { + t.Fatalf("expected m1 to be present") + } + if m1.Name != "Model1" { + t.Fatalf("expected Name=Model1 from first source, got %q", m1.Name) + } + if m1.Description != "fallback" { + t.Fatalf("expected Description=fallback from second source, got %q", m1.Description) + } +} + +func TestDescriptorsFromIDs(t *testing.T) { + t.Parallel() + + result := DescriptorsFromIDs([]string{"gpt-4.1", "gpt-4.1-mini"}) + if len(result) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(result)) + } + if result[0].ID != "gpt-4.1" { + t.Fatalf("expected first ID=gpt-4.1, got %q", result[0].ID) + } + if result[1].Name != "gpt-4.1-mini" { + t.Fatalf("expected second Name=gpt-4.1-mini, got %q", result[1].Name) + } +} + +func TestFirstNonEmptyString(t *testing.T) { + t.Parallel() + + if got := firstNonEmptyString("", " ", "hello", "world"); got != "hello" { + t.Fatalf("expected hello, got %q", got) + } + if got := firstNonEmptyString("", " "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestFirstPositiveInt(t *testing.T) { + t.Parallel() + + if got := firstPositiveInt(0, -1, 42, 100); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + if got := firstPositiveInt(int32(5)); got != 5 { + t.Fatalf("expected 5, got %d", got) + } + if got := firstPositiveInt(int64(10)); got != 10 { + t.Fatalf("expected 10, got %d", got) + } + if got := firstPositiveInt(float64(3.14)); got != 3 { + t.Fatalf("expected 3, got %d", got) + } + if got := firstPositiveInt(0, -5); got != 0 { + t.Fatalf("expected 0 when none positive, got %d", got) + } +} + +func TestModelCapabilityHintsFromValue(t *testing.T) { + t.Parallel() + + result := modelCapabilityHintsFromValue(map[string]any{ + "tool_call": true, + "image_input": false, + "ignored": "notbool", + }) + if result.ToolCalling != ModelCapabilityStateSupported { + t.Fatalf("expected tool calling supported, got %+v", result) + } + if result.ImageInput != ModelCapabilityStateUnsupported { + t.Fatalf("expected image input unsupported, got %+v", result) + } + if result := modelCapabilityHintsFromValue("not a map"); result != (ModelCapabilityHints{}) { + t.Fatalf("expected empty hints for non-map, got %+v", result) + } +} + +func TestMergeModelCapabilityHints(t *testing.T) { + t.Parallel() + + primary := ModelCapabilityHints{ + ToolCalling: ModelCapabilityStateSupported, + } + secondary := ModelCapabilityHints{ + ToolCalling: ModelCapabilityStateUnsupported, + ImageInput: ModelCapabilityStateUnsupported, + ReasoningMode: ModelReasoningModeConfigurable, + } + + result := mergeModelCapabilityHints(primary, secondary) + if result.ToolCalling != ModelCapabilityStateSupported { + t.Fatalf("expected primary tool calling to win, got %+v", result) + } + if result.ImageInput != ModelCapabilityStateUnsupported { + t.Fatalf("expected image input to be backfilled, got %+v", result) + } + if result.ReasoningMode != ModelReasoningModeConfigurable { + t.Fatalf("expected reasoning mode to be backfilled, got %+v", result) + } +} + +func TestMergeModelDescriptorFallback(t *testing.T) { + t.Parallel() + + primary := ModelDescriptor{ID: "m1"} + secondary := ModelDescriptor{ + Name: "Fallback", + Description: "desc", + ContextWindow: 8000, + MaxOutputTokens: 4096, + } + + result := mergeModelDescriptor(primary, secondary) + if result.Name != "Fallback" { + t.Fatalf("expected Name=Fallback from secondary, got %q", result.Name) + } + if result.ContextWindow != 8000 { + t.Fatalf("expected ContextWindow=8000 from secondary, got %d", result.ContextWindow) + } + if result.MaxOutputTokens != 4096 { + t.Fatalf("expected MaxOutputTokens=4096 from secondary, got %d", result.MaxOutputTokens) + } +} + +func TestCloneModelDescriptorsReturnsNormalizedIndependentCopy(t *testing.T) { + t.Parallel() + + source := []ModelDescriptor{ + { + ID: " model-a ", + Name: " ", + Description: " desc ", + CapabilityHints: ModelCapabilityHints{ + ToolCalling: " supported ", + ImageInput: " unsupported ", + ReasoningMode: " native ", + }, + }, + } + + cloned := CloneModelDescriptors(source) + if len(cloned) != 1 { + t.Fatalf("expected 1 cloned descriptor, got %+v", cloned) + } + if cloned[0].ID != "model-a" || cloned[0].Name != "model-a" || cloned[0].Description != "desc" { + t.Fatalf("expected normalized clone, got %+v", cloned[0]) + } + if cloned[0].CapabilityHints.ToolCalling != ModelCapabilityStateSupported || + cloned[0].CapabilityHints.ImageInput != ModelCapabilityStateUnsupported || + cloned[0].CapabilityHints.ReasoningMode != ModelReasoningModeNative { + t.Fatalf("expected normalized capability hints, got %+v", cloned[0].CapabilityHints) + } + + source[0].ID = "mutated" + if cloned[0].ID != "model-a" { + t.Fatalf("expected clone to be independent, got %+v", cloned[0]) + } + + if got := CloneModelDescriptors(nil); got != nil { + t.Fatalf("expected nil clone for nil source, got %+v", got) + } +} + +func TestNormalizeModelReasoningMode(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want ModelReasoningMode + }{ + {input: " native ", want: ModelReasoningModeNative}, + {input: "CONFIGURABLE", want: ModelReasoningModeConfigurable}, + {input: "unknown", want: ModelReasoningModeUnknown}, + {input: "invalid", want: ""}, + } + + for _, tt := range tests { + if got := normalizeModelReasoningMode(tt.input); got != tt.want { + t.Fatalf("normalizeModelReasoningMode(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 26487f33..daad016c 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -161,7 +161,7 @@ func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config. if err != nil { return nil, err } - return contextcompact.NewRunner(newCompactSummaryGenerator(s.providerFactory, resolvedProvider, model)), nil + return contextcompact.NewRunner(newCompactSummaryGenerator(s.providerFactory, resolvedProvider.ToRuntimeConfig(), model)), nil } // resolveCompactProviderSelection 优先复用会话记录的 provider/model,缺失时再回退当前配置。 diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index beb346bb..ff063827 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -3,23 +3,24 @@ package runtime import ( "context" "errors" + "fmt" "strings" - "neo-code/internal/config" agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" ) type compactSummaryGenerator struct { providerFactory ProviderFactory - providerConfig config.ResolvedProviderConfig + providerConfig provider.RuntimeConfig model string } func newCompactSummaryGenerator( providerFactory ProviderFactory, - providerCfg config.ResolvedProviderConfig, + providerCfg provider.RuntimeConfig, model string, ) contextcompact.SummaryGenerator { return &compactSummaryGenerator{ @@ -41,7 +42,7 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom strings.TrimSpace(g.providerConfig.APIKey) == "" { return "", errors.New("runtime: compact summary generator provider config is incomplete") } - if err := ensureProviderDriverCapabilities(g.providerFactory, g.providerConfig, true, false); err != nil { + if err := ensureDriverTransportCapabilities(g.providerFactory, g.providerConfig, true, false); err != nil { return "", err } @@ -104,6 +105,9 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom if streamErr != nil { return "", streamErr } + if !acc.messageDone { + return "", fmt.Errorf("%w: provider stream ended without message_done event", provider.ErrStreamInterrupted) + } message, err := acc.buildMessage() if err != nil { diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 2e643f04..8d21c41c 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -44,7 +44,7 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) }, } factory := &scriptedProviderFactory{provider: scripted} - generator := newCompactSummaryGenerator(factory, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(factory, resolvedProvider.ToRuntimeConfig(), "session-model") summary, err := generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -123,7 +123,7 @@ func TestCompactSummaryGeneratorRejectsToolCalls(t *testing.T) { }, }, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -153,7 +153,7 @@ func TestCompactSummaryGeneratorRejectsMalformedStreamEvent(t *testing.T) { }, }, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -164,6 +164,39 @@ func TestCompactSummaryGeneratorRejectsMalformedStreamEvent(t *testing.T) { } } +func TestCompactSummaryGeneratorRejectsCompletionWithoutMessageDone(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + resolvedProvider, err := resolvedProviderForTests(manager.Get(), config.OpenAIName) + if err != nil { + t.Fatalf("resolve provider: %v", err) + } + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + select { + case events <- providertypes.NewTextDeltaStreamEvent("[compact_summary]\npartial"): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") + + _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ + Mode: contextcompact.ModeManual, + Config: manager.Get().Context.Compact, + }) + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got %v", err) + } + if !strings.Contains(err.Error(), "without message_done") { + t.Fatalf("expected missing message_done error, got %v", err) + } +} + func TestCompactSummaryGeneratorMalformedStreamEventDoesNotDeadlock(t *testing.T) { t.Parallel() @@ -180,7 +213,7 @@ func TestCompactSummaryGeneratorMalformedStreamEventDoesNotDeadlock(t *testing.T scripted := &scriptedProvider{ streams: [][]providertypes.StreamEvent{stream}, } - generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider.ToRuntimeConfig(), "session-model") errCh := make(chan error, 1) go func() { @@ -217,12 +250,12 @@ func TestCompactSummaryGeneratorRejectsDriverWithoutStreaming(t *testing.T) { } factory := &scriptedProviderFactory{ provider: scripted, - capabilities: provider.DriverCapabilities{ + capabilities: provider.DriverTransportCapabilities{ Streaming: false, ToolTransport: true, }, } - generator := newCompactSummaryGenerator(factory, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(factory, resolvedProvider.ToRuntimeConfig(), "session-model") _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, @@ -252,7 +285,7 @@ func TestCompactSummaryGeneratorPropagatesDriverNotFoundFromCapabilities(t *test provider: &scriptedProvider{}, capabilitiesErr: provider.ErrDriverNotFound, } - generator := newCompactSummaryGenerator(factory, resolvedProvider, "session-model") + generator := newCompactSummaryGenerator(factory, resolvedProvider.ToRuntimeConfig(), "session-model") _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8efef734..6e4ab676 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -129,8 +129,8 @@ type UserInput struct { } type ProviderFactory interface { - Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) - DriverCapabilities(driverType string) (provider.DriverCapabilities, error) + Build(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) + DriverTransportCapabilities(driverType string) (provider.DriverTransportCapabilities, error) } type Service struct { @@ -667,10 +667,10 @@ func isRetryableProviderError(err error) bool { return pErr.Retryable } -// ensureProviderDriverCapabilities 校验当前 driver 是否满足指定运行场景的基础能力要求。 -func ensureProviderDriverCapabilities( +// ensureDriverTransportCapabilities 校验当前 driver 是否满足指定运行场景的基础传输能力要求。 +func ensureDriverTransportCapabilities( factory ProviderFactory, - cfg config.ResolvedProviderConfig, + cfg provider.RuntimeConfig, requireStreaming bool, requireToolTransport bool, ) error { @@ -679,7 +679,7 @@ func ensureProviderDriverCapabilities( } driverType := strings.TrimSpace(cfg.Driver) - caps, err := factory.DriverCapabilities(driverType) + caps, err := factory.DriverTransportCapabilities(driverType) if err != nil { return err } @@ -725,11 +725,13 @@ func (s *Service) callProviderWithRetry( if err != nil { return nil, err } - if err := ensureProviderDriverCapabilities(s.providerFactory, resolvedProvider, true, true); err != nil { + runtimeCfg := resolvedProvider.ToRuntimeConfig() + requireToolTransport := len(req.Tools) > 0 + if err := ensureDriverTransportCapabilities(s.providerFactory, runtimeCfg, true, requireToolTransport); err != nil { return nil, err } - modelProvider, err := s.providerFactory.Build(ctx, resolvedProvider) + modelProvider, err := s.providerFactory.Build(ctx, runtimeCfg) if err != nil { return nil, err } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 5a8771e0..b3da3ac6 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -174,13 +174,13 @@ func streamContainsMessageDone(events []providertypes.StreamEvent) bool { type scriptedProviderFactory struct { provider provider.Provider calls int - configs []config.ResolvedProviderConfig + configs []provider.RuntimeConfig err error - capabilities provider.DriverCapabilities + capabilities provider.DriverTransportCapabilities capabilitiesErr error } -func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { +func (f *scriptedProviderFactory) Build(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { f.calls++ f.configs = append(f.configs, cfg) if f.err != nil { @@ -189,12 +189,12 @@ func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.Resolved return f.provider, nil } -func (f *scriptedProviderFactory) DriverCapabilities(driverType string) (provider.DriverCapabilities, error) { +func (f *scriptedProviderFactory) DriverTransportCapabilities(driverType string) (provider.DriverTransportCapabilities, error) { if f.capabilitiesErr != nil { - return provider.DriverCapabilities{}, f.capabilitiesErr + return provider.DriverTransportCapabilities{}, f.capabilitiesErr } - if f.capabilities == (provider.DriverCapabilities{}) { - return provider.DriverCapabilities{ + if f.capabilities == (provider.DriverTransportCapabilities{}) { + return provider.DriverTransportCapabilities{ Streaming: true, ToolTransport: true, }, nil @@ -2539,16 +2539,16 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri } defaults := config.DefaultConfig() - selected := config.NormalizeProviderName(defaults.SelectedProvider) + selected := provider.NormalizeKey(defaults.SelectedProvider) for i := range defaults.Providers { - if config.NormalizeProviderName(defaults.Providers[i].Name) == selected { + if provider.NormalizeKey(defaults.Providers[i].Name) == selected { defaults.Providers[i].APIKeyEnv = apiKeyEnv break } } for providerName, envKey := range providerEnvs { for i := range defaults.Providers { - if config.NormalizeProviderName(defaults.Providers[i].Name) == config.NormalizeProviderName(providerName) { + if provider.NormalizeKey(defaults.Providers[i].Name) == provider.NormalizeKey(providerName) { defaults.Providers[i].APIKeyEnv = envKey break } @@ -3110,7 +3110,7 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { } } -func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { +func TestServiceRunRejectsDriverWithoutToolTransportWhenRequestExposesTools(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) @@ -3125,7 +3125,7 @@ func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { } factory := &scriptedProviderFactory{ provider: scripted, - capabilities: provider.DriverCapabilities{ + capabilities: provider.DriverTransportCapabilities{ Streaming: true, ToolTransport: false, }, @@ -3147,6 +3147,52 @@ func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { } } +func TestServiceRunAllowsDriverWithoutToolTransportWhenRequestHasNoTools(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + toolManager := &stubToolManager{} + + scripted := &scriptedProvider{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("plain answer")}, + }, + } + factory := &scriptedProviderFactory{ + provider: scripted, + capabilities: provider.DriverTransportCapabilities{ + Streaming: true, + ToolTransport: false, + }, + } + + service := NewWithFactory(manager, toolManager, store, factory, &stubContextBuilder{}) + err := service.Run(context.Background(), UserInput{ + RunID: "run-driver-no-tool-transport-no-tools", + Content: "hello", + }) + if err != nil { + t.Fatalf("expected no-tools request to succeed, got %v", err) + } + if factory.calls != 1 { + t.Fatalf("expected provider build once, got %d", factory.calls) + } + if scripted.callCount != 1 { + t.Fatalf("expected provider Generate() once, got %d", scripted.callCount) + } + if len(scripted.requests) != 1 { + t.Fatalf("expected one provider request, got %d", len(scripted.requests)) + } + if len(scripted.requests[0].Tools) != 0 { + t.Fatalf("expected provider request without tools, got %+v", scripted.requests[0].Tools) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentChunk, EventAgentDone}) + assertNoEventType(t, events, EventError) +} + func TestServiceRunPropagatesDriverNotFoundFromCapabilities(t *testing.T) { t.Parallel() diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go index 739b6970..630a06ca 100644 --- a/internal/tui/bootstrap/builder.go +++ b/internal/tui/bootstrap/builder.go @@ -5,6 +5,7 @@ import ( "fmt" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) @@ -12,8 +13,8 @@ import ( type ProviderService interface { ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) - ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..c3c8e70d 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -5,6 +5,7 @@ import ( "testing" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" ) @@ -55,11 +56,11 @@ func (s *testProviderService) SelectProvider(ctx context.Context, providerID str return config.ProviderSelection{}, nil } -func (s *testProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s *testProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, nil } -func (s *testProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s *testProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, nil } diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index f290ce30..33a1f0b5 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -51,8 +51,8 @@ type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg type ProviderController interface { ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) - ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ad373587..ab1d2f0a 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" tuicommands "neo-code/internal/tui/core/commands" tuistatus "neo-code/internal/tui/core/status" tuiservices "neo-code/internal/tui/services" @@ -145,15 +146,14 @@ func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { mapped := make([]selectionItem, 0, len(items)) for _, item := range items { mapped = append(mapped, selectionItem{ - id: item.ID, - name: item.Name, - description: item.Description, + id: item.ID, + name: item.Name, }) } return mapped } -func mapModelItems(models []config.ModelDescriptor) []selectionItem { +func mapModelItems(models []providertypes.ModelDescriptor) []selectionItem { mapped := make([]selectionItem, 0, len(models)) for _, option := range models { mapped = append(mapped, selectionItem{ @@ -297,7 +297,7 @@ func runModelCatalogRefresh(providerSvc ProviderController, providerID string) t return tuiservices.RefreshModelCatalogCmd( providerSvc, providerID, - func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { + func(providerID string, models []providertypes.ModelDescriptor, err error) tea.Msg { return modelCatalogRefreshMsg{ ProviderID: providerID, Models: models, diff --git a/internal/tui/services/provider_service.go b/internal/tui/services/provider_service.go index 71538113..69a5ccc4 100644 --- a/internal/tui/services/provider_service.go +++ b/internal/tui/services/provider_service.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" ) // ProviderSelector 定义 provider 选择命令所需最小能力。 @@ -19,9 +20,9 @@ type ModelSelector interface { SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } -// ModelCatalogReader 定义 model catalog 刷新所需最小能力。 +// ModelCatalogReader 定义当前 provider 模型目录刷新所需最小能力。 type ModelCatalogReader interface { - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) } // SelectProviderCmd 执行 provider 切换并将结果映射为 UI 消息。 @@ -48,11 +49,11 @@ func SelectModelCmd( } } -// RefreshModelCatalogCmd 拉取指定 provider 的模型列表并返回 UI 消息。 +// RefreshModelCatalogCmd 拉取当前 provider 的模型列表,并带回触发时的 providerID 快照供 UI 去重。 func RefreshModelCatalogCmd( providerSvc ModelCatalogReader, providerID string, - toMsg func(string, []config.ModelDescriptor, error) tea.Msg, + toMsg func(string, []providertypes.ModelDescriptor, error) tea.Msg, ) tea.Cmd { providerID = strings.TrimSpace(providerID) if providerSvc == nil || providerID == "" { diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index cde184c9..d4e804f0 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) @@ -35,7 +36,7 @@ func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactI type stubProvider struct { selection config.ProviderSelection - models []config.ModelDescriptor + models []providertypes.ModelDescriptor err error } @@ -47,7 +48,7 @@ func (s *stubProvider) SetCurrentModel(ctx context.Context, modelID string) (con return s.selection, s.err } -func (s *stubProvider) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s *stubProvider) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return s.models, s.err } @@ -104,7 +105,7 @@ func TestRunCompactCmd(t *testing.T) { func TestProviderCmds(t *testing.T) { svc := &stubProvider{ selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, - models: []config.ModelDescriptor{{ID: "gpt-5.4", Name: "GPT-5.4"}}, + models: []providertypes.ModelDescriptor{{ID: "gpt-5.4", Name: "GPT-5.4"}}, } msg := SelectProviderCmd(svc, "openai", func(sel config.ProviderSelection, err error) tea.Msg { return sel })() @@ -120,7 +121,7 @@ func TestProviderCmds(t *testing.T) { msg = RefreshModelCatalogCmd( svc, "openai", - func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { + func(providerID string, models []providertypes.ModelDescriptor, err error) tea.Msg { return providerID + ":" + models[0].ID }, )() @@ -128,7 +129,7 @@ func TestProviderCmds(t *testing.T) { t.Fatalf("expected catalog refresh msg, got %T %#v", msg, msg) } - if cmd := RefreshModelCatalogCmd(svc, "", func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { return nil }); cmd != nil { + if cmd := RefreshModelCatalogCmd(svc, "", func(providerID string, models []providertypes.ModelDescriptor, err error) tea.Msg { return nil }); cmd != nil { t.Fatalf("expected nil cmd for empty provider id") } } diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..3bb7ea2f 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -1,7 +1,7 @@ package state import ( - "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) @@ -21,7 +21,7 @@ type RunFinishedMsg struct { // ModelCatalogRefreshMsg 表示模型目录刷新结果。 type ModelCatalogRefreshMsg struct { ProviderID string - Models []config.ModelDescriptor + Models []providertypes.ModelDescriptor Err error } From d37880a0ccdac7254618d4255e649c78c617973e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 00:35:18 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=88=B0=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BC=9A=E5=81=B7=E5=81=B7=E6=94=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=AD=89=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/loader_test.go | 56 +++++++++++ internal/config/provider_loader.go | 6 ++ internal/config/selection.go | 31 +++--- internal/config/selection_test.go | 145 ++++++++++++++++++++++++++--- 4 files changed, 211 insertions(+), 27 deletions(-) diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index ab8466cd..8260bba3 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -307,6 +307,62 @@ openai_compatible: } } +func TestLoaderIgnoresDirectoriesWithoutProviderYAML(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + validDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + ignoredDir := filepath.Join(loader.BaseDir(), providersDirName, ".git") + for _, dir := range []string{validDir, ignoredDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +openai_compatible: + 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) + } + + 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.Source != ProviderSourceCustom { + t.Fatalf("expected custom provider source, got %+v", customProvider) + } +} + +func TestLoaderRejectsMalformedCustomProviderYAML(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte("name: [\n"), 0o644); err != nil { + t.Fatalf("write malformed provider.yaml: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "parse") { + t.Fatalf("expected malformed custom provider parse error, got %v", err) + } +} + func TestLoaderRejectsCustomProviderDefaultModel(t *testing.T) { t.Parallel() diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 75d6da18..943505d6 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -72,6 +72,12 @@ func loadCustomProviders(baseDir string) ([]ProviderConfig, error) { } providerDir := filepath.Join(providersDir, entry.Name()) + if _, err := os.Stat(filepath.Join(providerDir, customProviderConfigName)); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("config: stat %s: %w", filepath.Join(providerDir, customProviderConfigName), err) + } providerCfg, err := loadCustomProvider(providerDir) if err != nil { return nil, err diff --git a/internal/config/selection.go b/internal/config/selection.go index f6885980..036840fb 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -111,7 +111,15 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri if err != nil { return ProviderSelection{}, err } - models, err := s.catalogs.ListProviderModels(ctx, input) + var models []providertypes.ModelDescriptor + if providerCfg.Source == ProviderSourceCustom { + models, err = s.catalogs.ListProviderModels(ctx, input) + } else { + models, err = s.catalogs.ListProviderModelsSnapshot(ctx, input) + if len(models) == 0 { + models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(providerCfg.Model)}) + } + } if err != nil { return ProviderSelection{}, err } @@ -216,19 +224,6 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) return ProviderSelection{}, ErrNoModelsAvailable } if !containsModelDescriptorID(models, modelID) { - fallbackID := strings.TrimSpace(selected.Model) - if fallbackID != "" && containsModelDescriptorID(models, fallbackID) { - selection, fallbackErr := s.fallbackToProviderDefaultModel(ctx, selected, fallbackID) - if fallbackErr != nil { - return ProviderSelection{}, fallbackErr - } - return selection, fmt.Errorf( - "selection: model %q not found; fell back to default model %q: %w", - modelID, - fallbackID, - ErrModelNotFound, - ) - } return ProviderSelection{}, ErrModelNotFound } @@ -301,10 +296,16 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti if err != nil { return ProviderSelection{}, err } - models, err := s.catalogs.ListProviderModels(ctx, input) + models, err := s.catalogs.ListProviderModelsSnapshot(ctx, input) if err != nil { return ProviderSelection{}, err } + if len(models) == 0 { + if selected.Source == ProviderSourceCustom { + return selectionFromConfig(cfgSnapshot), nil + } + models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) + } if len(models) == 0 { return ProviderSelection{}, ErrNoModelsAvailable } diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index 4c078de6..d9d05478 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -153,8 +153,8 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { if !errors.Is(err, ErrModelNotFound) { t.Fatalf("expected ErrModelNotFound, got %v", err) } - if selection.ModelID != QiniuDefaultModel { - t.Fatalf("expected fallback selection to default model %q, got %+v", QiniuDefaultModel, selection) + if selection != (ProviderSelection{}) { + t.Fatalf("expected failed switch to return empty selection, got %+v", selection) } cfg := manager.Get() @@ -165,20 +165,57 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { if selected.Name != QiniuName { t.Fatalf("expected selected provider %q, got %+v", QiniuName, selected) } - if selected.Model != QiniuDefaultModel || cfg.CurrentModel != QiniuDefaultModel { - t.Fatalf("expected failed switch to fall back to provider default, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + if cfg.CurrentModel != QiniuDefaultModel+"-alt" { + t.Fatalf("expected failed switch to preserve current model %q, got %q", QiniuDefaultModel+"-alt", cfg.CurrentModel) } } func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) + defaults := DefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, defaults) service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ listModels: []providertypes.ModelDescriptor{ + {ID: "server-coder", Name: "Server Coder"}, + {ID: "server-chat", Name: "Server Chat"}, + }, + tracker: tracker, + }) + + selection, err := service.SelectProvider(context.Background(), "company-gateway") + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "server-coder" { + t.Fatalf("expected sync discovery-backed selection, got %+v", selection) + } + if tracker.listCalls != 1 || tracker.snapshotCalls != 0 { + t.Fatalf("expected custom provider selection to use sync catalog only, got %+v", *tracker) + } +} + +func TestSelectionServiceSelectBuiltinProviderUsesSnapshotCatalog(t *testing.T) { + t.Parallel() + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, DefaultConfig()) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listErr: errors.New("unexpected sync discovery"), + snapshotModels: []providertypes.ModelDescriptor{ {ID: QiniuDefaultModel, Name: QiniuDefaultModel}, {ID: QiniuDefaultModel + "-alt", Name: QiniuDefaultModel + "-alt"}, }, + tracker: tracker, }) selection, err := service.SelectProvider(context.Background(), QiniuName) @@ -186,7 +223,10 @@ func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T t.Fatalf("SelectProvider() error = %v", err) } if selection.ProviderID != QiniuName || selection.ModelID != QiniuDefaultModel { - t.Fatalf("expected sync discovery-backed selection, got %+v", selection) + t.Fatalf("expected snapshot-backed builtin selection, got %+v", selection) + } + if tracker.listCalls != 0 || tracker.snapshotCalls != 1 { + t.Fatalf("expected builtin provider selection to use snapshot catalog only, got %+v", *tracker) } } @@ -268,7 +308,7 @@ func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testi manager := newSelectionTestManager(t, defaults) service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ - listModels: []providertypes.ModelDescriptor{ + snapshotModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, {ID: "server-chat", Name: "Server Chat"}, }, @@ -283,18 +323,71 @@ func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testi } } -func TestSelectionServiceEnsureSelectionRejectsEmptyDiscoveredModelList(t *testing.T) { +func TestSelectionServiceEnsureSelectionFallsBackToBuiltinDefaultModelWhenSnapshotMissing(t *testing.T) { t.Parallel() defaults := testDefaultConfig() defaults.CurrentModel = "unsupported-current" + tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{}) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) - _, err := service.EnsureSelection(context.Background()) - if !errors.Is(err, ErrNoModelsAvailable) { - t.Fatalf("expected ErrNoModelsAvailable, got %v", err) + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != OpenAIName || selection.ModelID != OpenAIDefaultModel { + t.Fatalf("expected builtin fallback to default model, got %+v", selection) + } + if tracker.listCalls != 0 || tracker.snapshotCalls != 1 { + t.Fatalf("expected builtin ensure to use snapshot catalog only, got %+v", *tracker) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.CurrentModel != OpenAIDefaultModel { + t.Fatalf("expected builtin fallback to persist %q, got %q", OpenAIDefaultModel, reloaded.CurrentModel) + } +} + +func TestSelectionServiceEnsureSelectionKeepsCustomSelectionWhenSnapshotMissing(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + defaults.CurrentModel = "unknown-model" + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "unknown-model" { + t.Fatalf("expected custom selection to stay unchanged without snapshot, got %+v", selection) + } + if tracker.listCalls != 0 || tracker.snapshotCalls != 1 { + t.Fatalf("expected custom ensure to use snapshot catalog only, got %+v", *tracker) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.CurrentModel != "unknown-model" { + t.Fatalf("expected custom current model to stay unchanged, got %q", reloaded.CurrentModel) } } @@ -571,20 +664,48 @@ type catalogMethodsStub struct { listModels []providertypes.ModelDescriptor snapshotModels []providertypes.ModelDescriptor cachedModels []providertypes.ModelDescriptor + listErr error + snapshotErr error + cachedErr error + tracker *catalogMethodCalls } func (s catalogMethodsStub) ListProviderModels(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.listCalls++ + } + if s.listErr != nil { + return nil, s.listErr + } return providertypes.MergeModelDescriptors(s.listModels), nil } func (s catalogMethodsStub) ListProviderModelsSnapshot(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.snapshotCalls++ + } + if s.snapshotErr != nil { + return nil, s.snapshotErr + } return providertypes.MergeModelDescriptors(s.snapshotModels), nil } func (s catalogMethodsStub) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.cachedCalls++ + } + if s.cachedErr != nil { + return nil, s.cachedErr + } return providertypes.MergeModelDescriptors(s.cachedModels), nil } +type catalogMethodCalls struct { + listCalls int + snapshotCalls int + cachedCalls int +} + type errorCatalogStub struct { err error } From 3319c73752b0705a83b7136203b1689a70a359de Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 11:17:05 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(provider):=E8=A7=A3=E5=86=B3=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openaicompat/openaicompat_test.go | 76 ++++++++++++++++++- internal/provider/openaicompat/response.go | 24 +++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index cbdbac65..a77960f9 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -548,8 +548,57 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { ` events := make(chan providertypes.StreamEvent, 1) err = p.consumeStream(ctx, strings.NewReader(sseData), events) - if err != nil && !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled or nil, got: %v", err) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } + if errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected cancellation to win over stream interruption, got: %v", err) + } +} + +func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelThenErrorReader{cancel: cancel, err: io.ErrClosedPipe} + + err = p.consumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected cancellation to win over stream interruption, got %v", err) + } +} + +func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"}}]} +` + body := &cancelOnEOFReader{ + reader: strings.NewReader(sseData), + cancel: cancel, + } + events := make(chan providertypes.StreamEvent, 8) + + err = p.consumeStream(ctx, body, events) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected cancellation to win over stream interruption, got %v", err) } } @@ -1346,6 +1395,29 @@ type errReader struct{ err error } func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err } +type cancelThenErrorReader struct { + cancel func() + err error +} + +func (r *cancelThenErrorReader) Read(_ []byte) (int, error) { + r.cancel() + return 0, r.err +} + +type cancelOnEOFReader struct { + reader io.Reader + cancel func() +} + +func (r *cancelOnEOFReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if errors.Is(err, io.EOF) { + r.cancel() + } + return n, err +} + type failingReadCloser struct{ err error } func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } diff --git a/internal/provider/openaicompat/response.go b/internal/provider/openaicompat/response.go index dff1a1e1..adb75e30 100644 --- a/internal/provider/openaicompat/response.go +++ b/internal/provider/openaicompat/response.go @@ -76,24 +76,30 @@ func (p *Provider) consumeStream( } for { - line, err := reader.ReadLine() + select { + case <-ctx.Done(): + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return ctx.Err() + default: + } + line, err := reader.ReadLine() if err != nil && !errors.Is(err, io.EOF) { - // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, - // 避免中断前最后一段数据丢失。 if flushErr := flushPendingData(); flushErr != nil { return flushErr } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } return fmt.Errorf("%w: %w", provider.ErrStreamInterrupted, err) } trimmed := line - switch { case strings.HasPrefix(trimmed, "data:"): data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - // data: [DONE] 需要立即处理:先刷新已缓冲的 data 行,再标记结束, - // 避免与前面的合法 JSON 拼接后导致 json.Unmarshal 失败。 if data == "[DONE]" { if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -120,10 +126,10 @@ func (p *Provider) consumeStream( if done { return finishStream() } - if ctx.Err() != nil { - return ctx.Err() + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr } - return provider.ErrStreamInterrupted + return fmt.Errorf("%w: missing [DONE] marker before EOF", provider.ErrStreamInterrupted) } } } From 1c51367a954c3c43279e448108371713a9c04acf Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 12:12:10 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix(provider):=E4=BF=AE=20APIStyle=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/builtin_providers.go | 6 + internal/config/config_test.go | 1 + internal/config/provider_adapter.go | 8 +- internal/config/provider_adapter_test.go | 28 +++++ internal/config/provider_loader.go | 5 +- internal/provider/catalog/service.go | 11 ++ internal/provider/catalog/service_test.go | 108 +++++++++++++++++- .../openaicompat/openaicompat_test.go | 40 ------- 8 files changed, 161 insertions(+), 46 deletions(-) diff --git a/internal/config/builtin_providers.go b/internal/config/builtin_providers.go index 4a53fade..88c8f0fe 100644 --- a/internal/config/builtin_providers.go +++ b/internal/config/builtin_providers.go @@ -20,6 +20,8 @@ const ( QiniuDefaultBaseURL = "https://api.qnaigc.com/v1" QiniuDefaultModel = "openai/gpt-5" QiniuDefaultAPIKeyEnv = "QINIU_API_KEY" + + defaultOpenAICompatibleAPIStyle = "chat_completions" ) const OpenaiCompatDriver = "openaicompat" @@ -32,6 +34,7 @@ func OpenAIProvider() ProviderConfig { BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, + APIStyle: defaultOpenAICompatibleAPIStyle, Source: ProviderSourceBuiltin, } } @@ -44,6 +47,7 @@ func GeminiProvider() ProviderConfig { BaseURL: GeminiDefaultBaseURL, Model: GeminiDefaultModel, APIKeyEnv: GeminiDefaultAPIKeyEnv, + APIStyle: defaultOpenAICompatibleAPIStyle, Source: ProviderSourceBuiltin, } } @@ -56,6 +60,7 @@ func OpenLLProvider() ProviderConfig { BaseURL: OpenLLDefaultBaseURL, Model: OpenLLDefaultModel, APIKeyEnv: OpenLLDefaultAPIKeyEnv, + APIStyle: defaultOpenAICompatibleAPIStyle, Source: ProviderSourceBuiltin, } } @@ -68,6 +73,7 @@ func QiniuProvider() ProviderConfig { BaseURL: QiniuDefaultBaseURL, Model: QiniuDefaultModel, APIKeyEnv: QiniuDefaultAPIKeyEnv, + APIStyle: defaultOpenAICompatibleAPIStyle, Source: ProviderSourceBuiltin, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6b0e0cc3..8ed8fe74 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,6 +23,7 @@ func testDefaultProviderConfig() ProviderConfig { BaseURL: testBaseURL, Model: testModel, APIKeyEnv: testAPIKeyEnv, + APIStyle: defaultOpenAICompatibleAPIStyle, Source: ProviderSourceBuiltin, } } diff --git a/internal/config/provider_adapter.go b/internal/config/provider_adapter.go index b653c348..bc6b9984 100644 --- a/internal/config/provider_adapter.go +++ b/internal/config/provider_adapter.go @@ -1,6 +1,8 @@ package config import ( + "strings" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" ) @@ -22,10 +24,14 @@ func normalizeProviderDriver(driver string) string { // providerIdentityFromConfig 根据 provider 配置构造用于去重与缓存的规范化连接身份。 func providerIdentityFromConfig(cfg ProviderConfig) (provider.ProviderIdentity, error) { + apiStyle := cfg.APIStyle + if normalizeProviderDriver(cfg.Driver) == OpenaiCompatDriver && strings.TrimSpace(apiStyle) == "" { + apiStyle = defaultOpenAICompatibleAPIStyle + } return provider.NormalizeProviderIdentity(provider.ProviderIdentity{ Driver: cfg.Driver, BaseURL: cfg.BaseURL, - APIStyle: cfg.APIStyle, + APIStyle: apiStyle, DeploymentMode: cfg.DeploymentMode, APIVersion: cfg.APIVersion, }) diff --git a/internal/config/provider_adapter_test.go b/internal/config/provider_adapter_test.go index 705a1981..b5e129db 100644 --- a/internal/config/provider_adapter_test.go +++ b/internal/config/provider_adapter_test.go @@ -95,6 +95,34 @@ func TestNewProviderCatalogInputBuiltinIncludesDefaultsAndLazyDiscovery(t *testi } } +func TestNewProviderCatalogInputDefaultsOpenAICompatibleIdentityAPIStyle(t *testing.T) { + t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") + + input, err := NewProviderCatalogInput(ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://API.EXAMPLE.COM/v1/", + Model: "server-default", + APIKeyEnv: "CATALOG_PROVIDER_API_KEY", + Source: ProviderSourceBuiltin, + }) + if err != nil { + t.Fatalf("NewProviderCatalogInput() error = %v", err) + } + + if input.Identity.APIStyle != defaultOpenAICompatibleAPIStyle { + t.Fatalf("expected default api_style %q, got %+v", defaultOpenAICompatibleAPIStyle, input.Identity) + } + + runtimeConfig, err := input.ResolveDiscoveryConfig() + if err != nil { + t.Fatalf("ResolveDiscoveryConfig() error = %v", err) + } + if runtimeConfig.APIStyle != "" { + t.Fatalf("expected runtime config to preserve original empty api_style, got %+v", runtimeConfig) + } +} + func TestNewProviderCatalogInputCustomOmitsDefaultModels(t *testing.T) { t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 943505d6..346b6a87 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -11,9 +11,8 @@ import ( ) const ( - providersDirName = "providers" - customProviderConfigName = "provider.yaml" - defaultOpenAICompatibleAPIStyle = "chat_completions" + providersDirName = "providers" + customProviderConfigName = "provider.yaml" ) type customProviderFile struct { diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index d3d54d1c..b9cf4593 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -159,6 +159,13 @@ func (s *Service) discoverAndPersist(ctx context.Context, input provider.Catalog if !s.registry.Supports(driverType) { return nil, nil } + caps, err := s.registry.DriverTransportCapabilities(driverType) + if err != nil { + return nil, err + } + if !caps.ModelDiscovery { + return nil, nil + } if input.ResolveDiscoveryConfig == nil { return nil, errors.New("provider catalog: discovery config resolver is nil") @@ -195,6 +202,10 @@ func (s *Service) queueRefresh(input provider.CatalogInput) { if s.store == nil || !s.registry.Supports(driverType) { return } + caps, err := s.registry.DriverTransportCapabilities(driverType) + if err != nil || !caps.ModelDiscovery { + return + } identity := input.Identity if identity.Driver == "" || identity.BaseURL == "" { return diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 85c55db6..6691a4be 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -363,6 +363,90 @@ func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) } } +func TestListProviderModelsSkipsDiscoveryWhenDriverDisablesModelDiscovery(t *testing.T) { + t.Setenv(testAPIKeyEnv, "") + + var resolveCalls int32 + var discoverCalls int32 + registry := newRegistryWithCapabilities( + t, + openaicompat.DriverName, + provider.DriverTransportCapabilities{ + Streaming: true, + ToolTransport: true, + ModelDiscovery: false, + ImageInputTransport: false, + }, + func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + return []providertypes.ModelDescriptor{{ID: "server-model", Name: "Server Model"}}, nil + }, + ) + + service := NewService("", registry, newMemoryStore()) + input := openAIProviderSource() + input.ResolveDiscoveryConfig = func() (provider.RuntimeConfig, error) { + atomic.AddInt32(&resolveCalls, 1) + return provider.RuntimeConfig{}, errors.New("resolver should not run") + } + + models, err := service.ListProviderModels(context.Background(), input) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 1 || models[0].ID != config.OpenAIDefaultModel { + t.Fatalf("expected builtin default model fallback, got %+v", models) + } + if atomic.LoadInt32(&resolveCalls) != 0 { + t.Fatalf("expected discovery config resolver to be skipped, got %d calls", resolveCalls) + } + if atomic.LoadInt32(&discoverCalls) != 0 { + t.Fatalf("expected discovery func to be skipped, got %d calls", discoverCalls) + } +} + +func TestListProviderModelsCustomProviderSkipsDiscoveryWhenDriverDisablesModelDiscovery(t *testing.T) { + t.Setenv(testAPIKeyEnv, "") + + var resolveCalls int32 + var discoverCalls int32 + registry := newRegistryWithCapabilities( + t, + openaicompat.DriverName, + provider.DriverTransportCapabilities{ + Streaming: true, + ToolTransport: true, + ModelDiscovery: false, + ImageInputTransport: false, + }, + func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + atomic.AddInt32(&discoverCalls, 1) + return []providertypes.ModelDescriptor{{ID: "server-model", Name: "Server Model"}}, nil + }, + ) + + service := NewService("", registry, newMemoryStore()) + input := customGatewayProviderSource() + input.ResolveDiscoveryConfig = func() (provider.RuntimeConfig, error) { + atomic.AddInt32(&resolveCalls, 1) + return provider.RuntimeConfig{}, errors.New("resolver should not run") + } + + models, err := service.ListProviderModels(context.Background(), input) + if err != nil { + t.Fatalf("ListProviderModels() error = %v", err) + } + if len(models) != 0 { + t.Fatalf("expected custom provider without discovery support to stay empty, got %+v", models) + } + if atomic.LoadInt32(&resolveCalls) != 0 { + t.Fatalf("expected discovery config resolver to be skipped, got %d calls", resolveCalls) + } + if atomic.LoadInt32(&discoverCalls) != 0 { + t.Fatalf("expected discovery func to be skipped, got %d calls", discoverCalls) + } +} + func TestDiscoverAndPersistFailurePaths(t *testing.T) { t.Run("unsupported driver", func(t *testing.T) { service := NewService("", provider.NewRegistry(), newMemoryStore()) @@ -475,12 +559,32 @@ func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) { } func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *provider.Registry { + return newRegistryWithCapabilities( + t, + name, + provider.DriverTransportCapabilities{ + Streaming: true, + ToolTransport: true, + ModelDiscovery: true, + ImageInputTransport: false, + }, + discover, + ) +} + +func newRegistryWithCapabilities( + t *testing.T, + name string, + capabilities provider.DriverTransportCapabilities, + discover provider.DiscoveryFunc, +) *provider.Registry { t.Helper() registry := provider.NewRegistry() if err := registry.Register(provider.DriverDefinition{ - Name: name, - Discover: discover, + Name: name, + Discover: discover, + Capabilities: capabilities, Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return catalogTestProvider{}, nil }, diff --git a/internal/provider/openaicompat/openaicompat_test.go b/internal/provider/openaicompat/openaicompat_test.go index beeff1a0..a77960f9 100644 --- a/internal/provider/openaicompat/openaicompat_test.go +++ b/internal/provider/openaicompat/openaicompat_test.go @@ -602,46 +602,6 @@ func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *tes } } -func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing.T) { - t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - - p, err := New(resolvedConfig("", "")) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - body := &cancelThenErrorReader{cancel: cancel, err: io.ErrClosedPipe} - err = p.consumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } -} - -func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *testing.T) { - t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - - p, err := New(resolvedConfig("", "")) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"}}]} - -` - body := &cancelOnEOFReader{ - reader: strings.NewReader(sseData), - cancel: cancel, - } - events := make(chan providertypes.StreamEvent, 8) - - err = p.consumeStream(ctx, body, events) - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } -} - func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") From 46bc1290a94c60c5090dad1c55f03db2918781a0 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 12:32:23 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/errors.go | 2 -- internal/provider/errors_test.go | 10 +++++++ internal/runtime/runtime_test.go | 50 ++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/internal/provider/errors.go b/internal/provider/errors.go index 3a4e2964..abf0ef1f 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -40,8 +40,6 @@ var contextTooLongFragments = []string{ "maximum context length", "maximum prompt length", "prompt is too long", - "requested too many tokens", - "too many tokens", } // ProviderError 是 provider 层的领域错误类型。 diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index bbcd247b..31314ae5 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -127,6 +127,11 @@ func TestNewProviderErrorFromStatus(t *testing.T) { t.Fatalf("expected code %q, got %q", ErrorCodeContextTooLong, err.Code) } + err = NewProviderErrorFromStatus(400, "requested too many tokens for this minute") + if err.Code != ErrorCodeClient { + t.Fatalf("400 with token throttle message: expected code %q, got %q", ErrorCodeClient, err.Code) + } + // 429 with token-count message must stay rate_limited, not context_too_long. err = NewProviderErrorFromStatus(429, "requested too many tokens for this minute") if err.Code != ErrorCodeRateLimit { @@ -213,6 +218,11 @@ func TestIsContextTooLong(t *testing.T) { err: errors.New("context window exceeded for model"), want: true, }, + { + name: "plain text token throttle is not context too long", + err: errors.New("requested too many tokens for this minute"), + want: false, + }, { name: "non context error", err: errors.New("invalid api key"), diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 2d183eea..8dbe35ab 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3532,7 +3532,7 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { callCount := 0 scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { callCount++ if callCount == 1 { return &provider.ProviderError{ @@ -3670,7 +3670,7 @@ func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { if len(req.Messages) == 3 { return &provider.ProviderError{ StatusCode: 400, @@ -3740,7 +3740,7 @@ func TestServiceRunReactiveCompactRetriesOnlyOnce(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { return &provider.ProviderError{ StatusCode: 400, Code: provider.ErrorCodeContextTooLong, @@ -3799,6 +3799,50 @@ func TestServiceRunReactiveCompactRetriesOnlyOnce(t *testing.T) { } } +func TestServiceRunDoesNotReactiveCompactOnPlainTextTokenThrottle(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + throttleErr := errors.New("requested too many tokens for this minute") + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + return throttleErr + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + service.compactRunner = &stubCompactRunner{} + + err := service.Run(context.Background(), UserInput{ + RunID: "run-plain-text-token-throttle", + Content: "continue", + }) + if err == nil || !containsError(err, throttleErr.Error()) { + t.Fatalf("expected plain text token throttle error, got %v", err) + } + + compactRunner := service.compactRunner.(*stubCompactRunner) + if len(compactRunner.calls) != 0 { + t.Fatalf("expected no reactive compact attempts, got %d", len(compactRunner.calls)) + } + if scripted.callCount != 1 { + t.Fatalf("expected provider to be called once, got %d", scripted.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventError, + }) + assertNoEventType(t, events, EventCompactStart) + assertNoEventType(t, events, EventCompactDone) + assertNoEventType(t, events, EventCompactError) +} + func TestRestoreSessionTokens(t *testing.T) { t.Parallel() From 56d9be02e0a5acb9670c3ba68a016e41c9baf7ce Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 05:53:06 +0000 Subject: [PATCH 08/13] docs(session): restore persistence design notes Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- README.md | 1 + docs/runtime-provider-event-flow.md | 8 +++- docs/session-persistence-design.md | 66 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/session-persistence-design.md diff --git a/README.md b/README.md index cc30f1a9..d175c0d5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ go run ./cmd/neocode - **[扩展 Provider](docs/guides/adding-providers.md)** — 如何添加新的模型提供商 - **[架构设计](docs/neocode-coding-agent-mvp-architecture.md)** — 整体架构与设计理念 - **[事件流](docs/runtime-provider-event-flow.md)** — Runtime 与 Provider 的事件交互 +- **[Session 持久化](docs/session-persistence-design.md)** — 会话 JSON 存储、token 累计与工作区隔离 ## 开发 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 060d6c52..a345c7b1 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -22,7 +22,7 @@ 4. 解析当前 provider 配置并构建 provider 实例。 5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 6. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再继续构造请求。 -7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +7. 调用 `Provider.Generate`,并把流式事件桥接给 TUI。 8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅重试一次当前请求。 9. 保存 assistant 完整回复。 10. 执行返回的工具调用,并保存每一个工具结果。 @@ -86,3 +86,9 @@ runtime 在转发 provider 流式事件时,从 `MessageDone` 事件中提取 ` - assistant 完整回复后保存 - 每个工具结果完成后保存 - 避免在高频 UI 刷新路径中做磁盘 I/O + +## Session 持久化补充 + +- Session 默认按工作区隔离存储到 `~/.neocode/projects//sessions/` +- `runtime` 在加载会话时恢复 `token_input_total` 与 `token_output_total` +- compact 成功后,重置后的 token 累计值会随下一次 session 保存一起持久化 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md new file mode 100644 index 00000000..032fabbd --- /dev/null +++ b/docs/session-persistence-design.md @@ -0,0 +1,66 @@ +# Session 持久化设计 + +## 模块职责与边界 + +- `internal/session` 是会话领域模型、存储抽象与 JSON 持久化实现的唯一归属层。 +- `internal/runtime` 负责决定保存时机、恢复会话状态和编排主循环,不承载文件存储细节。 +- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读写会话文件。 + +## 存储策略 + +NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、可调试且跨平台可移植。 + +- 默认目录按工作区隔离:`~/.neocode/projects//sessions/` +- 工作区哈希基于启动阶段确定的工作区根目录生成 +- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 + +## 数据模型 + +`internal/session.Session` 持久化以下核心字段: + +- `id`、`title` +- `provider`、`model` +- `created_at`、`updated_at` +- `workdir` +- `messages` +- `token_input_total` +- `token_output_total` + +其中: + +- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用 +- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token +- token 字段使用 `omitempty`,以兼容旧版 session JSON 文件 + +`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。 + +## 读写行为 + +- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON +- `Load` 在用户真正进入某个会话时读取完整历史 +- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回 + +## Token 计数持久化 + +- runtime 在 provider 调用完成后更新 session 的累计 token 字段 +- 会话保存时,token 计数随 session 一起持久化 +- 会话重新加载时,runtime 从 session 恢复累计 token +- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化 + +## 保存时机 + +- 用户消息提交后保存 +- assistant 完整回复后保存 +- 每个工具结果完成后保存 +- 避免在高频 UI 刷新路径中直接做磁盘 I/O + +## 并发约束 + +- `internal/session` 的存储实现自行保护共享访问 +- 保存时机统一由 runtime 决定,TUI 不直接触发磁盘写入 + +## 演进约束 + +- 新增存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 +- 不应把持久化逻辑重新分散到 `runtime`、`tui` 或其他上层模块 From ca8f094257a21d3df4054ceead87eea635132909 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 05:55:10 +0000 Subject: [PATCH 09/13] docs(session): dedupe persistence references Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/context-compact.md | 2 +- docs/runtime-provider-event-flow.md | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/context-compact.md b/docs/context-compact.md index 92a92c99..f2a60482 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -40,7 +40,7 @@ context: ## 自动压缩 -当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩,并在成功后重置 token 计数器。 +当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩;token 计数的重置与持久化语义统一见 [Session 持久化设计](./session-persistence-design.md)。 设计原则: - **context 拥有压缩决策权**,runtime 只做编排执行。 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index a345c7b1..8b4cde31 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -87,8 +87,4 @@ runtime 在转发 provider 流式事件时,从 `MessageDone` 事件中提取 ` - 每个工具结果完成后保存 - 避免在高频 UI 刷新路径中做磁盘 I/O -## Session 持久化补充 - -- Session 默认按工作区隔离存储到 `~/.neocode/projects//sessions/` -- `runtime` 在加载会话时恢复 `token_input_total` 与 `token_output_total` -- compact 成功后,重置后的 token 累计值会随下一次 session 保存一起持久化 +会话 JSON 结构、工作区分桶以及 token 计数持久化约束统一见 [Session 持久化设计](./session-persistence-design.md)。 From 3da60fc66c757119de558a4301f83b401352a143 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 15:25:42 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=20current=5Fmodel=20=E5=BF=85=E9=A1=BB=E9=9D=9E?= =?UTF-8?q?=E7=A9=BA=E5=AF=BC=E8=87=B4=E5=8F=AF=E8=83=BD=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 27 +++++++++++++++++++++ internal/config/loader_test.go | 40 +++++++++++++++++++++++++++++++ internal/config/model.go | 2 +- internal/config/selection.go | 3 +++ internal/config/selection_test.go | 25 +++++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8ed8fe74..3d79682b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -528,6 +528,33 @@ func TestConfigValidateFailures(t *testing.T) { } } +func TestConfigValidateAllowsEmptyCurrentModelForSelectedCustomProvider(t *testing.T) { + t.Parallel() + + workdir := filepath.Clean(t.TempDir()) + cfg := Config{ + Providers: []ProviderConfig{ + testDefaultProviderConfig(), + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }, + }, + SelectedProvider: "company-gateway", + CurrentModel: "", + Workdir: workdir, + Shell: "powershell", + } + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + + if err := cfg.Validate(); err != nil { + t.Fatalf("expected selected custom provider with empty current_model to validate, got %v", err) + } +} + func TestMCPConfigApplyDefaultsAndClone(t *testing.T) { t.Parallel() diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8260bba3..d9e1429e 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -241,6 +241,46 @@ shell: powershell } } +func TestLoaderAllowsSelectedCustomProviderWithEmptyCurrentModel(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + rawConfig := ` +selected_provider: company-gateway +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(rawConfig)+"\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +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) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.SelectedProvider != "company-gateway" { + t.Fatalf("expected selected provider %q, got %q", "company-gateway", cfg.SelectedProvider) + } + if cfg.CurrentModel != "" { + t.Fatalf("expected empty current model before discovery, got %q", cfg.CurrentModel) + } +} + func TestLoaderLoadsCustomProvidersFromProvidersDirectory(t *testing.T) { t.Parallel() diff --git a/internal/config/model.go b/internal/config/model.go index e5a0717f..9c0f1643 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -247,7 +247,7 @@ func (c *Config) Validate() error { if err != nil { return err } - if strings.TrimSpace(c.CurrentModel) == "" { + if strings.TrimSpace(c.CurrentModel) == "" && selected.Source != ProviderSourceCustom { return errors.New("config: current_model is empty") } if strings.TrimSpace(c.Workdir) == "" { diff --git a/internal/config/selection.go b/internal/config/selection.go index 036840fb..a7c5a1d7 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -211,6 +211,9 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) if err != nil { return ProviderSelection{}, err } + if !s.supporters.Supports(selected.Driver) { + return ProviderSelection{}, ErrDriverUnsupported + } input, err := catalogInputFromProvider(selected) if err != nil { diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index d9d05478..fe5443c3 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -170,6 +170,31 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { } } +func TestSelectionServiceSetCurrentModelRejectsUnsupportedDriver(t *testing.T) { + t.Parallel() + + defaults := DefaultConfig() + defaults.Providers = []ProviderConfig{{ + Name: OpenAIName, + Driver: "missing-driver", + BaseURL: OpenAIDefaultBaseURL, + Model: OpenAIDefaultModel, + APIKeyEnv: OpenAIDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, + }} + defaults.SelectedProvider = OpenAIName + defaults.CurrentModel = OpenAIDefaultModel + + manager := newSelectionTestManager(t, defaults) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + service := NewSelectionService(manager, supporters, newCatalogStub()) + + _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } +} + func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T) { t.Parallel() From 8b3f7413436cef440abedeb19422d92fdbc0a06e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 16:16:21 +0800 Subject: [PATCH 11/13] =?UTF-8?q?pref(provider):=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8F=91=E7=8E=B0=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/selection.go | 13 ++++- internal/config/selection_test.go | 85 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/internal/config/selection.go b/internal/config/selection.go index a7c5a1d7..0e65eba9 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -305,9 +305,18 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti } if len(models) == 0 { if selected.Source == ProviderSourceCustom { - return selectionFromConfig(cfgSnapshot), nil + if strings.TrimSpace(cfgSnapshot.CurrentModel) == "" { + discovered, discoverErr := s.catalogs.ListProviderModels(ctx, input) + if discoverErr == nil { + models = discovered + } + } + if len(models) == 0 { + return selectionFromConfig(cfgSnapshot), nil + } + } else { + models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) } - models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) } if len(models) == 0 { return ProviderSelection{}, ErrNoModelsAvailable diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index fe5443c3..9dc55f59 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -416,6 +416,91 @@ func TestSelectionServiceEnsureSelectionKeepsCustomSelectionWhenSnapshotMissing( } } +func TestSelectionServiceEnsureSelectionBackfillsEmptyCustomModelFromSynchronousDiscovery(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + defaults.CurrentModel = "" + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{ + {ID: "server-coder", Name: "Server Coder"}, + {ID: "server-chat", Name: "Server Chat"}, + }, + tracker: tracker, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "server-coder" { + t.Fatalf("expected synchronous discovery to backfill first discovered model, got %+v", selection) + } + if tracker.listCalls != 1 || tracker.snapshotCalls != 1 { + t.Fatalf("expected custom ensure to try snapshot first and then sync discovery, got %+v", *tracker) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.CurrentModel != "server-coder" { + t.Fatalf("expected discovered current model to persist, got %q", reloaded.CurrentModel) + } +} + +func TestSelectionServiceEnsureSelectionKeepsEmptyCustomModelWhenSynchronousDiscoveryFails(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "company-gateway" + defaults.CurrentModel = "" + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, defaults) + service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listErr: errors.New("discover failed"), + tracker: tracker, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() should preserve the empty model when discovery fails, got %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "" { + t.Fatalf("expected empty custom model to stay unchanged after failed discovery, got %+v", selection) + } + if tracker.listCalls != 1 || tracker.snapshotCalls != 1 { + t.Fatalf("expected custom ensure to attempt sync discovery once, got %+v", *tracker) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.CurrentModel != "" { + t.Fatalf("expected empty current model to stay unchanged after failed discovery, got %q", reloaded.CurrentModel) + } +} + func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t *testing.T) { t.Parallel() From 9c1ced67ff962be98b3d97375c48e8917f3bbb93 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 16:28:53 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime_test.go | 2 +- internal/tui/bootstrap/builder_test.go | 4 +-- internal/tui/core/app/commands_test.go | 5 ++-- internal/tui/core/app/update_test.go | 39 ++++++++++++-------------- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f24e6763..acb72e97 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3567,7 +3567,7 @@ func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { callCount := 0 scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { callCount++ if callCount == 1 { return &provider.ProviderError{ diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 4525e8d0..deeb87e2 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -240,11 +240,11 @@ func (s noopProviderService) SelectProvider(ctx context.Context, providerID stri return config.ProviderSelection{}, nil } -func (s noopProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s noopProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, nil } -func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, nil } diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index db1b0f6a..b2a9ab82 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/list" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" tuistatus "neo-code/internal/tui/core/status" ) @@ -162,11 +163,11 @@ func (s errorProviderService) SelectProvider(ctx context.Context, providerID str return config.ProviderSelection{}, s.err } -func (s errorProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s errorProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, s.err } -func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return nil, s.err } diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index ae196767..a5d47680 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -20,7 +20,7 @@ import ( type stubProviderService struct { providers []config.ProviderCatalogItem - models []config.ModelDescriptor + models []providertypes.ModelDescriptor } func (s stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { @@ -35,11 +35,11 @@ func (s stubProviderService) SelectProvider(ctx context.Context, providerID stri return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil } -func (s stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s stubProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return s.models, nil } -func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { +func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { return s.models, nil } @@ -112,20 +112,19 @@ func newTestApp(t *testing.T) (App, *stubRuntime) { } var providers []config.ProviderCatalogItem - var models []config.ModelDescriptor + var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] providers = []config.ProviderCatalogItem{ { - ID: provider.Name, - Name: provider.Name, - Description: "test provider", - Models: []config.ModelDescriptor{ + ID: provider.Name, + Name: provider.Name, + Models: []providertypes.ModelDescriptor{ {ID: provider.Model, Name: provider.Model}, }, }, } - models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + models = []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} } runtime := newStubRuntime() @@ -801,20 +800,19 @@ func TestNewWithBootstrapSuccess(t *testing.T) { } var providers []config.ProviderCatalogItem - var models []config.ModelDescriptor + var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] providers = []config.ProviderCatalogItem{ { - ID: provider.Name, - Name: provider.Name, - Description: "test provider", - Models: []config.ModelDescriptor{ + ID: provider.Name, + Name: provider.Name, + Models: []providertypes.ModelDescriptor{ {ID: provider.Model, Name: provider.Model}, }, }, } - models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + models = []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} } runtime := newStubRuntime() @@ -875,20 +873,19 @@ func TestNewUsesBootstrap(t *testing.T) { } var providers []config.ProviderCatalogItem - var models []config.ModelDescriptor + var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] providers = []config.ProviderCatalogItem{ { - ID: provider.Name, - Name: provider.Name, - Description: "test provider", - Models: []config.ModelDescriptor{ + ID: provider.Name, + Name: provider.Name, + Models: []providertypes.ModelDescriptor{ {ID: provider.Model, Name: provider.Model}, }, }, } - models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + models = []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} } app, err := New(cfg, manager, newStubRuntime(), stubProviderService{providers: providers, models: models}) From b8dc34016bb8fb4aa12b8ab45479ef8fad9f6c27 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 10 Apr 2026 16:33:17 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(provider):=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/infra/infra_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index 1e9775ec..6c993771 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -271,7 +271,7 @@ func TestDefaultWorkspaceCommandExecutor(t *testing.T) { cfg := config.Config{ Workdir: workdir, Shell: shellName, - ToolTimeoutSec: 1, + ToolTimeoutSec: 3, } if _, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", " "); err == nil { @@ -302,7 +302,9 @@ func TestDefaultWorkspaceCommandExecutor(t *testing.T) { t.Fatalf("expected failing command to return sanitized output") } - output, err = DefaultWorkspaceCommandExecutor(context.Background(), cfg, workdir, sleepCmd) + timeoutCfg := cfg + timeoutCfg.ToolTimeoutSec = 1 + output, err = DefaultWorkspaceCommandExecutor(context.Background(), timeoutCfg, workdir, sleepCmd) if err == nil || !strings.Contains(err.Error(), "timed out") { t.Fatalf("expected timeout error, got err=%v output=%q", err, output) }