From ada183370d972dc639c833a1115c3419ec981815 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 7 Apr 2026 22:59:55 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(provider):=E6=A8=A1=E5=9E=8B=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BE=93=E5=87=BA=E4=B8=AD=E9=80=94=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=BD=86=E7=95=8C=E9=9D=A2=E6=97=A0=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/openai/openai_test.go | 24 ++++++---- internal/provider/openai/response.go | 12 +++++ internal/runtime/runtime.go | 17 ++++++- internal/runtime/runtime_test.go | 61 ++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 33179a2e..d16edc56 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -37,12 +37,12 @@ func TestWithTransport(t *testing.T) { customTransport := &http.Transport{} cfg := resolvedConfig("", "") - provider, err := New(cfg, withTransport(customTransport)) + p, err := New(cfg, withTransport(customTransport)) if err != nil { t.Fatalf("New() error = %v", err) } - if provider.client.Transport != customTransport { + if p.client.Transport != customTransport { t.Fatal("expected custom transport to be set") } } @@ -95,11 +95,11 @@ func TestNewDefaultTransportWhenNoOption(t *testing.T) { t.Parallel() cfg := resolvedConfig("", "") - provider, err := New(cfg) + p, err := New(cfg) if err != nil { t.Fatalf("New() error = %v", err) } - if provider.client.Transport == nil { + if p.client.Transport == nil { t.Fatal("expected default transport to be set") } } @@ -130,13 +130,13 @@ func TestDiscoverModels(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, "")) + p, err := New(resolvedConfig(server.URL, "")) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - models, err := provider.DiscoverModels(context.Background()) + models, err := p.DiscoverModels(context.Background()) if err != nil { t.Fatalf("DiscoverModels() error = %v", err) } @@ -490,6 +490,7 @@ func TestConsumeStream_MultiLineDataPayload(t *testing.T) { sseData := `data: {"id":"a","choices":[{"delta":{"content":"part1"},"finish_reason":""}]} data: {"id":"b","choices":[{"delta":{"content":"part2"},"finish_reason":"stop"}]} +data: [DONE] ` events := make(chan providertypes.StreamEvent, 8) @@ -503,7 +504,7 @@ data: {"id":"b","choices":[{"delta":{"content":"part2"},"finish_reason":"stop"}] } } -func TestConsumeStream_EOFWithoutDone(t *testing.T) { +func TestConsumeStream_EOFWithoutDoneReturnsInterrupted(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") p, err := New(resolvedConfig("", "")) @@ -515,8 +516,11 @@ func TestConsumeStream_EOFWithoutDone(t *testing.T) { ` events := make(chan providertypes.StreamEvent, 4) err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) - if err != nil { - t.Fatalf("consumeStream() error = %v", err) + if err == nil { + t.Fatal("expected interrupted error for EOF without [DONE]") + } + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got %v", err) } drained := drainStreamEvents(events) var foundText bool diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 02b7b9b5..5026a15d 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log" "strings" "neo-code/internal/provider" @@ -67,6 +68,7 @@ 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) } @@ -114,6 +116,16 @@ 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/runtime/runtime.go b/internal/runtime/runtime.go index 7b727e08..0ea4361b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -34,8 +34,9 @@ const ( // streamAccumulator 在流式事件处理过程中累积本轮对话需要持久化的助手消息状态, // 包括文本内容和工具调用列表。 type streamAccumulator struct { - content strings.Builder - toolCalls map[int]*providertypes.ToolCall + content strings.Builder + toolCalls map[int]*providertypes.ToolCall + messageDone bool } // newStreamAccumulator 创建并初始化一个空的流式事件累积器。 @@ -294,6 +295,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, session.ID, err) } if len(assistant.ToolCalls) == 0 { + log.Printf("[DEBUG-RUNTIME] No tool calls in assistant response, content length=%d, emitting EventAgentDone", + len(assistant.Content)) s.emit(ctx, EventAgentDone, input.RunID, session.ID, assistant) return nil } @@ -537,6 +540,9 @@ func handleProviderStreamEvent( if _, err := event.MessageDoneValue(); err != nil { return err } + if acc != nil { + acc.messageDone = true + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -679,6 +685,10 @@ func (s *Service) callProviderWithRetry( err = modelProvider.Chat(ctx, req, streamEvents) close(streamEvents) forwardErr := <-streamDone + + // [DEBUG] 打印 provider.Chat() 返回状态和转发错误 + log.Printf("[DEBUG-RUNTIME] provider.Chat() returned: err=%v, forwardErr=%v", err, forwardErr) + if forwardErr != nil { if err != nil { return nil, fmt.Errorf("runtime: provider stream handling failed after provider error: %v: %w", err, forwardErr) @@ -687,6 +697,9 @@ func (s *Service) callProviderWithRetry( } if err == nil { + if !acc.messageDone { + return nil, fmt.Errorf("%w: provider stream ended without message_done event", provider.ErrStreamInterrupted) + } return acc, nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0fe9f86a..b899eb56 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -121,6 +121,13 @@ func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatReque return ctx.Err() } } + if callIndex >= len(p.responses) && !streamContainsMessageDone(p.streams[callIndex]) { + select { + case events <- providertypes.NewMessageDoneStreamEvent("", nil): + case <-ctx.Done(): + return ctx.Err() + } + } } if callIndex < len(p.responses) { response := p.responses[callIndex] @@ -153,6 +160,16 @@ func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatReque return nil } +// streamContainsMessageDone 判断测试流中是否已显式包含结束事件,避免辅助 provider 重复补发 message_done。 +func streamContainsMessageDone(events []providertypes.StreamEvent) bool { + for _, event := range events { + if event.Type == providertypes.StreamEventMessageDone { + return true + } + } + return false +} + type scriptedProviderFactory struct { provider provider.Provider calls int @@ -469,7 +486,7 @@ func TestServiceRunMergesLateToolCallMetadata(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) if err := service.Run(context.Background(), UserInput{RunID: "run-late-tool-metadata", Content: "edit"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -520,7 +537,7 @@ func TestServiceRunRejectsToolCallWithoutID(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) err := service.Run(context.Background(), UserInput{RunID: "run-missing-tool-id", Content: "edit"}) if err == nil || !containsError(err, "without id") { t.Fatalf("expected missing tool id error, got %v", err) @@ -553,6 +570,41 @@ func TestServiceRunRejectsMalformedProviderStreamEvent(t *testing.T) { } } +func TestServiceRunRejectsProviderCompletionWithoutMessageDone(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + select { + case events <- providertypes.NewTextDeltaStreamEvent("partial"): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + err := service.Run(context.Background(), UserInput{RunID: "run-missing-message-done", Content: "hello"}) + if err == nil || !containsError(err, "without message_done") { + t.Fatalf("expected missing message_done error, got %v", err) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentChunk, EventError}) + assertNoEventType(t, events, EventAgentDone) + + session := onlySession(t, store) + if len(session.Messages) != 1 || session.Messages[0].Role != providertypes.RoleUser { + t.Fatalf("expected only user message to persist after missing message_done, got %+v", session.Messages) + } +} + func TestServiceRunMalformedProviderStreamEventDoesNotDeadlock(t *testing.T) { t.Parallel() @@ -1414,6 +1466,7 @@ func TestServiceRunErrorPaths(t *testing.T) { } } events <- providertypes.NewTextDeltaStreamEvent("recovered") + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) return nil }, } @@ -2213,6 +2266,7 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { } <-unblockProvider events <- providertypes.NewTextDeltaStreamEvent("done") + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) return nil }, } @@ -2462,6 +2516,7 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri t.Helper() apiKeyEnv := runtimeTestAPIKeyEnv(t) + defaultWorkdir := t.TempDir() restoreRuntimeEnv(t, apiKeyEnv) if err := os.Setenv(apiKeyEnv, "test-key"); err != nil { t.Fatalf("set env: %v", err) @@ -2491,6 +2546,7 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.ToolTimeoutSec = 1 cfg.MaxLoops = 4 + cfg.Workdir = defaultWorkdir return nil }); err != nil { t.Fatalf("update config: %v", err) @@ -2730,6 +2786,7 @@ func TestIsRetryableProviderError(t *testing.T) { {"non-retryable provider error", &provider.ProviderError{Retryable: false}, false}, {"plain error", errors.New("something failed"), false}, {"wrapped retryable", fmt.Errorf("wrapped: %w", &provider.ProviderError{Retryable: true}), true}, + {"stream interrupted sentinel", provider.ErrStreamInterrupted, false}, } for _, tt := range tests { From 0292f648e5adb777c429533487cfc61514832f36 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 8 Apr 2026 15:35:46 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(runtime)=EF=BC=9A=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8f38a8a4..2e13466d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -133,17 +133,17 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 - sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 - toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 - providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 - contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 - compactRunner contextcompact.Runner - events chan RuntimeEvent - operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 - runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 - activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 - nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 + sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 + toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 + providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 + contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 + compactRunner contextcompact.Runner + events chan RuntimeEvent + operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 + runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 sessionInputTokens int // 当前会话累计输入 token。 sessionOutputTokens int // 当前会话累计输出 token。 @@ -532,12 +532,16 @@ func handleProviderStreamEvent( acc.accumulateToolCallDelta(payload.Index, payload.ID, payload.ArgumentsDelta) } case providertypes.StreamEventMessageDone: - if _, err := event.MessageDoneValue(); err != nil { + payload, err := event.MessageDoneValue() + if err != nil { return err } if acc != nil { acc.messageDone = true } + if onMessageDone != nil { + onMessageDone(payload) + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -579,8 +583,8 @@ func (s *Service) forwardProviderEvents( s.sessionInputTokens += done.Usage.InputTokens s.sessionOutputTokens += done.Usage.OutputTokens s.emit(ctx, EventTokenUsage, runID, sessionID, TokenUsagePayload{ - InputTokens: done.Usage.InputTokens, - OutputTokens: done.Usage.OutputTokens, + InputTokens: done.Usage.InputTokens, + OutputTokens: done.Usage.OutputTokens, SessionInputTokens: s.sessionInputTokens, SessionOutputTokens: s.sessionOutputTokens, }) 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 3/7] =?UTF-8?q?pref(provider):=E4=BF=AE=E6=94=B9=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E5=A5=91=E7=BA=A6=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=83=BD?= =?UTF-8?q?=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 4/7] =?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 acf13dee35eadc73c6de18f7d795b1990ca10688 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 9 Apr 2026 10:07:41 +0800 Subject: [PATCH 5/7] =?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 --- README.md | 40 ++- docs/tools-and-tui-integration.md | 29 -- internal/app/bootstrap.go | 2 +- internal/config/builtin_providers.go | 4 + internal/config/config_test.go | 160 ++++++++-- internal/config/loader.go | 5 + internal/config/loader_test.go | 229 ++++++++++++++ internal/config/model.go | 206 +++++++++++- internal/config/model_descriptor.go | 294 +++++++++++++++++ internal/config/provider_identity.go | 89 +++++- internal/config/provider_identity_test.go | 71 +++++ internal/config/provider_loader.go | 130 ++++++++ internal/config/selection.go | 364 +++++++--------------- internal/config/selection_test.go | 193 +++++++++++- internal/provider/capabilities.go | 34 -- internal/provider/catalog/service.go | 58 +++- internal/provider/catalog/service_test.go | 104 ++++++- internal/provider/catalog/store.go | 4 +- internal/provider/catalog/store_test.go | 63 +++- internal/tui/core/app/commands.go | 5 +- 20 files changed, 1659 insertions(+), 425 deletions(-) delete mode 100644 docs/tools-and-tui-integration.md create mode 100644 internal/config/model_descriptor.go create mode 100644 internal/config/provider_identity_test.go create mode 100644 internal/config/provider_loader.go diff --git a/README.md b/README.md index 95135439..cee6c393 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,44 @@ go run ./cmd/neocode - `/provider` — 切换内建模型提供商 - `/model` — 切换模型 +## Provider 配置 + +NeoCode 当前将运行时全局配置与自定义 provider 定义拆开管理: + +- `~/.neocode/config.yaml` 只保存 `selected_provider`、`current_model`、`shell`、工具与上下文等全局配置 +- 内建 provider 继续由代码提供,不写入 `config.yaml` +- 自定义 provider 统一放在 `~/.neocode/providers//` 目录中 + +目录结构示例: + +```text +~/.neocode/ + config.yaml + providers/ + company-gateway/ + provider.yaml +``` + +`provider.yaml` 用来描述连接与驱动信息;custom provider 的模型列表只允许来自远程发现,不会读取目录中的 `models.yaml`。例如: + +```yaml +# ~/.neocode/providers/company-gateway/provider.yaml +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 +``` + +说明: + +- 自定义 provider 名称不能与内建 provider 或其他自定义 provider 重复 +- 自定义 provider 的旧 `driver: openai` 命名已不再接受 +- custom provider 不允许声明 `default_model` +- custom provider 的模型列表仅来自远程发现;目录中的 `models.yaml` 不会被读取 + ## 架构概览 ``` @@ -191,7 +229,7 @@ MIT ## Manual Compact -NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。配置项见 `docs/guides/configuration.md`,流程和摘要约定见 `docs/context-compact.md`。 +NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。当前相关迁移背景与摘要生成设计可参考 `docs/phase2-runtime-migration.md`。 ## CLI Workdir diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md deleted file mode 100644 index b41a38a3..00000000 --- a/docs/tools-and-tui-integration.md +++ /dev/null @@ -1,29 +0,0 @@ -# Tools 与 TUI 集成设计 -## 工具契约 -每个工具都应提供: -- 合法且稳定的工具名 -- 面向模型的简明描述 -- 类 JSON Schema 的参数定义 -- 接收 `context.Context` 和结构化输入的 `Execute` 方法 - -## Registry 职责 -- 以名字注册工具 -- 向 Provider 返回可消费的工具 schema 列表 -- 根据工具名分发执行,并把失败规范化为可回灌给模型的 ToolResult - -## 当前工具集 -- `filesystem_read_file` -- `filesystem_write_file` -- `filesystem_grep` -- `filesystem_glob` -- `filesystem_edit` -- `bash` -- `webfetch` - -## TUI 集成方式 -- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 -- runtime 事件以内联形式渲染到 transcript 中,而不是单独拆出控制台面板 -- 工具开始和结束事件会以轻量提示插入聊天流,使交互更沉浸 - -## 交互原则 -Composer 是唯一的控制入口。只要某个功能本质上是在修改本地 Agent 状态,优先通过 Slash Command 发现和触发,而不是继续叠加额外快捷键。 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/config/builtin_providers.go b/internal/config/builtin_providers.go index 57d1a2eb..4753daa8 100644 --- a/internal/config/builtin_providers.go +++ b/internal/config/builtin_providers.go @@ -30,6 +30,7 @@ func OpenAIProvider() ProviderConfig { BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -41,6 +42,7 @@ func GeminiProvider() ProviderConfig { BaseURL: GeminiDefaultBaseURL, Model: GeminiDefaultModel, APIKeyEnv: GeminiDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -52,6 +54,7 @@ func OpenLLProvider() ProviderConfig { BaseURL: OpenLLDefaultBaseURL, Model: OpenLLDefaultModel, APIKeyEnv: OpenLLDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } @@ -63,6 +66,7 @@ func QiniuProvider() ProviderConfig { BaseURL: QiniuDefaultBaseURL, Model: QiniuDefaultModel, APIKeyEnv: QiniuDefaultAPIKeyEnv, + Source: ProviderSourceBuiltin, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20dc6c24..643aafe0 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, + Source: ProviderSourceBuiltin, } } @@ -574,6 +575,30 @@ func TestProviderConfigValidateFailures(t *testing.T) { }, expectErr: "driver is empty", }, + { + name: "legacy openai driver rejected for custom provider", + 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{ @@ -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,45 @@ 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) } } @@ -1267,6 +1330,27 @@ func TestDescriptorFromRawModel(t *testing.T) { }, 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 { @@ -1295,6 +1379,9 @@ func TestDescriptorFromRawModel(t *testing.T) { if got.MaxOutputTokens != tt.want.MaxOutputTokens { t.Fatalf("expected MaxOutputTokens=%d, got %d", tt.want.MaxOutputTokens, got.MaxOutputTokens) } + if got.CapabilityHints != tt.want.CapabilityHints { + t.Fatalf("expected CapabilityHints=%+v, got %+v", tt.want.CapabilityHints, got.CapabilityHints) + } }) } } @@ -1375,41 +1462,46 @@ func TestFirstPositiveInt(t *testing.T) { } } -func TestBoolMapValue(t *testing.T) { +func TestModelCapabilityHintsFromValue(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") + 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["c"] { - t.Fatalf("expected c=false") + if result.ImageInput != ModelCapabilityStateUnsupported { + t.Fatalf("expected image input unsupported, got %+v", result) } - - if result := boolMapValue("not a map"); result != nil { - t.Fatalf("expected nil for non-map, got %v", result) + if result := modelCapabilityHintsFromValue("not a map"); result != (ModelCapabilityHints{}) { + t.Fatalf("expected empty hints for non-map, got %+v", result) } } -func TestMergeStringBoolMaps(t *testing.T) { +func TestMergeModelCapabilityHints(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)") + primary := ModelCapabilityHints{ + ToolCalling: ModelCapabilityStateSupported, } - if result["b"] { - t.Fatalf("expected b=false") + secondary := ModelCapabilityHints{ + ToolCalling: ModelCapabilityStateUnsupported, + ImageInput: ModelCapabilityStateUnsupported, + ReasoningMode: ModelReasoningModeConfigurable, } - if result := mergeStringBoolMaps(nil, nil); result != nil { - t.Fatalf("expected nil for both empty") + 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) } } diff --git a/internal/config/loader.go b/internal/config/loader.go index 2cf6e693..9a7af858 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 diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index bfbf7513..6b5d4c22 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -240,3 +240,232 @@ 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) + } +} diff --git a/internal/config/model.go b/internal/config/model.go index 4281b4b1..24cc06b1 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -46,12 +46,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 []ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` } type ResolvedProviderConfig struct { @@ -150,9 +162,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 +178,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 } @@ -219,7 +229,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 +253,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 { @@ -285,10 +295,16 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Driver) == "" { return fmt.Errorf("provider %q driver is empty", p.Name) } + if p.Source == ProviderSourceCustom && 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) == "" { @@ -301,7 +317,7 @@ func (p ProviderConfig) Validate() error { } func (p ProviderConfig) Identity() (ProviderIdentity, error) { - return NewProviderIdentity(p.Driver, p.BaseURL) + return NewProviderIdentityFromConfig(p) } func (p ProviderConfig) ResolveAPIKey() (string, error) { @@ -689,11 +705,173 @@ 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 = 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 = 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 sameProviderDefinition(existing, candidate) +} + +// sameProviderDefinition 判断两个 provider 是否是同一条定义的重复快照。 +func sameProviderDefinition(left ProviderConfig, right ProviderConfig) bool { + if NormalizeProviderName(left.Name) != NormalizeProviderName(right.Name) { + return false + } + if NormalizeProviderDriver(left.Driver) != NormalizeProviderDriver(right.Driver) { + return false + } + if normalizedBaseURLOrRaw(left.BaseURL) != normalizedBaseURLOrRaw(right.BaseURL) { + return false + } + if NormalizeKey(left.Model) != NormalizeKey(right.Model) { + return false + } + if NormalizeKey(left.APIKeyEnv) != NormalizeKey(right.APIKeyEnv) { + return false + } + if NormalizeProviderAPIStyle(left.APIStyle) != NormalizeProviderAPIStyle(right.APIStyle) { + return false + } + if NormalizeProviderDeploymentMode(left.DeploymentMode) != NormalizeProviderDeploymentMode(right.DeploymentMode) { + return false + } + if NormalizeProviderAPIVersion(left.APIVersion) != NormalizeProviderAPIVersion(right.APIVersion) { + return false + } + return sameModelDescriptors(left.Models, right.Models) +} + +// sameModelDescriptors 判断两个模型描述列表是否表示同一份配置快照。 +func sameModelDescriptors(left []ModelDescriptor, right []ModelDescriptor) bool { + if len(left) != len(right) { + return false + } + + for index := range left { + if normalizeModelDescriptor(left[index]) != normalizeModelDescriptor(right[index]) { + return false + } + } + return true +} + +// normalizedBaseURLOrRaw 优先返回规范化 base_url,解析失败时退回原始值以保持比较稳定。 +func normalizedBaseURLOrRaw(baseURL string) string { + normalized, err := NormalizeProviderBaseURL(baseURL) + if err == nil { + return normalized + } + return strings.TrimSpace(baseURL) +} + func defaultSelectedProviderName(providers []ProviderConfig, fallback string) string { if containsProviderName(providers, fallback) { return strings.TrimSpace(fallback) diff --git a/internal/config/model_descriptor.go b/internal/config/model_descriptor.go new file mode 100644 index 00000000..fa7281fd --- /dev/null +++ b/internal/config/model_descriptor.go @@ -0,0 +1,294 @@ +package config + +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 描述配置层关注的模型能力提示。 +type ModelCapabilityHints struct { + ToolCalling ModelCapabilityState `json:"tool_calling,omitempty"` + ImageInput ModelCapabilityState `json:"image_input,omitempty"` + ReasoningMode ModelReasoningMode `json:"reasoning_mode,omitempty"` +} + +// 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"` + 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 +} + +// 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) +} + +// 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 +} + +// 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 "" + } +} + +// 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/config/provider_identity.go b/internal/config/provider_identity.go index 93f01207..89df423d 100644 --- a/internal/config/provider_identity.go +++ b/internal/config/provider_identity.go @@ -9,12 +9,30 @@ import ( // ProviderIdentity identifies a concrete provider endpoint for discovery and caching. type ProviderIdentity struct { - Driver string `json:"driver"` - BaseURL string `json:"base_url"` + 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"` } func (i ProviderIdentity) Key() string { - return i.Driver + "|" + i.BaseURL + 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() } func NormalizeKey(s string) string { @@ -29,6 +47,21 @@ 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) +} + func NormalizeProviderBaseURL(raw string) (string, error) { value := strings.TrimSpace(raw) if value == "" { @@ -74,3 +107,53 @@ func NewProviderIdentity(driver string, baseURL string) (ProviderIdentity, error 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) + } +} + +// NewProviderIdentityFromConfig 根据 provider 配置生成去重与缓存使用的规范化连接身份。 +func NewProviderIdentityFromConfig(provider ProviderConfig) (ProviderIdentity, error) { + return NormalizeProviderIdentity(ProviderIdentity{ + Driver: provider.Driver, + BaseURL: provider.BaseURL, + APIStyle: provider.APIStyle, + DeploymentMode: provider.DeploymentMode, + APIVersion: provider.APIVersion, + }) +} diff --git a/internal/config/provider_identity_test.go b/internal/config/provider_identity_test.go new file mode 100644 index 00000000..869ca9cc --- /dev/null +++ b/internal/config/provider_identity_test.go @@ -0,0 +1,71 @@ +package config + +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 TestNewProviderIdentityFromConfigUsesDriverSpecificNormalization(t *testing.T) { + t.Parallel() + + provider := ProviderConfig{ + Name: "gateway", + Driver: " OpenAICompat ", + BaseURL: "https://API.EXAMPLE.COM/v1/", + Model: "gpt-4.1", + APIKeyEnv: "GATEWAY_API_KEY", + APIStyle: " Responses ", + Source: ProviderSourceCustom, + } + + identity, err := NewProviderIdentityFromConfig(provider) + if err != nil { + t.Fatalf("NewProviderIdentityFromConfig() 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) + } +} diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go new file mode 100644 index 00000000..96726c34 --- /dev/null +++ b/internal/config/provider_loader.go @@ -0,0 +1,130 @@ +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"` +} + +// 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), + ) + } + + cfg := ProviderConfig{ + Name: strings.TrimSpace(file.Name), + Driver: strings.TrimSpace(file.Driver), + BaseURL: strings.TrimSpace(resolveCustomProviderBaseURL(file)), + APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), + APIStyle: strings.TrimSpace(file.OpenAICompatible.APIStyle), + DeploymentMode: strings.TrimSpace(file.Gemini.DeploymentMode), + APIVersion: strings.TrimSpace(file.Anthropic.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 +} + +// resolveCustomProviderBaseURL 根据 driver 对应的配置块提取最终生效的接入地址。 +func resolveCustomProviderBaseURL(file customProviderFile) string { + return firstNonEmptyString( + file.BaseURL, + file.OpenAICompatible.BaseURL, + file.Gemini.BaseURL, + file.Anthropic.BaseURL, + ) +} diff --git a/internal/config/selection.go b/internal/config/selection.go index 54963402..aacf29d9 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -3,6 +3,7 @@ package config import ( "context" "errors" + "fmt" "strings" ) @@ -10,27 +11,15 @@ import ( 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 []ModelDescriptor `json:"models,omitempty"` } // ProviderSelection 表示当前选中的 provider 和 model。 @@ -39,130 +28,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) } -// 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 @@ -191,7 +85,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 +100,13 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri return ProviderSelection{}, ErrDriverUnsupported } - models, err := s.catalogs.ListProviderModelsCached(ctx, providerCfg) + models, err := s.catalogs.ListProviderModels(ctx, providerCfg) 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,7 +131,7 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri return selection, nil } -// ListModels 获取当前选中 provider 的模型列表(可触发远程刷新)。 +// ListModels 获取当前选中 provider 的模型列表,必要时会同步触发远程发现。 func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err @@ -250,7 +147,7 @@ func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, e return s.catalogs.ListProviderModels(ctx, selected) } -// ListModelsSnapshot 获取当前选中 provider 的缓存模型列表。 +// ListModelsSnapshot 获取当前选中 provider 的快照模型列表,不阻塞等待同步发现。 func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err @@ -266,7 +163,7 @@ func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]ModelDescr return s.catalogs.ListProviderModelsSnapshot(ctx, selected) } -// 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 +179,27 @@ func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) return ProviderSelection{}, err } - models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + models, err := s.catalogs.ListProviderModels(ctx, selected) 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 +219,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 @@ -316,26 +255,36 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti } cfgSnapshot := s.manager.Get() - selected, err := cfgSnapshot.SelectedProviderConfig() + selected, selectionChanged, err := s.resolveSupportedProvider(cfgSnapshot) if err != nil { return ProviderSelection{}, err } - models, err := s.catalogs.ListProviderModelsSnapshot(ctx, selected) + models, err := s.catalogs.ListProviderModels(ctx, selected) if err != nil { return ProviderSelection{}, err } - nextModel, changed := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) - if !changed { + if len(models) == 0 { + return ProviderSelection{}, ErrNoModelsAvailable + } + _, modelChanged := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) + if !selectionChanged && !modelChanged { return selectionFromConfig(cfgSnapshot), nil } var selection ProviderSelection err = s.manager.Update(ctx, func(cfg *Config) error { - if _, err := cfg.SelectedProviderConfig(); err != nil { - return err + nextSelected := selected + if selectionChanged { + cfg.SelectedProvider = selected.Name + } else { + resolved, err := cfg.SelectedProviderConfig() + if err != nil { + return err + } + nextSelected = resolved } - cfg.CurrentModel = nextModel + cfg.CurrentModel, _ = resolveCurrentModel(cfg.CurrentModel, models, nextSelected.Model) selection = selectionFromConfig(*cfg) return nil }) @@ -346,6 +295,25 @@ func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelecti return selection, nil } +// resolveSupportedProvider 返回当前可用的 provider;若已选 provider 不受支持,则回退到首个受支持项。 +func (s *SelectionService) resolveSupportedProvider(cfg Config) (ProviderConfig, bool, error) { + selected, err := cfg.SelectedProviderConfig() + if err == nil && s.supporters.Supports(selected.Driver) { + return selected, false, nil + } + + for _, providerCfg := range cfg.Providers { + if s.supporters.Supports(providerCfg.Driver) { + return providerCfg, true, nil + } + } + + if err != nil { + return ProviderConfig{}, false, err + } + return ProviderConfig{}, false, ErrDriverUnsupported +} + func (s *SelectionService) validate() error { if s == nil { return errors.New("selection: service is nil") @@ -367,8 +335,6 @@ func (s *SelectionService) selectedProviderConfig() (ProviderConfig, error) { return cfg.SelectedProviderConfig() } -// ---- 内部辅助函数 ---- - func selectionFromConfig(cfg Config) ProviderSelection { return ProviderSelection{ ProviderID: cfg.SelectedProvider, @@ -387,6 +353,13 @@ 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 } @@ -411,128 +384,3 @@ func containsModelDescriptorID(models []ModelDescriptor, modelID string) bool { } 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..1eea3475 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -7,11 +7,11 @@ import ( "testing" ) -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 { @@ -52,7 +52,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 +62,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 +70,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 +86,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: []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 +122,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 +141,108 @@ func TestSelectionServiceEnsureSelectionRepairsInvalidCurrentModel(t *testing.T) } } +func TestSelectionServiceEnsureSelectionFallsBackFromUnsupportedSelectedProvider(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 = "deepseek-coder" + + manager := newSelectionTestManager(t, defaults) + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openai": true}} + service := NewSelectionService(manager, supporters, newCatalogStub()) + + 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 fallback to builtin openai selection, got %+v", selection) + } +} + +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: []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 +253,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()) + 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 +325,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 +383,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) { @@ -305,14 +432,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{ {ID: "gpt-4o"}, }, fallback: "gpt-4.1", - expected: "unknown-model", - changed: false, + expected: "gpt-4o", + changed: true, }, } @@ -356,6 +483,40 @@ func (catalogStub) ListProviderModelsCached(_ context.Context, cfg ProviderConfi return defaultModelsForProvider(cfg), nil } +type catalogMethodsStub struct { + listModels []ModelDescriptor + snapshotModels []ModelDescriptor + cachedModels []ModelDescriptor +} + +func (s catalogMethodsStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return MergeModelDescriptors(s.listModels), nil +} + +func (s catalogMethodsStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return MergeModelDescriptors(s.snapshotModels), nil +} + +func (s catalogMethodsStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return MergeModelDescriptors(s.cachedModels), nil +} + +type errorCatalogStub struct { + err error +} + +func (s errorCatalogStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return nil, s.err +} + +func (s errorCatalogStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return nil, s.err +} + +func (s errorCatalogStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { + return nil, s.err +} + // defaultModelsForProvider 为给定 provider 返回包含默认模型和额外测试模型的列表。 func defaultModelsForProvider(cfg ProviderConfig) []ModelDescriptor { model := strings.TrimSpace(cfg.Model) diff --git a/internal/provider/capabilities.go b/internal/provider/capabilities.go index 2218d227..852aa62f 100644 --- a/internal/provider/capabilities.go +++ b/internal/provider/capabilities.go @@ -1,24 +1,5 @@ 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 @@ -26,18 +7,3 @@ type DriverCapabilities struct { 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..59385ae5 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -71,7 +71,7 @@ func (s *Service) listProviderModels( return nil, err } - return s.modelsForProvider(ctx, providerCfg, options), nil + return s.modelsForProvider(ctx, providerCfg, options) } func (s *Service) validate() error { @@ -96,25 +96,41 @@ type catalogSnapshot struct { identity config.ProviderIdentity } -func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) []config.ModelDescriptor { - defaultModels := config.DescriptorsFromIDs([]string{providerCfg.Model}) +func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) ([]config.ModelDescriptor, error) { + configuredModels := config.MergeModelDescriptors(providerCfg.Models) + defaultModels := providerDefaultModels(providerCfg) snapshot := s.catalogSnapshot(ctx, providerCfg) 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, providerCfg) + 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) { + if options.queueRefresh && snapshot.expired { + s.queueRefresh(providerCfg, snapshot.identity) + } + if options.queueRefresh && !snapshot.ok && !performedSyncRefresh { s.queueRefresh(providerCfg, snapshot.identity) } - return config.MergeModelDescriptors(models, defaultModels) + if !catalogOK { + if len(defaultModels) == 0 { + return nil, nil + } + return config.MergeModelDescriptors(configuredModels, defaultModels), nil + } + return config.MergeModelDescriptors(configuredModels, models, defaultModels), nil } func (s *Service) catalogSnapshot(ctx context.Context, providerCfg config.ProviderConfig) catalogSnapshot { @@ -147,29 +163,29 @@ func (s *Service) loadCatalog(ctx context.Context, providerCfg config.ProviderCo return s.store.Load(ctx, identity) } -func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, bool) { +func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { if !s.registry.Supports(providerCfg.Driver) { - return nil, false + return nil, nil } resolved, err := providerCfg.Resolve() if err != nil { - return nil, false + return nil, err } discovered, err := s.registry.DiscoverModels(ctx, resolved) if err != nil { - return nil, false + return nil, err } discovered = config.MergeModelDescriptors(discovered) if s.store == nil { - return discovered, true + return discovered, nil } identity, err := providerCfg.Identity() if err != nil { - return discovered, true + return discovered, nil } now := s.now() @@ -180,7 +196,7 @@ func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.Pro ExpiresAt: now.Add(s.catalogTTL), Models: discovered, }) - return discovered, true + return discovered, nil } func (s *Service) queueRefresh(providerCfg config.ProviderConfig, identity config.ProviderIdentity) { @@ -212,3 +228,11 @@ func (s *Service) queueRefresh(providerCfg config.ProviderConfig, identity confi _, _ = s.discoverAndPersist(ctx, providerCfg) }() } + +// providerDefaultModels 仅为内建 provider 暴露代码定义的默认模型,自定义 provider 必须完全依赖远程发现结果。 +func providerDefaultModels(providerCfg config.ProviderConfig) []config.ModelDescriptor { + if providerCfg.Source == config.ProviderSourceCustom { + return nil + } + return config.DescriptorsFromIDs([]string{providerCfg.Model}) +} diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index cbf3a854..6b7e81cf 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" @@ -41,6 +42,65 @@ func TestListProviderModelsFallsBackToDefaultModelWithoutDiscovery(t *testing.T) } } +func TestListProviderModelsCustomProviderDoesNotFallbackWithoutDiscovery(t *testing.T) { + t.Parallel() + + service := NewService("", provider.NewRegistry(), newMemoryStore()) + models, err := service.ListProviderModels(context.Background(), customGatewayProvider()) + 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, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + return []config.ModelDescriptor{{ + ID: "deepseek-coder", + Name: "Server DeepSeek", + ContextWindow: 32768, + MaxOutputTokens: 4096, + }}, nil + }) + + service := NewService("", registry, newMemoryStore()) + providerCfg := config.OpenAIProvider() + providerCfg.Models = []config.ModelDescriptor{{ + ID: "deepseek-coder", + Name: "DeepSeek Coder", + ContextWindow: 131072, + MaxOutputTokens: 8192, + CapabilityHints: config.ModelCapabilityHints{ + ToolCalling: config.ModelCapabilityStateSupported, + }, + }} + providerCfg.Model = "deepseek-coder" + + models, err := service.ListProviderModels(context.Background(), providerCfg) + 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 != config.ModelCapabilityStateSupported { + t.Fatalf("expected configured capability hints to win, got %+v", models[0].CapabilityHints) + } +} + func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss(t *testing.T) { t.Setenv(testAPIKeyEnv, "test-key") @@ -92,6 +152,19 @@ 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 config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + return nil, nil + }), newMemoryStore()) + + _, err := service.ListProviderModels(context.Background(), customGatewayProvider()) + 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") @@ -288,8 +361,9 @@ 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(), config.OpenAIProvider()) + if err != nil || discovered != nil { + t.Fatalf("expected unsupported driver to skip discovery, got err=%v models=%+v", err, discovered) } }) @@ -306,8 +380,9 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { 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) + discovered, err := service.discoverAndPersist(context.Background(), providerCfg) + if err == nil || discovered != nil { + t.Fatalf("expected resolve failure to surface as error, got err=%v models=%+v", err, discovered) } }) @@ -317,8 +392,9 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { 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(), config.OpenAIProvider()) + if err == nil || discovered != nil { + t.Fatalf("expected discovery error to skip persistence, got err=%v models=%+v", err, discovered) } }) @@ -328,9 +404,9 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { 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") + discovered, err := service.discoverAndPersist(context.Background(), config.OpenAIProvider()) + 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) @@ -405,6 +481,16 @@ func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *pr return registry } +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 []config.ModelDescriptor, modelID string) bool { target := config.NormalizeKey(modelID) if target == "" { diff --git a/internal/provider/catalog/store.go b/internal/provider/catalog/store.go index 96ce6f7b..81e35944 100644 --- a/internal/provider/catalog/store.go +++ b/internal/provider/catalog/store.go @@ -55,7 +55,7 @@ func (s *jsonStore) Load(ctx context.Context, identity config.ProviderIdentity) return ModelCatalog{}, err } - normalized, err := config.NewProviderIdentity(identity.Driver, identity.BaseURL) + normalized, err := config.NormalizeProviderIdentity(identity) if err != nil { return ModelCatalog{}, fmt.Errorf("provider: normalize model catalog key: %w", err) } @@ -86,7 +86,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 := config.NormalizeProviderIdentity(normalized.Identity) if err != nil { return fmt.Errorf("provider: normalize model catalog key: %w", err) } diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index 82024d28..e11e6a34 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -33,8 +33,8 @@ func TestJSONStoreRoundTrip(t *testing.T) { Description: "Fast flagship", ContextWindow: 128000, MaxOutputTokens: 16384, - Capabilities: map[string]bool{ - "tool_call": true, + CapabilityHints: config.ModelCapabilityHints{ + ToolCalling: config.ModelCapabilityStateSupported, }, }, }, @@ -63,8 +63,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 != config.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 := config.ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://API.EXAMPLE.COM/v1/", + APIStyle: " Responses ", + } + chatIdentity := config.ProviderIdentity{ + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIStyle: "chat_completions", + } + + if err := store.Save(context.Background(), ModelCatalog{ + Identity: responsesIdentity, + Models: []config.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: []config.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) } } diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ad373587..15c7b8a9 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -145,9 +145,8 @@ 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 From 4190bd6124d0a153cd077d1f54f8f104843daf61 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 9 Apr 2026 13:44:55 +0800 Subject: [PATCH 6/7] =?UTF-8?q?pref(provider):=E5=AE=8C=E6=88=90provider?= =?UTF-8?q?=20phaseB=E7=9A=84=E9=85=8D=E7=BD=AE=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 240 ------------------ internal/config/model.go | 42 ++- internal/config/provider_loader.go | 9 +- internal/config/provider_runtime_config.go | 17 ++ internal/config/selection.go | 26 +- internal/config/selection_test.go | 48 ++-- internal/provider/catalog/service.go | 29 ++- internal/provider/catalog/service_test.go | 52 ++-- internal/provider/catalog/store.go | 13 +- internal/provider/catalog/store_test.go | 19 +- internal/provider/openai/driver.go | 6 +- internal/provider/openai/openai_test.go | 59 ++--- internal/provider/openai/provider.go | 22 +- internal/provider/openai/request.go | 2 +- internal/provider/registry.go | 19 +- internal/provider/registry_test.go | 43 ++-- internal/provider/runtime_config.go | 13 + .../types/model.go} | 66 +++-- internal/provider/types/model_test.go | 226 +++++++++++++++++ internal/runtime/compact.go | 2 +- internal/runtime/compact_generator.go | 6 +- internal/runtime/compact_generator_test.go | 12 +- internal/runtime/runtime.go | 9 +- internal/runtime/runtime_test.go | 4 +- 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 | 5 +- internal/tui/services/provider_service.go | 5 +- internal/tui/services/services_test.go | 11 +- internal/tui/state/messages.go | 4 +- 31 files changed, 529 insertions(+), 494 deletions(-) create mode 100644 internal/config/provider_runtime_config.go create mode 100644 internal/provider/runtime_config.go rename internal/{config/model_descriptor.go => provider/types/model.go} (86%) create mode 100644 internal/provider/types/model_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 643aafe0..8d5a33d2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1287,243 +1287,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, - }, - { - 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.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) - } - if got.CapabilityHints != tt.want.CapabilityHints { - t.Fatalf("expected CapabilityHints=%+v, got %+v", tt.want.CapabilityHints, got.CapabilityHints) - } - }) - } -} - -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 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) - } -} diff --git a/internal/config/model.go b/internal/config/model.go index 24cc06b1..914b5d8b 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -8,6 +8,8 @@ import ( "path/filepath" goruntime "runtime" "strings" + + providertypes "neo-code/internal/provider/types" ) const ( @@ -54,16 +56,16 @@ const ( ) type ProviderConfig struct { - Name string `yaml:"name"` - Driver string `yaml:"driver"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` - APIStyle string `yaml:"-"` - DeploymentMode string `yaml:"-"` - APIVersion string `yaml:"-"` - Models []ModelDescriptor `yaml:"-"` - Source ProviderSource `yaml:"-"` + Name string `yaml:"name"` + Driver string `yaml:"driver"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` + APIStyle string `yaml:"-"` + DeploymentMode string `yaml:"-"` + APIVersion string `yaml:"-"` + Models []providertypes.ModelDescriptor `yaml:"-"` + Source ProviderSource `yaml:"-"` } type ResolvedProviderConfig struct { @@ -713,7 +715,7 @@ func cloneProviders(providers []ProviderConfig) []ProviderConfig { // cloneProviderConfig 返回 provider 配置的深拷贝,避免模型元数据等切片在不同快照间共享。 func cloneProviderConfig(provider ProviderConfig) ProviderConfig { cloned := provider - cloned.Models = cloneModelDescriptors(provider.Models) + cloned.Models = providertypes.CloneModelDescriptors(provider.Models) return cloned } @@ -796,7 +798,7 @@ func mergeProviderConfig(base ProviderConfig, override ProviderConfig) ProviderC merged.APIVersion = strings.TrimSpace(override.APIVersion) } if len(override.Models) > 0 { - merged.Models = cloneModelDescriptors(override.Models) + merged.Models = providertypes.CloneModelDescriptors(override.Models) } if override.Source != "" { merged.Source = override.Source @@ -846,21 +848,7 @@ func sameProviderDefinition(left ProviderConfig, right ProviderConfig) bool { if NormalizeProviderAPIVersion(left.APIVersion) != NormalizeProviderAPIVersion(right.APIVersion) { return false } - return sameModelDescriptors(left.Models, right.Models) -} - -// sameModelDescriptors 判断两个模型描述列表是否表示同一份配置快照。 -func sameModelDescriptors(left []ModelDescriptor, right []ModelDescriptor) bool { - if len(left) != len(right) { - return false - } - - for index := range left { - if normalizeModelDescriptor(left[index]) != normalizeModelDescriptor(right[index]) { - return false - } - } - return true + return providertypes.EqualModelDescriptors(left.Models, right.Models) } // normalizedBaseURLOrRaw 优先返回规范化 base_url,解析失败时退回原始值以保持比较稳定。 diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 96726c34..628d6854 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -121,10 +121,15 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { // resolveCustomProviderBaseURL 根据 driver 对应的配置块提取最终生效的接入地址。 func resolveCustomProviderBaseURL(file customProviderFile) string { - return firstNonEmptyString( + for _, value := range []string{ file.BaseURL, file.OpenAICompatible.BaseURL, file.Gemini.BaseURL, file.Anthropic.BaseURL, - ) + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" } diff --git a/internal/config/provider_runtime_config.go b/internal/config/provider_runtime_config.go new file mode 100644 index 00000000..8c5e86b7 --- /dev/null +++ b/internal/config/provider_runtime_config.go @@ -0,0 +1,17 @@ +package config + +import "neo-code/internal/provider" + +// 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, + } +} diff --git a/internal/config/selection.go b/internal/config/selection.go index aacf29d9..a6c3c0e8 100644 --- a/internal/config/selection.go +++ b/internal/config/selection.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + providertypes "neo-code/internal/provider/types" ) // Selection 领域错误。 @@ -17,9 +19,9 @@ var ( // ProviderCatalogItem 表示一个已配置的 provider 及其可用模型列表,用于 UI 展示。 type ProviderCatalogItem struct { - ID string `json:"id"` - Name string `json:"name"` - Models []ModelDescriptor `json:"models,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Models []providertypes.ModelDescriptor `json:"models,omitempty"` } // ProviderSelection 表示当前选中的 provider 和 model。 @@ -35,9 +37,9 @@ type DriverSupporter interface { // 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, providerCfg ProviderConfig) ([]providertypes.ModelDescriptor, error) + ListProviderModelsSnapshot(ctx context.Context, providerCfg ProviderConfig) ([]providertypes.ModelDescriptor, error) + ListProviderModelsCached(ctx context.Context, providerCfg ProviderConfig) ([]providertypes.ModelDescriptor, error) } // SelectionService 管理 provider 和模型选择状态,并通过 ConfigManager 持久化变更。 @@ -132,7 +134,7 @@ func (s *SelectionService) SelectProvider(ctx context.Context, providerName stri } // ListModels 获取当前选中 provider 的模型列表,必要时会同步触发远程发现。 -func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, error) { +func (s *SelectionService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -148,7 +150,7 @@ func (s *SelectionService) ListModels(ctx context.Context) ([]ModelDescriptor, e } // ListModelsSnapshot 获取当前选中 provider 的快照模型列表,不阻塞等待同步发现。 -func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]ModelDescriptor, error) { +func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -342,7 +344,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 @@ -363,15 +365,15 @@ func resolveCurrentModel(currentModel string, models []ModelDescriptor, fallback 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 { +func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { target := NormalizeKey(modelID) if target == "" { return false diff --git a/internal/config/selection_test.go b/internal/config/selection_test.go index 1eea3475..ba9909da 100644 --- a/internal/config/selection_test.go +++ b/internal/config/selection_test.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "testing" + + providertypes "neo-code/internal/provider/types" ) func TestSelectionServiceListProvidersUsesCatalogModels(t *testing.T) { @@ -96,7 +98,7 @@ func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T manager := newSelectionTestManager(t, DefaultConfig()) service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ - listModels: []ModelDescriptor{ + listModels: []providertypes.ModelDescriptor{ {ID: QiniuDefaultModel, Name: QiniuDefaultModel}, {ID: QiniuDefaultModel + "-alt", Name: QiniuDefaultModel + "-alt"}, }, @@ -184,7 +186,7 @@ func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testi manager := newSelectionTestManager(t, defaults) service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ - listModels: []ModelDescriptor{ + listModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, {ID: "server-chat", Name: "Server Chat"}, }, @@ -402,7 +404,7 @@ func TestResolveCurrentModelHelper(t *testing.T) { tests := []struct { name string currentModel string - models []ModelDescriptor + models []providertypes.ModelDescriptor fallback string expected string changed bool @@ -410,7 +412,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"}, @@ -422,7 +424,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"}, @@ -434,7 +436,7 @@ func TestResolveCurrentModelHelper(t *testing.T) { { name: "missing fallback uses first discovered model", currentModel: "unknown-model", - models: []ModelDescriptor{ + models: []providertypes.ModelDescriptor{ {ID: "gpt-4o"}, }, fallback: "gpt-4.1", @@ -471,59 +473,59 @@ func (s *selectiveDriverSupporter) Supports(driverType string) bool { type catalogStub struct{} -func (catalogStub) ListProviderModels(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { +func (catalogStub) ListProviderModels(_ context.Context, cfg ProviderConfig) ([]providertypes.ModelDescriptor, error) { return defaultModelsForProvider(cfg), nil } -func (catalogStub) ListProviderModelsSnapshot(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { +func (catalogStub) ListProviderModelsSnapshot(_ context.Context, cfg ProviderConfig) ([]providertypes.ModelDescriptor, error) { return defaultModelsForProvider(cfg), nil } -func (catalogStub) ListProviderModelsCached(_ context.Context, cfg ProviderConfig) ([]ModelDescriptor, error) { +func (catalogStub) ListProviderModelsCached(_ context.Context, cfg ProviderConfig) ([]providertypes.ModelDescriptor, error) { return defaultModelsForProvider(cfg), nil } type catalogMethodsStub struct { - listModels []ModelDescriptor - snapshotModels []ModelDescriptor - cachedModels []ModelDescriptor + listModels []providertypes.ModelDescriptor + snapshotModels []providertypes.ModelDescriptor + cachedModels []providertypes.ModelDescriptor } -func (s catalogMethodsStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { - return MergeModelDescriptors(s.listModels), nil +func (s catalogMethodsStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.listModels), nil } -func (s catalogMethodsStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { - return MergeModelDescriptors(s.snapshotModels), nil +func (s catalogMethodsStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.snapshotModels), nil } -func (s catalogMethodsStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { - return MergeModelDescriptors(s.cachedModels), nil +func (s catalogMethodsStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { + return providertypes.MergeModelDescriptors(s.cachedModels), nil } type errorCatalogStub struct { err error } -func (s errorCatalogStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { +func (s errorCatalogStub) ListProviderModels(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { return nil, s.err } -func (s errorCatalogStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { +func (s errorCatalogStub) ListProviderModelsSnapshot(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { return nil, s.err } -func (s errorCatalogStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]ModelDescriptor, error) { +func (s errorCatalogStub) ListProviderModelsCached(_ context.Context, _ ProviderConfig) ([]providertypes.ModelDescriptor, error) { return nil, s.err } // defaultModelsForProvider 为给定 provider 返回包含默认模型和额外测试模型的列表。 -func defaultModelsForProvider(cfg ProviderConfig) []ModelDescriptor { +func defaultModelsForProvider(cfg ProviderConfig) []providertypes.ModelDescriptor { model := strings.TrimSpace(cfg.Model) if model == "" { return nil } - return []ModelDescriptor{ + return []providertypes.ModelDescriptor{ {ID: model, Name: model}, {ID: model + "-alt", Name: model + "-alt"}, } diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index 59385ae5..191f6320 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -9,6 +9,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const ( @@ -42,20 +43,20 @@ func NewService(baseDir string, registry *provider.Registry, store Store) *Servi } } -func (s *Service) ListProviderModels(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { +func (s *Service) ListProviderModels(ctx context.Context, providerCfg config.ProviderConfig) ([]providertypes.ModelDescriptor, error) { return s.listProviderModels(ctx, providerCfg, queryOptions{ allowSyncRefresh: true, queueRefresh: true, }) } -func (s *Service) ListProviderModelsSnapshot(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { +func (s *Service) ListProviderModelsSnapshot(ctx context.Context, providerCfg config.ProviderConfig) ([]providertypes.ModelDescriptor, error) { return s.listProviderModels(ctx, providerCfg, queryOptions{ queueRefresh: true, }) } -func (s *Service) ListProviderModelsCached(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { +func (s *Service) ListProviderModelsCached(ctx context.Context, providerCfg config.ProviderConfig) ([]providertypes.ModelDescriptor, error) { return s.listProviderModels(ctx, providerCfg, queryOptions{}) } @@ -63,7 +64,7 @@ func (s *Service) listProviderModels( ctx context.Context, providerCfg config.ProviderConfig, options queryOptions, -) ([]config.ModelDescriptor, error) { +) ([]providertypes.ModelDescriptor, error) { if err := s.validate(); err != nil { return nil, err } @@ -90,14 +91,14 @@ type queryOptions struct { } type catalogSnapshot struct { - models []config.ModelDescriptor + models []providertypes.ModelDescriptor ok bool expired bool identity config.ProviderIdentity } -func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) ([]config.ModelDescriptor, error) { - configuredModels := config.MergeModelDescriptors(providerCfg.Models) +func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.ProviderConfig, options queryOptions) ([]providertypes.ModelDescriptor, error) { + configuredModels := providertypes.MergeModelDescriptors(providerCfg.Models) defaultModels := providerDefaultModels(providerCfg) snapshot := s.catalogSnapshot(ctx, providerCfg) @@ -128,9 +129,9 @@ func (s *Service) modelsForProvider(ctx context.Context, providerCfg config.Prov if len(defaultModels) == 0 { return nil, nil } - return config.MergeModelDescriptors(configuredModels, defaultModels), nil + return providertypes.MergeModelDescriptors(configuredModels, defaultModels), nil } - return config.MergeModelDescriptors(configuredModels, models, defaultModels), nil + return providertypes.MergeModelDescriptors(configuredModels, models, defaultModels), nil } func (s *Service) catalogSnapshot(ctx context.Context, providerCfg config.ProviderConfig) catalogSnapshot { @@ -163,7 +164,7 @@ func (s *Service) loadCatalog(ctx context.Context, providerCfg config.ProviderCo return s.store.Load(ctx, identity) } -func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]config.ModelDescriptor, error) { +func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.ProviderConfig) ([]providertypes.ModelDescriptor, error) { if !s.registry.Supports(providerCfg.Driver) { return nil, nil } @@ -173,12 +174,12 @@ func (s *Service) discoverAndPersist(ctx context.Context, providerCfg config.Pro return nil, err } - discovered, err := s.registry.DiscoverModels(ctx, resolved) + discovered, err := s.registry.DiscoverModels(ctx, resolved.ToRuntimeConfig()) if err != nil { return nil, err } - discovered = config.MergeModelDescriptors(discovered) + discovered = providertypes.MergeModelDescriptors(discovered) if s.store == nil { return discovered, nil } @@ -230,9 +231,9 @@ func (s *Service) queueRefresh(providerCfg config.ProviderConfig, identity confi } // providerDefaultModels 仅为内建 provider 暴露代码定义的默认模型,自定义 provider 必须完全依赖远程发现结果。 -func providerDefaultModels(providerCfg config.ProviderConfig) []config.ModelDescriptor { +func providerDefaultModels(providerCfg config.ProviderConfig) []providertypes.ModelDescriptor { if providerCfg.Source == config.ProviderSourceCustom { return nil } - return config.DescriptorsFromIDs([]string{providerCfg.Model}) + return providertypes.DescriptorsFromIDs([]string{providerCfg.Model}) } diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 6b7e81cf..5c7fbd7c 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -58,8 +58,8 @@ func TestListProviderModelsCustomProviderDoesNotFallbackWithoutDiscovery(t *test func TestListProviderModelsMergesConfiguredMetadataAfterDiscovery(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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ ID: "deepseek-coder", Name: "Server DeepSeek", ContextWindow: 32768, @@ -69,13 +69,13 @@ func TestListProviderModelsMergesConfiguredMetadataAfterDiscovery(t *testing.T) service := NewService("", registry, newMemoryStore()) providerCfg := config.OpenAIProvider() - providerCfg.Models = []config.ModelDescriptor{{ + providerCfg.Models = []providertypes.ModelDescriptor{{ ID: "deepseek-coder", Name: "DeepSeek Coder", ContextWindow: 131072, MaxOutputTokens: 8192, - CapabilityHints: config.ModelCapabilityHints{ - ToolCalling: config.ModelCapabilityStateSupported, + CapabilityHints: providertypes.ModelCapabilityHints{ + ToolCalling: providertypes.ModelCapabilityStateSupported, }, }} providerCfg.Model = "deepseek-coder" @@ -96,7 +96,7 @@ func TestListProviderModelsMergesConfiguredMetadataAfterDiscovery(t *testing.T) if models[0].MaxOutputTokens != 8192 { t.Fatalf("expected configured max output tokens to win, got %+v", models[0]) } - if models[0].CapabilityHints.ToolCalling != config.ModelCapabilityStateSupported { + if models[0].CapabilityHints.ToolCalling != providertypes.ModelCapabilityStateSupported { t.Fatalf("expected configured capability hints to win, got %+v", models[0].CapabilityHints) } } @@ -105,12 +105,12 @@ func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss( 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, config.OpenAIName, 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() @@ -155,7 +155,7 @@ func TestListProviderModelsSnapshotReturnsDefaultAndRefreshesInBackgroundOnMiss( func TestListProviderModelsReturnsDiscoveryErrorOnCacheMiss(t *testing.T) { t.Setenv(testAPIKeyEnv, "") - service := NewService("", newRegistry(t, "openaicompat", func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + service := NewService("", newRegistry(t, "openaicompat", func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return nil, nil }), newMemoryStore()) @@ -168,8 +168,8 @@ func TestListProviderModelsReturnsDiscoveryErrorOnCacheMiss(t *testing.T) { 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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ ID: "server-model", Name: "Server Model", ContextWindow: 32000, @@ -216,7 +216,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 { @@ -224,12 +224,12 @@ 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, config.OpenAIName, 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) @@ -269,7 +269,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)) } @@ -330,7 +330,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 { @@ -338,9 +338,9 @@ 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, config.OpenAIName, 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) @@ -368,7 +368,7 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { }) 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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return nil, nil }), newMemoryStore()) @@ -388,7 +388,7 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { 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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { return nil, errors.New("discover failed") }), newMemoryStore()) @@ -400,8 +400,8 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { 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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { + return []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil }), nil) discovered, err := service.discoverAndPersist(context.Background(), config.OpenAIProvider()) @@ -425,7 +425,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, config.OpenAIName, func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { atomic.AddInt32(&discoverCalls, 1) select { case started <- struct{}{}: @@ -435,7 +435,7 @@ 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()) @@ -472,7 +472,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 { @@ -491,7 +491,7 @@ func customGatewayProvider() config.ProviderConfig { } } -func containsModelDescriptorID(models []config.ModelDescriptor, modelID string) bool { +func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { target := config.NormalizeKey(modelID) if target == "" { return false diff --git a/internal/provider/catalog/store.go b/internal/provider/catalog/store.go index 81e35944..d2e430af 100644 --- a/internal/provider/catalog/store.go +++ b/internal/provider/catalog/store.go @@ -14,6 +14,7 @@ import ( "time" "neo-code/internal/config" + 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 config.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 { @@ -113,7 +114,7 @@ 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 } diff --git a/internal/provider/catalog/store_test.go b/internal/provider/catalog/store_test.go index e11e6a34..83e17129 100644 --- a/internal/provider/catalog/store_test.go +++ b/internal/provider/catalog/store_test.go @@ -10,6 +10,7 @@ import ( "time" "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" ) func TestJSONStoreRoundTrip(t *testing.T) { @@ -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, - CapabilityHints: config.ModelCapabilityHints{ - ToolCalling: config.ModelCapabilityStateSupported, + CapabilityHints: providertypes.ModelCapabilityHints{ + ToolCalling: providertypes.ModelCapabilityStateSupported, }, }, }, @@ -63,7 +64,7 @@ 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].CapabilityHints.ToolCalling != config.ModelCapabilityStateSupported { + if got.Models[0].CapabilityHints.ToolCalling != providertypes.ModelCapabilityStateSupported { t.Fatalf("expected capability hints to round-trip, got %+v", got.Models[0].CapabilityHints) } } @@ -85,7 +86,7 @@ func TestJSONStoreSeparatesDriverSpecificIdentityKeys(t *testing.T) { if err := store.Save(context.Background(), ModelCatalog{ Identity: responsesIdentity, - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "responses-model", Name: "Responses Model"}, }, }); err != nil { @@ -93,7 +94,7 @@ func TestJSONStoreSeparatesDriverSpecificIdentityKeys(t *testing.T) { } if err := store.Save(context.Background(), ModelCatalog{ Identity: chatIdentity, - Models: []config.ModelDescriptor{ + Models: []providertypes.ModelDescriptor{ {ID: "chat-model", Name: "Chat Model"}, }, }); err != nil { @@ -153,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"}, }, } @@ -162,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"}, }, } @@ -304,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/openai/driver.go b/internal/provider/openai/driver.go index 5b41a4d9..64f1e1c1 100644 --- a/internal/provider/openai/driver.go +++ b/internal/provider/openai/driver.go @@ -4,9 +4,9 @@ import ( "context" "net/http" - "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/transport" + providertypes "neo-code/internal/provider/types" ) // DriverName 是 OpenAI 驱动的注册标识。 @@ -21,10 +21,10 @@ func defaultRetryTransport() http.RoundTripper { func Driver() provider.DriverDefinition { return provider.DriverDefinition{ Name: DriverName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + Build: func(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) { return New(cfg, withTransport(defaultRetryTransport())) }, - Discover: func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + Discover: func(ctx context.Context, cfg provider.RuntimeConfig) ([]providertypes.ModelDescriptor, error) { p, err := New(cfg, withTransport(defaultRetryTransport())) if err != nil { return nil, err diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index aa3d4414..e5367ffa 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -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/openai/provider.go index acf48233..399916b6 100644 --- a/internal/provider/openai/provider.go +++ b/internal/provider/openai/provider.go @@ -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/openai/request.go index 9a9b8d2b..3accaa9e 100644 --- a/internal/provider/openai/request.go +++ b/internal/provider/openai/request.go @@ -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/registry.go b/internal/provider/registry.go index b49344a1..7c2dd409 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -6,11 +6,11 @@ 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 @@ -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 @@ -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..7c570397 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -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 }, } @@ -40,15 +40,12 @@ 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: "OPENAI", + BaseURL: config.OpenAIDefaultBaseURL, + DefaultModel: config.OpenAIDefaultModel, + APIKey: "test-key", }) if err != nil { t.Fatalf("Build() error = %v", err) @@ -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) } @@ -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/runtime_config.go b/internal/provider/runtime_config.go new file mode 100644 index 00000000..f3ccf209 --- /dev/null +++ b/internal/provider/runtime_config.go @@ -0,0 +1,13 @@ +package provider + +// RuntimeConfig 表示 provider 构建与模型发现使用的最小运行时输入。 +type RuntimeConfig struct { + Name string + Driver string + BaseURL string + DefaultModel string + APIKey string + APIStyle string + DeploymentMode string + APIVersion string +} diff --git a/internal/config/model_descriptor.go b/internal/provider/types/model.go similarity index 86% rename from internal/config/model_descriptor.go rename to internal/provider/types/model.go index fa7281fd..25f310db 100644 --- a/internal/config/model_descriptor.go +++ b/internal/provider/types/model.go @@ -1,8 +1,8 @@ -package config +package types import "strings" -// ModelCapabilityState 表示模型能力提示的三态值。 +// ModelCapabilityState 表示模型能力提示的三态枚举。 type ModelCapabilityState string const ( @@ -21,14 +21,14 @@ const ( ModelReasoningModeConfigurable ModelReasoningMode = "configurable" ) -// ModelCapabilityHints 描述配置层关注的模型能力提示。 +// 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 表示模型元数据描述符。 +// ModelDescriptor 表示 discovery/catalog 链路共享的模型元数据描述符。 type ModelDescriptor struct { ID string `json:"id"` Name string `json:"name"` @@ -60,8 +60,7 @@ func DescriptorFromRawModel(raw map[string]any) (ModelDescriptor, bool) { return normalizeModelDescriptor(descriptor), true } -// MergeModelDescriptors 合并多个 ModelDescriptor 来源,按 ID 去重, -// 优先保留较早来源的字段值,后续来源用于回填空字段。 +// MergeModelDescriptors 合并多个 ModelDescriptor 来源,按 ID 去重并回填缺失字段。 func MergeModelDescriptors(sources ...[]ModelDescriptor) []ModelDescriptor { if len(sources) == 0 { return nil @@ -73,7 +72,7 @@ func MergeModelDescriptors(sources ...[]ModelDescriptor) []ModelDescriptor { for _, source := range sources { for _, candidate := range source { normalized := normalizeModelDescriptor(candidate) - key := NormalizeKey(normalized.ID) + key := normalizeKey(normalized.ID) if key == "" { continue } @@ -94,7 +93,7 @@ func MergeModelDescriptors(sources ...[]ModelDescriptor) []ModelDescriptor { return merged } -// DescriptorsFromIDs 从模型 ID 字符串列表构建最小化的 ModelDescriptor 列表。 +// DescriptorsFromIDs 从模型 ID 列表构建最小化的 ModelDescriptor 列表。 func DescriptorsFromIDs(modelIDs []string) []ModelDescriptor { if len(modelIDs) == 0 { return nil @@ -117,6 +116,33 @@ func DescriptorsFromIDs(modelIDs []string) []ModelDescriptor { 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 +} + +// EqualModelDescriptors 判断两个模型描述列表是否表示同一份规范化快照。 +func EqualModelDescriptors(left []ModelDescriptor, right []ModelDescriptor) bool { + if len(left) != len(right) { + return false + } + + for index := range left { + if normalizeModelDescriptor(left[index]) != normalizeModelDescriptor(right[index]) { + return false + } + } + return true +} + // normalizeModelDescriptor 统一清理模型描述中的字符串和能力提示字段。 func normalizeModelDescriptor(descriptor ModelDescriptor) ModelDescriptor { descriptor.ID = strings.TrimSpace(descriptor.ID) @@ -161,19 +187,6 @@ func mergeModelCapabilityHints(primary ModelCapabilityHints, secondary ModelCapa return normalizeModelCapabilityHints(primary) } -// 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 -} - // normalizeModelCapabilityHints 规范化模型能力提示中的枚举字符串。 func normalizeModelCapabilityHints(hints ModelCapabilityHints) ModelCapabilityHints { hints.ToolCalling = normalizeModelCapabilityState(string(hints.ToolCalling)) @@ -196,7 +209,7 @@ func modelCapabilityHintsFromValue(value any) ModelCapabilityHints { continue } - switch NormalizeKey(key) { + switch normalizeKey(key) { case "tool_calling", "tool_call": hints.ToolCalling = modelCapabilityStateFromBool(boolValue) case "image_input": @@ -216,7 +229,7 @@ func modelCapabilityStateFromBool(value bool) ModelCapabilityState { // normalizeModelCapabilityState 将能力状态字符串收敛为受支持的三态枚举。 func normalizeModelCapabilityState(value string) ModelCapabilityState { - switch ModelCapabilityState(NormalizeKey(value)) { + switch ModelCapabilityState(normalizeKey(value)) { case ModelCapabilityStateSupported: return ModelCapabilityStateSupported case ModelCapabilityStateUnsupported: @@ -230,7 +243,7 @@ func normalizeModelCapabilityState(value string) ModelCapabilityState { // normalizeModelReasoningMode 将 reasoning_mode 字符串规范化为受支持的枚举值。 func normalizeModelReasoningMode(value string) ModelReasoningMode { - switch ModelReasoningMode(NormalizeKey(value)) { + switch ModelReasoningMode(normalizeKey(value)) { case ModelReasoningModeNone: return ModelReasoningModeNone case ModelReasoningModeNative: @@ -244,6 +257,11 @@ func normalizeModelReasoningMode(value string) ModelReasoningMode { } } +// normalizeKey 统一执行大小写折叠与空白清理,保证跨层比较稳定。 +func normalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + // stringValue 从通用值中提取字符串并做空白裁剪。 func stringValue(value any) string { switch typed := value.(type) { diff --git a/internal/provider/types/model_test.go b/internal/provider/types/model_test.go new file mode 100644 index 00000000..913cbb3f --- /dev/null +++ b/internal/provider/types/model_test.go @@ -0,0 +1,226 @@ +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) + } +} 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..989afbe3 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -5,21 +5,21 @@ import ( "errors" "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{ diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 2e643f04..9fabeb32 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, @@ -180,7 +180,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() { @@ -222,7 +222,7 @@ func TestCompactSummaryGeneratorRejectsDriverWithoutStreaming(t *testing.T) { 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 +252,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..54aa4784 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -129,7 +129,7 @@ type UserInput struct { } type ProviderFactory interface { - Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) + Build(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) DriverCapabilities(driverType string) (provider.DriverCapabilities, error) } @@ -670,7 +670,7 @@ func isRetryableProviderError(err error) bool { // ensureProviderDriverCapabilities 校验当前 driver 是否满足指定运行场景的基础能力要求。 func ensureProviderDriverCapabilities( factory ProviderFactory, - cfg config.ResolvedProviderConfig, + cfg provider.RuntimeConfig, requireStreaming bool, requireToolTransport bool, ) error { @@ -725,11 +725,12 @@ func (s *Service) callProviderWithRetry( if err != nil { return nil, err } - if err := ensureProviderDriverCapabilities(s.providerFactory, resolvedProvider, true, true); err != nil { + runtimeCfg := resolvedProvider.ToRuntimeConfig() + if err := ensureProviderDriverCapabilities(s.providerFactory, runtimeCfg, true, true); 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..99b289e0 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 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 { 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 15c7b8a9..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" @@ -152,7 +153,7 @@ func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { 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{ @@ -296,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..6723866d 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 选择命令所需最小能力。 @@ -21,7 +22,7 @@ type ModelSelector interface { // ModelCatalogReader 定义 model catalog 刷新所需最小能力。 type ModelCatalogReader interface { - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) } // SelectProviderCmd 执行 provider 切换并将结果映射为 UI 消息。 @@ -52,7 +53,7 @@ func SelectModelCmd( 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 d3d1ad8db6d9c1ee2e819e88fda43c6a3e26ab75 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 9 Apr 2026 14:00:24 +0800 Subject: [PATCH 7/7] =?UTF-8?q?refactor=EF=BC=88provider=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E6=9B=B4=E6=94=B9driver=E8=83=BD=E5=8A=9B=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/capabilities.go | 9 --------- internal/provider/driver_capabilities.go | 9 +++++++++ internal/provider/openai/driver.go | 2 +- internal/provider/registry.go | 8 ++++---- internal/provider/registry_test.go | 8 ++++---- internal/runtime/compact_generator.go | 2 +- internal/runtime/compact_generator_test.go | 2 +- internal/runtime/runtime.go | 10 +++++----- internal/runtime/runtime_test.go | 12 ++++++------ 9 files changed, 31 insertions(+), 31 deletions(-) delete mode 100644 internal/provider/capabilities.go create mode 100644 internal/provider/driver_capabilities.go diff --git a/internal/provider/capabilities.go b/internal/provider/capabilities.go deleted file mode 100644 index 852aa62f..00000000 --- a/internal/provider/capabilities.go +++ /dev/null @@ -1,9 +0,0 @@ -package provider - -// DriverCapabilities 描述 driver 层本身是否能传输某类能力。 -type DriverCapabilities struct { - Streaming bool - ToolTransport bool - ModelDiscovery bool - ImageInputTransport bool -} diff --git a/internal/provider/driver_capabilities.go b/internal/provider/driver_capabilities.go new file mode 100644 index 00000000..10d6d7e7 --- /dev/null +++ b/internal/provider/driver_capabilities.go @@ -0,0 +1,9 @@ +package provider + +// DriverTransportCapabilities 描述 driver 层本身是否能传输某类运行时能力。 +type DriverTransportCapabilities struct { + Streaming bool + ToolTransport bool + ModelDiscovery bool + ImageInputTransport bool +} diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go index 64f1e1c1..7630a2b6 100644 --- a/internal/provider/openai/driver.go +++ b/internal/provider/openai/driver.go @@ -31,7 +31,7 @@ func Driver() provider.DriverDefinition { } return p.DiscoverModels(ctx) }, - Capabilities: provider.DriverCapabilities{ + Capabilities: provider.DriverTransportCapabilities{ Streaming: true, ToolTransport: true, ModelDiscovery: true, diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 7c2dd409..e7fb32ba 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -16,7 +16,7 @@ type DriverDefinition struct { Name string Build Builder Discover DiscoveryFunc - Capabilities DriverCapabilities + Capabilities DriverTransportCapabilities } type Registry struct { @@ -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 } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 7c570397..e52e797e 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -176,13 +176,13 @@ 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("OPENAI") 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) @@ -194,7 +194,7 @@ func TestRegistryDriverCapabilities(t *testing.T) { t.Fatalf("expected openai 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) } diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index 989afbe3..a5946eb4 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -41,7 +41,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 } diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index 9fabeb32..47f63c1d 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -217,7 +217,7 @@ func TestCompactSummaryGeneratorRejectsDriverWithoutStreaming(t *testing.T) { } factory := &scriptedProviderFactory{ provider: scripted, - capabilities: provider.DriverCapabilities{ + capabilities: provider.DriverTransportCapabilities{ Streaming: false, ToolTransport: true, }, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 54aa4784..1e2ce574 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 provider.RuntimeConfig) (provider.Provider, error) - DriverCapabilities(driverType string) (provider.DriverCapabilities, error) + DriverTransportCapabilities(driverType string) (provider.DriverTransportCapabilities, error) } type Service struct { @@ -667,8 +667,8 @@ func isRetryableProviderError(err error) bool { return pErr.Retryable } -// ensureProviderDriverCapabilities 校验当前 driver 是否满足指定运行场景的基础能力要求。 -func ensureProviderDriverCapabilities( +// ensureDriverTransportCapabilities 校验当前 driver 是否满足指定运行场景的基础传输能力要求。 +func ensureDriverTransportCapabilities( factory ProviderFactory, cfg provider.RuntimeConfig, requireStreaming bool, @@ -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 } @@ -726,7 +726,7 @@ func (s *Service) callProviderWithRetry( return nil, err } runtimeCfg := resolvedProvider.ToRuntimeConfig() - if err := ensureProviderDriverCapabilities(s.providerFactory, runtimeCfg, true, true); err != nil { + if err := ensureDriverTransportCapabilities(s.providerFactory, runtimeCfg, true, true); err != nil { return nil, err } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 99b289e0..52fecc32 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -176,7 +176,7 @@ type scriptedProviderFactory struct { calls int configs []provider.RuntimeConfig err error - capabilities provider.DriverCapabilities + capabilities provider.DriverTransportCapabilities capabilitiesErr error } @@ -189,12 +189,12 @@ func (f *scriptedProviderFactory) Build(ctx context.Context, cfg provider.Runtim 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 @@ -3125,7 +3125,7 @@ func TestServiceRunRejectsDriverWithoutToolTransport(t *testing.T) { } factory := &scriptedProviderFactory{ provider: scripted, - capabilities: provider.DriverCapabilities{ + capabilities: provider.DriverTransportCapabilities{ Streaming: true, ToolTransport: false, },