270 lines
8.4 KiB
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
|
|
}
|