380 lines
13 KiB
Go
380 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const AgentKindGenericNode = "generic-node"
|
|
|
|
// resolveProtocolProfiles resolves the top-level protocol_profiles catalog
|
|
// (built-in + custom overlays) and writes an immutable ConcreteProtocolProfile
|
|
// snapshot onto each provider that has a profile selector or a legacy type
|
|
// mapping. It is called once during Edge config normalization.
|
|
func resolveProtocolProfiles(cfg *EdgeConfig) error {
|
|
catalog, err := buildProfileCatalog(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profileIDs := make([]string, 0, len(catalog))
|
|
for id := range catalog {
|
|
profileIDs = append(profileIDs, id)
|
|
}
|
|
sort.Strings(profileIDs)
|
|
for _, id := range profileIDs {
|
|
if _, err := ResolveProtocolProfile(id, "", catalog); err != nil {
|
|
return fmt.Errorf("protocol_profiles[%q]: %w", id, err)
|
|
}
|
|
}
|
|
for i := range cfg.Nodes {
|
|
for j := range cfg.Nodes[i].Providers {
|
|
p := &cfg.Nodes[i].Providers[j]
|
|
// Skip providers that have no explicit profile selector and no
|
|
// legacy type mapping. Only OpenAI-compatible aliases and explicit
|
|
// profile selectors produce a concrete profile snapshot; other
|
|
// provider types (ollama, cli, etc.) are handled by their own
|
|
// adapter paths.
|
|
if strings.TrimSpace(p.Profile) == "" && LegacyProviderTypeToProfile(p.Type) == "" {
|
|
continue
|
|
}
|
|
resolved, err := ResolveProtocolProfile(p.Profile, p.Type, catalog)
|
|
if err != nil {
|
|
return fmt.Errorf("nodes[%d].providers[%d] %q: %w", i, j, p.ID, err)
|
|
}
|
|
resolved = resolved.Clone()
|
|
if adapterRef := strings.TrimSpace(p.Adapter); adapterRef != "" {
|
|
backingEndpoint, err := resolveProtocolBackingEndpoint(cfg.Nodes[i].Adapters, adapterRef)
|
|
if err != nil {
|
|
nodeID := cfg.Nodes[i].ID
|
|
if nodeID == "" {
|
|
nodeID = cfg.Nodes[i].Alias
|
|
}
|
|
return fmt.Errorf("node %q provider %q adapter %q: %w", nodeID, p.ID, adapterRef, err)
|
|
}
|
|
normalized, err := overlayProtocolEndpoint(resolved.BaseURL, backingEndpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("nodes[%d].providers[%d] %q: backing adapter endpoint: %w", i, j, p.ID, err)
|
|
}
|
|
resolved.BaseURL = normalized
|
|
}
|
|
if endpoint := effectiveProtocolEndpoint(*p); endpoint != "" {
|
|
normalized, err := overlayProtocolEndpoint(resolved.BaseURL, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("nodes[%d].providers[%d] %q: protocol endpoint: %w", i, j, p.ID, err)
|
|
}
|
|
resolved.BaseURL = normalized
|
|
}
|
|
snapshot := resolved.Clone()
|
|
p.RuntimeProfile = &snapshot
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveProtocolBackingEndpoint resolves the legacy HTTP adapter instance
|
|
// that supplies transport settings for an adapter-backed protocol profile.
|
|
// Exact instance names win; a type-name route is valid only with one enabled
|
|
// instance, matching the Node router's ambiguity contract.
|
|
func resolveProtocolBackingEndpoint(adapters AdaptersConf, ref string) (string, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
type exactMatch struct {
|
|
enabled bool
|
|
endpoint string
|
|
typeName string
|
|
}
|
|
var exact []exactMatch
|
|
for _, inst := range adapters.VllmInstances {
|
|
if inst.Name == ref {
|
|
exact = append(exact, exactMatch{inst.Enabled, inst.Endpoint, "vllm"})
|
|
}
|
|
}
|
|
for _, inst := range adapters.OpenAICompatInstances {
|
|
if inst.Name == ref {
|
|
exact = append(exact, exactMatch{inst.Enabled, inst.Endpoint, "openai_compat"})
|
|
}
|
|
}
|
|
if len(exact) > 1 {
|
|
return "", fmt.Errorf("backing adapter reference is ambiguous across supported HTTP adapter types")
|
|
}
|
|
if len(exact) == 1 {
|
|
if !exact[0].enabled {
|
|
return "", fmt.Errorf("backing %s adapter instance is disabled", exact[0].typeName)
|
|
}
|
|
return exact[0].endpoint, nil
|
|
}
|
|
|
|
var enabled []string
|
|
disabled := 0
|
|
switch ref {
|
|
case "vllm":
|
|
for _, inst := range adapters.VllmInstances {
|
|
if inst.Enabled {
|
|
enabled = append(enabled, inst.Endpoint)
|
|
} else {
|
|
disabled++
|
|
}
|
|
}
|
|
case "openai_compat":
|
|
for _, inst := range adapters.OpenAICompatInstances {
|
|
if inst.Enabled {
|
|
enabled = append(enabled, inst.Endpoint)
|
|
} else {
|
|
disabled++
|
|
}
|
|
}
|
|
default:
|
|
return "", fmt.Errorf("backing adapter reference does not match a vllm or openai_compat instance")
|
|
}
|
|
switch len(enabled) {
|
|
case 1:
|
|
return enabled[0], nil
|
|
case 0:
|
|
if disabled > 0 {
|
|
return "", fmt.Errorf("backing adapter type has no enabled instance")
|
|
}
|
|
return "", fmt.Errorf("backing adapter reference is missing")
|
|
default:
|
|
return "", fmt.Errorf("backing adapter type is ambiguous (%d enabled instances)", len(enabled))
|
|
}
|
|
}
|
|
|
|
// buildProfileCatalog combines the immutable built-in profiles with the
|
|
// top-level custom overlays from EdgeConfig. Custom overlays may extend
|
|
// built-ins via the Base field.
|
|
func buildProfileCatalog(cfg *EdgeConfig) (map[string]ProtocolProfileConf, error) {
|
|
catalog := BuiltInProtocolProfileCatalog()
|
|
for id, p := range cfg.ProtocolProfiles {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return nil, fmt.Errorf("protocol_profiles: profile id must not be empty")
|
|
}
|
|
if _, reserved := catalog[id]; reserved {
|
|
return nil, fmt.Errorf("protocol_profiles[%q]: built-in profile id is reserved", id)
|
|
}
|
|
catalog[id] = deepCopyProfileConf(p)
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
// effectiveProtocolEndpoint applies provider-first precedence: endpoint is the
|
|
// HTTP adapter endpoint, while base_url is accepted as a compatibility alias.
|
|
func effectiveProtocolEndpoint(p NodeProviderConf) string {
|
|
if endpoint := strings.TrimSpace(p.Endpoint); endpoint != "" {
|
|
return endpoint
|
|
}
|
|
return strings.TrimSpace(p.BaseURL)
|
|
}
|
|
|
|
func normalizeProtocolEndpoint(raw string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return "", fmt.Errorf("%q must be an absolute http(s) URL", raw)
|
|
}
|
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
// overlayProtocolEndpoint replaces the upstream origin and honors an explicit
|
|
// provider path. A root-only endpoint retains the profile's documented base
|
|
// path (for example /v1), preserving legacy root endpoint compatibility.
|
|
func overlayProtocolEndpoint(profileBaseURL, rawEndpoint string) (string, error) {
|
|
endpoint, err := normalizeProtocolEndpoint(rawEndpoint)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parsedEndpoint, _ := url.Parse(endpoint)
|
|
if parsedEndpoint.Path == "" || parsedEndpoint.Path == "/" {
|
|
parsedProfile, parseErr := url.Parse(profileBaseURL)
|
|
if parseErr != nil {
|
|
return "", fmt.Errorf("profile base_url %q is invalid: %w", profileBaseURL, parseErr)
|
|
}
|
|
parsedEndpoint.Path = strings.TrimRight(parsedProfile.Path, "/")
|
|
}
|
|
return parsedEndpoint.String(), nil
|
|
}
|
|
|
|
// NormalizeAgentKind returns the canonical agent kind for a node definition.
|
|
// An empty value defaults to generic-node; any other unsupported value is
|
|
// rejected so misconfigured kinds fail at config load time.
|
|
func NormalizeAgentKind(kind string) (string, error) {
|
|
switch kind {
|
|
case "":
|
|
return AgentKindGenericNode, nil
|
|
case AgentKindGenericNode:
|
|
return kind, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid agent_kind %q (allowed: %q)", kind, AgentKindGenericNode)
|
|
}
|
|
}
|
|
|
|
// NormalizeProviderType returns the canonical driver name for provider type.
|
|
func NormalizeProviderType(t string) string {
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case "openai_compat", "openai_api", "vllm", "vllm-mlx", "lemonade", "sglang", "seulgivibe_claude", "seulgivibe_openai":
|
|
return "openai_compat"
|
|
case "ollama":
|
|
return "ollama"
|
|
case "cli":
|
|
return "cli"
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
// NormalizeAdapters promotes legacy single-instance Ollama/Vllm fields into the
|
|
// typed instance slices and validates that all instance names are unique.
|
|
func NormalizeAdapters(a *AdaptersConf) error {
|
|
return normalizeAdapters(a)
|
|
}
|
|
|
|
func normalizeAdapters(a *AdaptersConf) error {
|
|
if a.Ollama.Enabled {
|
|
existing := findOllamaInstance(a.OllamaInstances, "ollama")
|
|
if existing == nil {
|
|
a.OllamaInstances = append([]OllamaInstanceConf{{
|
|
Name: "ollama",
|
|
Enabled: a.Ollama.Enabled,
|
|
BaseURL: a.Ollama.BaseURL,
|
|
ContextSize: a.Ollama.ContextSize,
|
|
Capacity: a.Ollama.Capacity,
|
|
MaxQueue: a.Ollama.MaxQueue,
|
|
QueueTimeoutMS: a.Ollama.QueueTimeoutMS,
|
|
RequestTimeoutMS: a.Ollama.RequestTimeoutMS,
|
|
}}, a.OllamaInstances...)
|
|
} else if !sameOllamaInstance(*existing, a.Ollama) {
|
|
return fmt.Errorf("ollama: legacy field conflicts with explicit instance %q: enabled/base_url/context_size/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "ollama")
|
|
}
|
|
}
|
|
if a.Vllm.Enabled {
|
|
existing := findVllmInstance(a.VllmInstances, "vllm")
|
|
if existing == nil {
|
|
a.VllmInstances = append([]VllmInstanceConf{{
|
|
Name: "vllm",
|
|
Enabled: a.Vllm.Enabled,
|
|
Endpoint: a.Vllm.Endpoint,
|
|
Capacity: a.Vllm.Capacity,
|
|
MaxQueue: a.Vllm.MaxQueue,
|
|
QueueTimeoutMS: a.Vllm.QueueTimeoutMS,
|
|
RequestTimeoutMS: a.Vllm.RequestTimeoutMS,
|
|
}}, a.VllmInstances...)
|
|
} else if !sameVllmInstance(*existing, a.Vllm) {
|
|
return fmt.Errorf("vllm: legacy field conflicts with explicit instance %q: enabled/endpoint/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "vllm")
|
|
}
|
|
}
|
|
if a.OpenAICompat.Enabled {
|
|
existing := findOpenAICompatInstance(a.OpenAICompatInstances, "openai_compat")
|
|
if existing == nil {
|
|
a.OpenAICompatInstances = append([]OpenAICompatInstanceConf{{
|
|
Name: "openai_compat",
|
|
Enabled: a.OpenAICompat.Enabled,
|
|
Provider: a.OpenAICompat.Provider,
|
|
Endpoint: a.OpenAICompat.Endpoint,
|
|
Headers: a.OpenAICompat.Headers,
|
|
Capacity: a.OpenAICompat.Capacity,
|
|
MaxQueue: a.OpenAICompat.MaxQueue,
|
|
QueueTimeoutMS: a.OpenAICompat.QueueTimeoutMS,
|
|
RequestTimeoutMS: a.OpenAICompat.RequestTimeoutMS,
|
|
}}, a.OpenAICompatInstances...)
|
|
} else if !sameOpenAICompatInstance(*existing, a.OpenAICompat) {
|
|
return fmt.Errorf("openai_compat: legacy field conflicts with explicit instance %q: enabled/provider/endpoint/headers/capacity/max_queue/queue_timeout_ms/request_timeout_ms mismatch", "openai_compat")
|
|
}
|
|
}
|
|
|
|
if err := checkUniqueNames("ollama_instances", func(i int) string { return a.OllamaInstances[i].Name }, len(a.OllamaInstances)); err != nil {
|
|
return err
|
|
}
|
|
if err := checkUniqueNames("vllm_instances", func(i int) string { return a.VllmInstances[i].Name }, len(a.VllmInstances)); err != nil {
|
|
return err
|
|
}
|
|
if err := checkUniqueNames("openai_compat_instances", func(i int) string { return a.OpenAICompatInstances[i].Name }, len(a.OpenAICompatInstances)); err != nil {
|
|
return err
|
|
}
|
|
for i, inst := range a.OllamaInstances {
|
|
field := fmt.Sprintf("ollama_instances[%d]", i)
|
|
if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i, inst := range a.VllmInstances {
|
|
field := fmt.Sprintf("vllm_instances[%d]", i)
|
|
if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for i, inst := range a.OpenAICompatInstances {
|
|
field := fmt.Sprintf("openai_compat_instances[%d]", i)
|
|
if err := validateProviderQueueConfig(field, inst.Capacity, inst.MaxQueue, inst.QueueTimeoutMS, inst.RequestTimeoutMS); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameOllamaInstance(inst OllamaInstanceConf, legacy OllamaConf) bool {
|
|
return inst.Enabled == legacy.Enabled &&
|
|
inst.BaseURL == legacy.BaseURL &&
|
|
inst.ContextSize == legacy.ContextSize &&
|
|
inst.Capacity == legacy.Capacity &&
|
|
inst.MaxQueue == legacy.MaxQueue &&
|
|
inst.QueueTimeoutMS == legacy.QueueTimeoutMS &&
|
|
inst.RequestTimeoutMS == legacy.RequestTimeoutMS
|
|
}
|
|
|
|
func sameVllmInstance(inst VllmInstanceConf, legacy VllmConf) bool {
|
|
return inst.Enabled == legacy.Enabled &&
|
|
inst.Endpoint == legacy.Endpoint &&
|
|
inst.Capacity == legacy.Capacity &&
|
|
inst.MaxQueue == legacy.MaxQueue &&
|
|
inst.QueueTimeoutMS == legacy.QueueTimeoutMS &&
|
|
inst.RequestTimeoutMS == legacy.RequestTimeoutMS
|
|
}
|
|
|
|
func findOllamaInstance(instances []OllamaInstanceConf, name string) *OllamaInstanceConf {
|
|
for i := range instances {
|
|
if instances[i].Name == name {
|
|
return &instances[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findVllmInstance(instances []VllmInstanceConf, name string) *VllmInstanceConf {
|
|
for i := range instances {
|
|
if instances[i].Name == name {
|
|
return &instances[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameOpenAICompatInstance(inst OpenAICompatInstanceConf, legacy OpenAICompatConf) bool {
|
|
if inst.Enabled != legacy.Enabled ||
|
|
inst.Provider != legacy.Provider ||
|
|
inst.Endpoint != legacy.Endpoint ||
|
|
inst.Capacity != legacy.Capacity ||
|
|
inst.MaxQueue != legacy.MaxQueue ||
|
|
inst.QueueTimeoutMS != legacy.QueueTimeoutMS ||
|
|
inst.RequestTimeoutMS != legacy.RequestTimeoutMS {
|
|
return false
|
|
}
|
|
if len(inst.Headers) != len(legacy.Headers) {
|
|
return false
|
|
}
|
|
for k, v := range inst.Headers {
|
|
if lv, ok := legacy.Headers[k]; !ok || lv != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func findOpenAICompatInstance(instances []OpenAICompatInstanceConf, name string) *OpenAICompatInstanceConf {
|
|
for i := range instances {
|
|
if instances[i].Name == name {
|
|
return &instances[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|