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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/config-management-detail-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,12 @@ custom provider 来自:

## custom provider `models` 校验约束

`~/.neocode/providers/<provider-name>/provider.yaml` 中允许通过 `models` 补齐模型元数据,用于 catalog/discovery 无法提供完整 `ContextWindow` 或 `MaxOutputTokens` 的场景
`~/.neocode/providers/<provider-name>/provider.yaml` 中的 `models` 现在只表达“用户声明需要出现的模型 ID / 展示名”,不再承担元数据补齐职责

该能力的约束是
当前约束是

- `models[].id` 必须非空。
- `models[].context_window` 和 `models[].max_output_tokens` 如果显式提供,必须大于 `0`。
- 重复的模型 `id` 会在加载 custom provider 时直接失败,不保留 silently drop 的宽松行为。
- 这些元数据不会写回 `config.yaml`,只在 custom provider 文件中声明,并通过现有 catalog 合并链路参与运行时解析。
- `models[].name` 必须非空。
- `models` 中不允许出现 `context_window`、`max_output_tokens`、`description`、`capability_hints`。
- discovery 缓存只保存规范化后的白名单字段:`id/name/description/context_window/max_output_tokens/capability_hints`。
- builtin provider 不再走 `/models` discovery,模型清单改为仓库内静态维护。
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func testDefaultProviderConfig() ProviderConfig {
BaseURL: testBaseURL,
Model: testModel,
APIKeyEnv: testAPIKeyEnv,
Models: cloneBuiltinModels(openAIStaticModels),
Source: ProviderSourceBuiltin,
}
}
Expand Down
61 changes: 22 additions & 39 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,6 @@ api_key_env: COMPANY_GATEWAY_API_KEY
models:
- id: deepseek-coder
name: DeepSeek Coder
context_window: 131072
max_output_tokens: 8192
`
customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway")
if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil {
Expand Down Expand Up @@ -532,10 +530,10 @@ models:
t.Fatalf("expected custom provider default model to be empty, got %q", customProvider.Model)
}
if len(customProvider.Models) != 1 {
t.Fatalf("expected custom provider model metadata from provider.yaml, got %+v", customProvider.Models)
t.Fatalf("expected custom provider models from provider.yaml, got %+v", customProvider.Models)
}
if customProvider.Models[0].ID != "deepseek-coder" || customProvider.Models[0].ContextWindow != 131072 {
t.Fatalf("expected parsed model metadata, got %+v", customProvider.Models[0])
if customProvider.Models[0].ID != "deepseek-coder" || customProvider.Models[0].ContextWindow != 0 {
t.Fatalf("expected parsed id/name only model, got %+v", customProvider.Models[0])
}
}

Expand Down Expand Up @@ -815,7 +813,7 @@ models:
}
}

func TestLoaderRejectsCustomProviderModelWithInvalidContextWindow(t *testing.T) {
func TestLoaderRejectsCustomProviderModelWithUnsupportedContextWindow(t *testing.T) {
t.Parallel()

loader := NewLoader(t.TempDir(), testDefaultConfig())
Expand All @@ -832,19 +830,19 @@ api_key_env: COMPANY_GATEWAY_API_KEY
models:
- id: deepseek-coder
name: DeepSeek Coder
context_window: 0
context_window: 131072
`
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(), "context_window") {
t.Fatalf("expected invalid context_window rejection, got %v", err)
if err == nil || !strings.Contains(err.Error(), "field context_window not found") {
t.Fatalf("expected unknown context_window rejection, got %v", err)
}
}

