iop/packages/go/config/normalize.go
toki f2306f4dc8 feat(protocol-profile): 멀티 프로토콜 프로필을 추가한다
OpenAI 호환 및 Anthropic Messages 경로의 프로토콜 프로필, 라우팅 계약, 테스트와 실행 증거를 함께 반영한다.
2026-08-01 10:33:10 +09:00

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
}