iop/apps/edge/internal/edgevalidate/validate.go

270 lines
8.4 KiB
Go

package edgevalidate
import (
"fmt"
"strings"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
// ValidateEdgeConfig validates Edge config fields that config.LoadEdge does
// not check: adapter endpoint presence, per-node adapter-enabled requirements,
// and node store consistency (token uniqueness). It is the shared validation
// path used by both `config check` and the refresh candidate loader so that
// the same ruleset applies regardless of the entry point.
func ValidateEdgeConfig(cfg *config.EdgeConfig) error {
_, err := edgenode.LoadFromConfig(cfg.Nodes)
if err != nil {
return err
}
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
hasOllama := anyOllamaEnabled(n.Adapters)
hasVllm := anyVllmEnabled(n.Adapters)
hasOpenAICompat := anyOpenAICompatEnabled(n.Adapters)
hasProviders := len(n.Providers) > 0
if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm && !hasOpenAICompat && !hasProviders {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
for j, inst := range n.Adapters.OllamaInstances {
if inst.Enabled && inst.BaseURL == "" {
return fmt.Errorf("node %q: ollama_instances[%d] %q: base_url must not be empty", name, j, inst.Name)
}
}
if n.Adapters.Vllm.Enabled && n.Adapters.Vllm.Endpoint == "" {
return fmt.Errorf("node %q: vllm adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.VllmInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: vllm_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Adapters.OpenAICompat.Enabled && n.Adapters.OpenAICompat.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat adapter endpoint must not be empty", name)
}
for j, inst := range n.Adapters.OpenAICompatInstances {
if inst.Enabled && inst.Endpoint == "" {
return fmt.Errorf("node %q: openai_compat_instances[%d] %q: endpoint must not be empty", name, j, inst.Name)
}
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
if err := validateProviderAdapterReferences(name, n); err != nil {
return err
}
// COMPAT: provider-first / legacy adapter field conflicts
if err := config.CheckProviderLegacyConflict(i, name, &n); err != nil {
return err
}
}
return nil
}
func validateProviderAdapterReferences(nodeName string, n config.NodeDefinition) error {
idx := buildAdapterIndex(n.Adapters)
for i, p := range n.Providers {
// Disabled providers are validated for structural fields (type, category,
// models, numeric bounds) but adapter reference checks are skipped since
// the provider won't be dispatched and its adapter may be offline.
if !config.ProviderEnabled(p) {
if err := p.Validate(); err != nil {
return fmt.Errorf("node %q: providers[%d]: %w", nodeName, i, err)
}
normType := config.NormalizeProviderType(p.Type)
if normType != "openai_compat" && normType != "ollama" && normType != "cli" {
return fmt.Errorf("node %q: providers[%d] %q: unknown provider type %q", nodeName, i, p.ID, p.Type)
}
continue
}
trimmedType := strings.TrimSpace(p.Type)
if trimmedType == "" {
return fmt.Errorf("node %q: providers[%d] %q: type must not be empty", nodeName, i, p.ID)
}
normType := config.NormalizeProviderType(p.Type)
if normType != "openai_compat" && normType != "ollama" && normType != "cli" {
return fmt.Errorf("node %q: providers[%d] %q: unknown provider type %q", nodeName, i, p.ID, p.Type)
}
adapter := strings.TrimSpace(p.Adapter)
if adapter == "" {
if p.Endpoint == "" && p.BaseURL == "" && p.Command == "" {
if len(p.Models) == 0 {
return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID)
}
count := 0
switch normType {
case "openai_compat":
count = idx.typeCount["openai_compat"] + idx.typeCount["vllm"]
case "ollama":
count = idx.typeCount["ollama"]
case "cli":
count = idx.typeCount["cli"]
}
if count == 1 {
continue
} else if count > 1 {
return fmt.Errorf("node %q: providers[%d] %q: adapter %q is ambiguous; use an enabled adapter instance key", nodeName, i, p.ID, p.Type)
}
return fmt.Errorf("node %q: providers[%d] %q: adapter must not be empty", nodeName, i, p.ID)
}
// provider-first execution fields present
switch normType {
case "openai_compat":
if p.Endpoint == "" {
return fmt.Errorf("node %q: providers[%d] %q: openai_compat provider endpoint must not be empty", nodeName, i, p.ID)
}
if p.BaseURL != "" || p.Command != "" {
return fmt.Errorf("node %q: providers[%d] %q: openai_compat provider must not set base_url or command", nodeName, i, p.ID)
}
case "ollama":
if p.BaseURL == "" {
return fmt.Errorf("node %q: providers[%d] %q: ollama provider base_url must not be empty", nodeName, i, p.ID)
}
if p.Endpoint != "" || p.Command != "" {
return fmt.Errorf("node %q: providers[%d] %q: ollama provider must not set endpoint or command", nodeName, i, p.ID)
}
case "cli":
if p.Command == "" {
return fmt.Errorf("node %q: providers[%d] %q: cli provider command must not be empty", nodeName, i, p.ID)
}
if p.Endpoint != "" || p.BaseURL != "" {
return fmt.Errorf("node %q: providers[%d] %q: cli provider must not set endpoint or base_url", nodeName, i, p.ID)
}
}
continue
}
if p.Category == config.CategoryCLI && !n.Adapters.CLI.Enabled {
return fmt.Errorf("node %q: providers[%d] %q: category cli requires enabled cli adapter", nodeName, i, p.ID)
}
if idx.exact[adapter] {
continue
}
if count, knownType := idx.typeCount[adapter]; knownType {
switch count {
case 0:
return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter)
case 1:
continue
default:
return fmt.Errorf("node %q: providers[%d] %q: adapter %q is ambiguous; use an enabled adapter instance key", nodeName, i, p.ID, adapter)
}
}
return fmt.Errorf("node %q: providers[%d] %q: adapter %q does not resolve to an enabled adapter instance", nodeName, i, p.ID, adapter)
}
return nil
}
type adapterIndex struct {
exact map[string]bool
typeKeys map[string]map[string]struct{}
typeCount map[string]int
}
func buildAdapterIndex(a config.AdaptersConf) adapterIndex {
idx := adapterIndex{
exact: make(map[string]bool),
typeKeys: make(map[string]map[string]struct{}),
typeCount: map[string]int{"ollama": 0, "vllm": 0, "openai_compat": 0, "cli": 0},
}
if a.Ollama.Enabled {
idx.add("ollama", "ollama")
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
idx.add("ollama", defaultAdapterKey(inst.Name, "ollama"))
}
}
if a.Vllm.Enabled {
idx.add("vllm", "vllm")
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
idx.add("vllm", defaultAdapterKey(inst.Name, "vllm"))
}
}
if a.OpenAICompat.Enabled {
idx.add("openai_compat", "openai_compat")
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
idx.add("openai_compat", defaultAdapterKey(inst.Name, "openai_compat"))
}
}
if a.CLI.Enabled {
idx.add("cli", "cli")
}
for typeName, keys := range idx.typeKeys {
idx.typeCount[typeName] = len(keys)
}
return idx
}
func (idx adapterIndex) add(typeName, key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
idx.exact[key] = true
if idx.typeKeys[typeName] == nil {
idx.typeKeys[typeName] = make(map[string]struct{})
}
idx.typeKeys[typeName][key] = struct{}{}
}
func defaultAdapterKey(name, typeName string) string {
if strings.TrimSpace(name) == "" {
return typeName
}
return name
}
func anyOllamaEnabled(a config.AdaptersConf) bool {
if a.Ollama.Enabled {
return true
}
for _, inst := range a.OllamaInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyVllmEnabled(a config.AdaptersConf) bool {
if a.Vllm.Enabled {
return true
}
for _, inst := range a.VllmInstances {
if inst.Enabled {
return true
}
}
return false
}
func anyOpenAICompatEnabled(a config.AdaptersConf) bool {
if a.OpenAICompat.Enabled {
return true
}
for _, inst := range a.OpenAICompatInstances {
if inst.Enabled {
return true
}
}
return false
}