func TestLoaderRejectsCustomProviderModelWithInvalidMaxOutputTokens(t *testing.T) {
func TestLoaderRejectsCustomProviderModelWithUnsupportedMaxOutputTokens(t *testing.T) {
t.Parallel()

loader := NewLoader(t.TempDir(), testDefaultConfig())
Expand All @@ -861,15 +859,15 @@ api_key_env: COMPANY_GATEWAY_API_KEY
models:
- id: deepseek-coder
name: DeepSeek Coder
max_output_tokens: 0
max_output_tokens: 8192
`
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(), "max_output_tokens") {
t.Fatalf("expected invalid max_output_tokens rejection, got %v", err)
if err == nil || !strings.Contains(err.Error(), "field max_output_tokens not found") {
t.Fatalf("expected unknown max_output_tokens rejection, got %v", err)
}
}

Expand Down Expand Up @@ -1327,10 +1325,8 @@ func TestSaveCustomProviderAndLoadCustomProviderStayConsistent(t *testing.T) {
DiscoveryEndpointPath: "/should-be-cleared",
Models: []providertypes.ModelDescriptor{
{
ID: "manual-model-1",
Name: "Manual Model 1",
ContextWindow: 131072,
MaxOutputTokens: 8192,
ID: "manual-model-1",
Name: "Manual Model 1",
},
},
},
Expand Down Expand Up @@ -1387,7 +1383,7 @@ func TestSaveCustomProviderAndLoadCustomProviderStayConsistent(t *testing.T) {
}
}

func TestSaveCustomProviderManualModelsPersistOptionalFields(t *testing.T) {
func TestSaveCustomProviderManualModelsPersistIDAndNameOnly(t *testing.T) {
t.Parallel()

baseDir := t.TempDir()
Expand All @@ -1404,10 +1400,8 @@ func TestSaveCustomProviderManualModelsPersistOptionalFields(t *testing.T) {
Name: "Manual Model 1",
},
{
ID: "manual-model-2",
Name: "Manual Model 2",
ContextWindow: 131072,
MaxOutputTokens: 8192,
ID: "manual-model-2",
Name: "Manual Model 2",
},
},
})
Expand All @@ -1428,11 +1422,8 @@ func TestSaveCustomProviderManualModelsPersistOptionalFields(t *testing.T) {
if len(cfg.Models) != 2 {
t.Fatalf("expected model list with 2 entries, got %+v", cfg.Models)
}
if cfg.Models[0].ContextWindow != 0 || cfg.Models[0].MaxOutputTokens != 0 {
t.Fatalf("expected optional fields omitted for model-1, got %+v", cfg.Models[0])
}
if cfg.Models[1].ContextWindow != 131072 || cfg.Models[1].MaxOutputTokens != 8192 {
t.Fatalf("expected optional fields persisted for model-2, got %+v", cfg.Models[1])
if cfg.Models[0].ContextWindow != 0 || cfg.Models[0].MaxOutputTokens != 0 || cfg.Models[1].ContextWindow != 0 || cfg.Models[1].MaxOutputTokens != 0 {
t.Fatalf("expected persisted manual models to omit metadata, got %+v", cfg.Models)
}
}

Expand Down Expand Up @@ -1641,10 +1632,8 @@ func TestToCustomProviderModelFiles(t *testing.T) {
Name: "Model A",
},
{
ID: "model-b",
Name: "Model B",
ContextWindow: 32768,
MaxOutputTokens: 2048,
ID: "model-b",
Name: "Model B",
},
{
ID: "Model-A",
Expand All @@ -1657,14 +1646,8 @@ func TestToCustomProviderModelFiles(t *testing.T) {
if converted[0].ID != "model-a" || converted[0].Name != "Model A" {
t.Fatalf("expected normalized merge result for model-a, got %+v", converted[0])
}
if converted[0].ContextWindow != nil || converted[0].MaxOutputTokens != nil {
t.Fatalf("expected model-a optional pointers nil, got %+v", converted[0])
}
if converted[1].ContextWindow == nil || *converted[1].ContextWindow != 32768 {
t.Fatalf("expected model-b context window pointer, got %+v", converted[1])
}
if converted[1].MaxOutputTokens == nil || *converted[1].MaxOutputTokens != 2048 {
t.Fatalf("expected model-b max output tokens pointer, got %+v", converted[1])
if converted[1].ID != "model-b" || converted[1].Name != "Model B" {
t.Fatalf("expected id/name only persistence for model-b, got %+v", converted[1])
}
}

Expand Down
Loading
Loading