269 lines
8.9 KiB
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
|
|
}
|