iop/packages/go/config/validate.go

269 lines
8.9 KiB
Go

package config
import (
"encoding/hex"
"fmt"
"strings"
"github.com/spf13/viper"
)
// validateOpenAIRoutes rejects duplicate and empty model ids in the route catalog.
func validateOpenAIRoutes(routes []OpenAIRouteEntry) error {
seen := make(map[string]struct{}, len(routes))
for i, r := range routes {
model := strings.TrimSpace(r.Model)
if model == "" {
return fmt.Errorf("openai.model_routes[%d]: model must not be empty", i)
}
if _, dup := seen[model]; dup {
return fmt.Errorf("openai.model_routes: duplicate model %q", model)
}
seen[model] = struct{}{}
if r.MaxQueue < 0 {
return fmt.Errorf("openai.model_routes[%d].max_queue must be non-negative", i)
}
if r.QueueTimeoutMS < 0 {
return fmt.Errorf("openai.model_routes[%d].queue_timeout_ms must be non-negative", i)
}
}
return nil
}
// validateOpenAIPrincipalTokens validates the raw-token-free bearer token
// operation mapping. Each entry must resolve to a unique, 64-char lowercase
// hex SHA-256 hash and a non-empty principal_ref; token_ref must be unique.
func validateOpenAIPrincipalTokens(tokens []OpenAIPrincipalTokenConf) error {
seenTokenRef := make(map[string]struct{}, len(tokens))
seenHash := make(map[string]struct{}, len(tokens))
for i, t := range tokens {
ref := strings.TrimSpace(t.TokenRef)
if ref == "" {
return fmt.Errorf("openai.principal_tokens[%d]: token_ref must not be empty", i)
}
if _, dup := seenTokenRef[ref]; dup {
return fmt.Errorf("openai.principal_tokens: duplicate token_ref %q", ref)
}
seenTokenRef[ref] = struct{}{}
hash := strings.ToLower(strings.TrimSpace(t.TokenHashSHA256))
if len(hash) != 64 {
return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be a 64-character hex SHA-256 hash", ref)
}
if _, err := hex.DecodeString(hash); err != nil {
return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be hex-encoded: %w", ref, err)
}
if _, dup := seenHash[hash]; dup {
return fmt.Errorf("openai.principal_tokens: duplicate token_hash_sha256 for token_ref %q", ref)
}
seenHash[hash] = struct{}{}
if strings.TrimSpace(t.PrincipalRef) == "" {
return fmt.Errorf("openai.principal_tokens[%q]: principal_ref must not be empty", ref)
}
}
return nil
}
func normalizeOpenAIProviderAuth(v *viper.Viper, auth *EdgeOpenAIProviderAuthConf) error {
if !auth.Enabled {
return nil
}
if v.InConfig("openai.provider_auth.from_header") {
auth.FromHeader = strings.TrimSpace(auth.FromHeader)
if auth.FromHeader == "" {
return fmt.Errorf("openai.provider_auth.from_header must not be empty when provider_auth is enabled")
}
} else {
auth.FromHeader = "X-IOP-Provider-Authorization"
}
if v.InConfig("openai.provider_auth.target_header") {
auth.TargetHeader = strings.TrimSpace(auth.TargetHeader)
if auth.TargetHeader == "" {
return fmt.Errorf("openai.provider_auth.target_header must not be empty when provider_auth is enabled")
}
} else {
auth.TargetHeader = "Authorization"
}
auth.Scheme = strings.TrimSpace(auth.Scheme)
if auth.Scheme == "" {
auth.Scheme = "Bearer"
}
if !v.InConfig("openai.provider_auth.required") {
auth.Required = true
}
return nil
}
// validateProviderLongContextBudget enforces the long-context admission budget:
// every provider referenced by a model group with a positive context window must
// have total_context_tokens >= context_window_tokens * long_context_capacity.
// Providers with long_context_capacity == 0 declare no long-slot budget and are
// therefore unconstrained by this check.
func validateProviderLongContextBudget(m ModelCatalogEntry, providerByID map[string]NodeProviderConf) error {
if m.ContextWindowTokens <= 0 {
return nil
}
for pid := range m.Providers {
p := strings.TrimSpace(pid)
prov, ok := providerByID[p]
if !ok {
continue
}
if prov.LongContextCapacity <= 0 {
continue
}
required := m.ContextWindowTokens * prov.LongContextCapacity
if prov.TotalContextTokens < required {
return fmt.Errorf("providers[%q].total_context_tokens (%d) must be >= context_window_tokens (%d) * long_context_capacity (%d) = %d",
p, prov.TotalContextTokens, m.ContextWindowTokens, prov.LongContextCapacity, required)
}
}
return nil
}
func checkUniqueNames(field string, name func(int) string, n int) error {
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
k := name(i)
if k == "" {
return fmt.Errorf("%s[%d]: name must not be empty", field, i)
}
if _, dup := seen[k]; dup {
return fmt.Errorf("%s: duplicate name %q", field, k)
}
seen[k] = struct{}{}
}
return nil
}
func validateProviderQueueConfig(field string, capacity, maxQueue, queueTimeoutMS, requestTimeoutMS int) error {
if capacity < 0 {
return fmt.Errorf("%s.capacity must be non-negative", field)
}
if maxQueue < 0 {
return fmt.Errorf("%s.max_queue must be non-negative", field)
}
if queueTimeoutMS < 0 {
return fmt.Errorf("%s.queue_timeout_ms must be non-negative", field)
}
if requestTimeoutMS < 0 {
return fmt.Errorf("%s.request_timeout_ms must be non-negative", field)
}
return nil
}
// CheckProviderLegacyConflict validates that when a provider-first resource
// declares an ID matching an enabled legacy adapter instance name, the
// provider's execution fields match the legacy adapter's fields. A mismatch
// produces a validation error to prevent silent precedence between the two
// config paths.
func CheckProviderLegacyConflict(nodeIdx int, nodeName string, node *NodeDefinition) error {
// Build a map of enabled adapter instance names to their configs per type.
legacyByKey := make(map[string]*legacyAdapterRef)
for _, inst := range node.Adapters.OllamaInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "ollama",
BaseURL: inst.BaseURL,
ContextSize: inst.ContextSize,
Capacity: inst.Capacity,
}
}
}
for _, inst := range node.Adapters.VllmInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "vllm",
Endpoint: inst.Endpoint,
Capacity: inst.Capacity,
}
}
}
for _, inst := range node.Adapters.OpenAICompatInstances {
if inst.Enabled {
legacyByKey[inst.Name] = &legacyAdapterRef{
Type: "openai_compat",
Provider: inst.Provider,
Endpoint: inst.Endpoint,
Headers: inst.Headers,
Capacity: inst.Capacity,
MaxQueue: inst.MaxQueue,
QueueTimeoutMS: inst.QueueTimeoutMS,
}
}
}
// CLI legacy: enabled cli adapter with its profiles.
if node.Adapters.CLI.Enabled {
for name, profile := range node.Adapters.CLI.Profiles {
legacyByKey[name] = &legacyAdapterRef{
Type: "cli",
Command: profile.Command,
Args: profile.Args,
}
}
}
for j, p := range node.Providers {
legacy, exists := legacyByKey[p.ID]
if !exists {
continue
}
normType := NormalizeProviderType(p.Type)
switch normType {
case "ollama":
if legacy.Type != "ollama" {
continue
}
if p.BaseURL != "" && p.BaseURL != legacy.BaseURL {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: base_url mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, p.ID, p.BaseURL, legacy.BaseURL)
}
if p.ContextSize != 0 && p.ContextSize != legacy.ContextSize {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: context_size mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, p.ID, p.ContextSize, legacy.ContextSize)
}
if p.Capacity != legacy.Capacity {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: capacity mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, p.ID, p.Capacity, legacy.Capacity)
}
case "vllm", "openai_compat":
if legacy.Type != "vllm" && legacy.Type != "openai_compat" {
continue
}
if p.Endpoint != "" && p.Endpoint != legacy.Endpoint {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: endpoint mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, legacy.Type, p.ID, p.Endpoint, legacy.Endpoint)
}
if p.Capacity != legacy.Capacity {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: capacity mismatch (provider=%d, legacy=%d)",
nodeIdx, j, p.ID, legacy.Type, p.ID, p.Capacity, legacy.Capacity)
}
case "cli":
if legacy.Type != "cli" {
continue
}
if p.Command != "" && p.Command != legacy.Command {
return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.cli profile %q: command mismatch (provider=%q, legacy=%q)",
nodeIdx, j, p.ID, p.ID, p.Command, legacy.Command)
}
}
}
return nil
}
type legacyAdapterRef struct {
Type string
Endpoint string
BaseURL string
Provider string
Headers map[string]string
ContextSize int
Capacity int
MaxQueue int
QueueTimeoutMS int
Command string
Args []string
}