- Add mock resource activation for G06 review followup alignment - Expand edgevalidate tests and refactor validation logic - Update node mapper with improved handling - Add integration and server test fixes - Update Go config with new options - Fix E2E scripts (cli-workspace, lemonade, ollama, vllm)
1075 lines
45 KiB
Go
1075 lines
45 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
const AgentKindGenericNode = "generic-node"
|
|
|
|
// 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", "lemonade", "sglang":
|
|
return "openai_compat"
|
|
case "ollama":
|
|
return "ollama"
|
|
case "cli":
|
|
return "cli"
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
type NodeConfig struct {
|
|
Transport TransportConf `mapstructure:"transport" yaml:"transport"`
|
|
Reconnect ReconnectConf `mapstructure:"reconnect" yaml:"reconnect"`
|
|
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
|
|
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
|
|
}
|
|
|
|
// ReconnectConf controls Edge reconnect behaviour after a disconnect.
|
|
type ReconnectConf struct {
|
|
IntervalSec int `mapstructure:"interval_sec" yaml:"interval_sec"`
|
|
MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"`
|
|
}
|
|
|
|
// EdgeRefreshConf configures the Edge-local refresh admin HTTP server.
|
|
// When Enabled is false (default) the admin server is not started and
|
|
// config refresh is not available without a restart.
|
|
type EdgeRefreshConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
|
}
|
|
|
|
type EdgeConfig struct {
|
|
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
|
|
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
|
|
Bootstrap EdgeBootstrapConf `mapstructure:"bootstrap" yaml:"bootstrap"`
|
|
OpenAI EdgeOpenAIConf `mapstructure:"openai" yaml:"openai"`
|
|
A2A EdgeA2AConf `mapstructure:"a2a" yaml:"a2a"`
|
|
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
|
|
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
|
|
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
|
|
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
|
|
ControlPlane EdgeControlPlaneConf `mapstructure:"control_plane" yaml:"control_plane"`
|
|
Refresh EdgeRefreshConf `mapstructure:"refresh" yaml:"refresh,omitempty"`
|
|
// LongContextThresholdTokens is the input-token estimate at or above which
|
|
// a request is classified as long-context for admission policy.
|
|
LongContextThresholdTokens int `mapstructure:"long_context_threshold_tokens" yaml:"long_context_threshold_tokens,omitempty"`
|
|
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
|
|
// Models is the top-level model catalog. Each entry defines a canonical
|
|
// routing key (ID) and its provider-pool mapping. When set it takes
|
|
// precedence over the legacy openai.model_routes for runtime dispatch.
|
|
Models []ModelCatalogEntry `mapstructure:"models" yaml:"models,omitempty"`
|
|
}
|
|
|
|
// EdgeControlPlaneConf holds the outbound connector settings for the
|
|
// Control Plane-Edge wire. When Enabled is false or WireAddr is empty the
|
|
// connector is a no-op and existing Edge behaviour is unaffected.
|
|
type EdgeControlPlaneConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
WireAddr string `mapstructure:"wire_addr" yaml:"wire_addr"`
|
|
ReconnectIntervalSec int `mapstructure:"reconnect_interval_sec" yaml:"reconnect_interval_sec"`
|
|
}
|
|
|
|
// EdgeInfo carries this edge instance's stable identity for loading and logging.
|
|
// It is not used for Control Plane registration.
|
|
type EdgeInfo struct {
|
|
ID string `mapstructure:"id" yaml:"id"`
|
|
Name string `mapstructure:"name" yaml:"name"`
|
|
}
|
|
|
|
// NodeDefinition is the edge-side record for a pre-registered node.
|
|
type NodeDefinition struct {
|
|
ID string `mapstructure:"id" yaml:"id"` // stable node identity; if empty, a UUID v4 is auto-assigned (dev fallback only)
|
|
Alias string `mapstructure:"alias" yaml:"alias"`
|
|
Token string `mapstructure:"token" yaml:"token"`
|
|
AgentKind string `mapstructure:"agent_kind" yaml:"agent_kind"` // generic-node (default)
|
|
Adapters AdaptersConf `mapstructure:"adapters" yaml:"adapters"`
|
|
Providers []NodeProviderConf `mapstructure:"providers" yaml:"providers,omitempty"`
|
|
Runtime RuntimeConf `mapstructure:"runtime" yaml:"runtime"`
|
|
}
|
|
|
|
// Category represents the provider category.
|
|
type Category string
|
|
|
|
const (
|
|
CategoryAPI Category = "api"
|
|
CategoryCLI Category = "cli"
|
|
CategoryLocalInference Category = "local_inference"
|
|
)
|
|
|
|
// validateCategory returns nil when category is valid.
|
|
func validateCategory(c Category) error {
|
|
switch c {
|
|
case CategoryAPI, CategoryCLI, CategoryLocalInference:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid provider category %q (allowed: %q, %q, %q)", c, CategoryAPI, CategoryCLI, CategoryLocalInference)
|
|
}
|
|
}
|
|
|
|
// NodeProviderConf defines a resource/provider candidate that belongs to a
|
|
// node. Provider-first resources can declare type-specific execution fields
|
|
// directly, while Adapter remains available for legacy/compat overrides.
|
|
type NodeProviderConf struct {
|
|
// ID uniquely identifies this provider within the node and is referenced
|
|
// by models[].providers keys.
|
|
ID string `mapstructure:"id" yaml:"id"`
|
|
// Type is the runtime type (e.g. "ollama", "vllm", "lemonade", "sglang", "openai_api", "cli").
|
|
Type string `mapstructure:"type" yaml:"type"`
|
|
// Category classifies the resource; MVP values: api, cli, local_inference.
|
|
Category Category `mapstructure:"category" yaml:"category"`
|
|
// Adapter is the optional concrete enabled adapter instance key used by
|
|
// legacy/compat provider resources. A legacy type-name route is valid only
|
|
// when exactly one enabled adapter instance of that type exists on the same
|
|
// node.
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"`
|
|
// Models lists the served model names this provider can actually serve.
|
|
Models []string `mapstructure:"models" yaml:"models,omitempty"`
|
|
// Health is the observed provider health state.
|
|
Health string `mapstructure:"health" yaml:"health,omitempty"`
|
|
// Capacity is the provider/resource maximum concurrent execution slots.
|
|
// Provider-pool dispatch treats 0 as not dispatchable rather than falling
|
|
// back to node runtime concurrency metadata.
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity,omitempty"`
|
|
// TotalContextTokens is the provider runtime's total KV/context budget used
|
|
// as the long-context admission budget. Runtime-specific options (e.g.
|
|
// ctx_size) map onto this value. Must satisfy
|
|
// total_context_tokens >= context_window_tokens * long_context_capacity for
|
|
// every model group that references this provider.
|
|
TotalContextTokens int `mapstructure:"total_context_tokens" yaml:"total_context_tokens,omitempty"`
|
|
// LongContextCapacity is the number of concurrent long-context slots this
|
|
// provider allows (context_window_tokens-sized requests). Long requests
|
|
// occupy both a normal capacity slot and a long slot. Zero means the
|
|
// provider declares no dedicated long-slot limit.
|
|
LongContextCapacity int `mapstructure:"long_context_capacity" yaml:"long_context_capacity,omitempty"`
|
|
// MaxQueue is the maximum queue depth.
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
|
|
// QueueTimeoutMS is the queue timeout in milliseconds. Zero means no queue
|
|
// timeout when a queue policy is configured.
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
|
|
// Priority is the provider dispatch priority. Lower values are preferred
|
|
// when in_flight counts are equal. Must be non-negative; zero is the
|
|
// default priority.
|
|
Priority int `mapstructure:"priority" yaml:"priority,omitempty"`
|
|
// LifecycleCapabilities lists coarse lifecycle capabilities.
|
|
LifecycleCapabilities []string `mapstructure:"lifecycle_capabilities" yaml:"lifecycle_capabilities,omitempty"`
|
|
// Enabled controls whether this provider participates in the dispatch pool.
|
|
// Nil (omitted) means enabled; false disables the provider without removing it.
|
|
Enabled *bool `mapstructure:"enabled" yaml:"enabled,omitempty"`
|
|
|
|
// Provider-First fields (G06)
|
|
Provider string `mapstructure:"provider" yaml:"provider,omitempty"`
|
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint,omitempty"`
|
|
BaseURL string `mapstructure:"base_url" yaml:"base_url,omitempty"`
|
|
Headers map[string]string `mapstructure:"headers" yaml:"headers,omitempty"`
|
|
Command string `mapstructure:"command" yaml:"command,omitempty"`
|
|
Args []string `mapstructure:"args" yaml:"args,omitempty"`
|
|
Env []string `mapstructure:"env" yaml:"env,omitempty"`
|
|
Mode string `mapstructure:"mode" yaml:"mode,omitempty"`
|
|
ResumeArgs []string `mapstructure:"resume_args" yaml:"resume_args,omitempty"`
|
|
OutputFormat string `mapstructure:"output_format" yaml:"output_format,omitempty"`
|
|
ContextSize int `mapstructure:"context_size" yaml:"context_size,omitempty"`
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms,omitempty"`
|
|
}
|
|
|
|
// Validate checks internal consistency of the provider candidate config.
|
|
func (p NodeProviderConf) Validate() error {
|
|
id := strings.TrimSpace(p.ID)
|
|
if id == "" {
|
|
return fmt.Errorf("nodes[].providers[].id must not be empty")
|
|
}
|
|
if strings.TrimSpace(p.Type) == "" {
|
|
return fmt.Errorf("nodes[].providers[%q]: type must not be empty", id)
|
|
}
|
|
if err := validateCategory(p.Category); err != nil {
|
|
return fmt.Errorf("nodes[].providers[%q]: %w", id, err)
|
|
}
|
|
for i, m := range p.Models {
|
|
if strings.TrimSpace(m) == "" {
|
|
return fmt.Errorf("nodes[].providers[%q].models[%d]: served model name must not be empty", id, i)
|
|
}
|
|
}
|
|
if p.Capacity < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].capacity must be non-negative", id)
|
|
}
|
|
if p.MaxQueue < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].max_queue must be non-negative", id)
|
|
}
|
|
if p.QueueTimeoutMS < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].queue_timeout_ms must be non-negative", id)
|
|
}
|
|
if p.RequestTimeoutMS < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].request_timeout_ms must be non-negative", id)
|
|
}
|
|
if p.ContextSize < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].context_size must be non-negative", id)
|
|
}
|
|
if p.Priority < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].priority must be non-negative", id)
|
|
}
|
|
if p.TotalContextTokens < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].total_context_tokens must be non-negative", id)
|
|
}
|
|
if p.LongContextCapacity < 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].long_context_capacity must be non-negative", id)
|
|
}
|
|
if p.LongContextCapacity > 0 && p.TotalContextTokens <= 0 {
|
|
return fmt.Errorf("nodes[].providers[%q].total_context_tokens must be positive when long_context_capacity > 0", id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProviderEnabled reports whether a provider is enabled.
|
|
// Returns true when Enabled is nil (omitted) or *Enabled is true.
|
|
// Returns false only when Enabled is explicitly set to false.
|
|
func ProviderEnabled(p NodeProviderConf) bool {
|
|
return p.Enabled == nil || *p.Enabled
|
|
}
|
|
|
|
type NodeInfo struct {
|
|
ID string `mapstructure:"id" yaml:"id"`
|
|
Name string `mapstructure:"name" yaml:"name"`
|
|
}
|
|
|
|
type EdgeServerConf struct {
|
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
|
AdvertiseHost string `mapstructure:"advertise_host" yaml:"advertise_host"`
|
|
}
|
|
|
|
type EdgeBootstrapConf struct {
|
|
ArtifactBaseURL string `mapstructure:"artifact_base_url" yaml:"artifact_base_url"`
|
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
|
ArtifactDir string `mapstructure:"artifact_dir" yaml:"artifact_dir"`
|
|
}
|
|
|
|
// OpenAIRouteEntry maps an external model id to an internal adapter/target routing.
|
|
// Fields not set here fall back to the top-level EdgeOpenAIConf defaults.
|
|
type OpenAIRouteEntry struct {
|
|
Model string `mapstructure:"model" yaml:"model"`
|
|
NodeRef string `mapstructure:"node" yaml:"node,omitempty"`
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"`
|
|
Target string `mapstructure:"target" yaml:"target"`
|
|
SessionID string `mapstructure:"session_id" yaml:"session_id,omitempty"`
|
|
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec,omitempty"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue,omitempty"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms,omitempty"`
|
|
WorkspaceRequired bool `mapstructure:"workspace_required" yaml:"workspace_required,omitempty"`
|
|
}
|
|
|
|
// ModelCatalogEntry is a top-level Edge config entry that defines a canonical
|
|
// routing key (`ID`) and its provider-pool mapping. Each provider id key
|
|
// maps to the concrete served model name that the provider actually exposes.
|
|
type ModelCatalogEntry struct {
|
|
// ID is the canonical routing key (e.g. "qwen3.6:35b") that matches the
|
|
// external OpenAI-compatible model field.
|
|
ID string `mapstructure:"id" yaml:"id"`
|
|
// DisplayName is a human-readable name shown in operation dashboards.
|
|
DisplayName string `mapstructure:"display_name" yaml:"display_name,omitempty"`
|
|
// ContextWindowTokens is the model group's single-request maximum context
|
|
// contract shared by every provider in the group.
|
|
ContextWindowTokens int `mapstructure:"context_window_tokens" yaml:"context_window_tokens,omitempty"`
|
|
// DefaultMaxTokens is injected when OpenAI-compatible requests omit an
|
|
// output-token limit for this model group.
|
|
DefaultMaxTokens int `mapstructure:"default_max_tokens" yaml:"default_max_tokens,omitempty"`
|
|
// MinMaxTokens raises too-small OpenAI-compatible output-token limits for
|
|
// this model group.
|
|
MinMaxTokens int `mapstructure:"min_max_tokens" yaml:"min_max_tokens,omitempty"`
|
|
// DefaultThinkingTokenBudget bounds reasoning tokens for think-capable
|
|
// providers when callers do not explicitly set a budget.
|
|
DefaultThinkingTokenBudget int `mapstructure:"default_thinking_token_budget" yaml:"default_thinking_token_budget,omitempty"`
|
|
// Providers maps provider id to the concrete served model name that the
|
|
// provider actually exposes. Keys must match nodes[].providers[].id.
|
|
Providers map[string]string `mapstructure:"providers" yaml:"providers"`
|
|
}
|
|
|
|
// buildProviderServedModelsIndex aggregates all nodes[].providers[].models
|
|
// keyed by provider id. Returns the index for Validate.
|
|
func buildProviderServedModelsIndex(nodes []NodeDefinition) map[string]map[string]struct{} {
|
|
serveModels := make(map[string]map[string]struct{})
|
|
for i := range nodes {
|
|
for _, p := range nodes[i].Providers {
|
|
if _, ok := serveModels[p.ID]; !ok {
|
|
serveModels[p.ID] = make(map[string]struct{})
|
|
}
|
|
for _, m := range p.Models {
|
|
serveModels[p.ID][m] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return serveModels
|
|
}
|
|
|
|
// Validate checks the structure and internal references of the model catalog entry.
|
|
// When serveModels is non-nil, it also verifies that each models[].providers[pid]=model
|
|
// refers to a served model that exists in nodes[].providers[id].models.
|
|
func (e ModelCatalogEntry) Validate(resolvedProviderIDs map[string]struct{}, serveModels map[string]map[string]struct{}) error {
|
|
id := strings.TrimSpace(e.ID)
|
|
if id == "" {
|
|
return fmt.Errorf("models[].id must not be empty")
|
|
}
|
|
if e.DefaultMaxTokens < 0 {
|
|
return fmt.Errorf("models[%q].default_max_tokens must be non-negative", id)
|
|
}
|
|
if e.MinMaxTokens < 0 {
|
|
return fmt.Errorf("models[%q].min_max_tokens must be non-negative", id)
|
|
}
|
|
if e.DefaultThinkingTokenBudget < 0 {
|
|
return fmt.Errorf("models[%q].default_thinking_token_budget must be non-negative", id)
|
|
}
|
|
if e.ContextWindowTokens < 0 {
|
|
return fmt.Errorf("models[%q].context_window_tokens must be non-negative", id)
|
|
}
|
|
if e.DefaultMaxTokens > 0 && e.MinMaxTokens > 0 && e.DefaultMaxTokens < e.MinMaxTokens {
|
|
return fmt.Errorf("models[%q].default_max_tokens must be greater than or equal to min_max_tokens", id)
|
|
}
|
|
if len(e.Providers) == 0 {
|
|
return fmt.Errorf("models[%q].providers must not be empty", id)
|
|
}
|
|
for pid, model := range e.Providers {
|
|
p := strings.TrimSpace(pid)
|
|
if p == "" {
|
|
return fmt.Errorf("models[%q].providers: provider id must not be empty", id)
|
|
}
|
|
m := strings.TrimSpace(model)
|
|
if m == "" {
|
|
return fmt.Errorf("models[%q].providers[%q]: served model name must not be empty", id, p)
|
|
}
|
|
if _, ok := resolvedProviderIDs[p]; !ok {
|
|
return fmt.Errorf("models[%q].providers[%q]: provider id %q does not resolve to any nodes[].providers[].id", id, p, p)
|
|
}
|
|
// Serve model membership validation (REVIEW_API-1):
|
|
// The served model must be listed in the provider's own models[].
|
|
if serveModels != nil {
|
|
servedSet, exists := serveModels[p]
|
|
if !exists {
|
|
// No providers list yet (should not happen in modern configs).
|
|
return fmt.Errorf("models[%q].providers[%q]: provider %q has no models[] defined", id, p, p)
|
|
}
|
|
if _, ok := servedSet[m]; !ok {
|
|
return fmt.Errorf("models[%q].providers[%q]: served model %q is not listed in nodes[].providers[%q].models", id, p, m, p)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type EdgeOpenAIConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
|
BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"`
|
|
NodeRef string `mapstructure:"node" yaml:"node"`
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
|
Target string `mapstructure:"target" yaml:"target"`
|
|
Models []string `mapstructure:"models" yaml:"models"`
|
|
ModelRoutes []OpenAIRouteEntry `mapstructure:"model_routes" yaml:"model_routes,omitempty"`
|
|
SessionID string `mapstructure:"session_id" yaml:"session_id"`
|
|
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
|
|
StrictOutput bool `mapstructure:"strict_output" yaml:"strict_output"`
|
|
StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"`
|
|
}
|
|
|
|
type EdgeA2AConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
|
Path string `mapstructure:"path" yaml:"path"`
|
|
NodeRef string `mapstructure:"node" yaml:"node"`
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
|
Target string `mapstructure:"target" yaml:"target"`
|
|
SessionID string `mapstructure:"session_id" yaml:"session_id"`
|
|
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
|
|
BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"`
|
|
}
|
|
|
|
type EdgeConsoleConf struct {
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
|
Target string `mapstructure:"target" yaml:"target"`
|
|
Agent string `mapstructure:"agent" yaml:"agent"` // legacy alias for target
|
|
Model string `mapstructure:"model" yaml:"model"` // legacy alias for target
|
|
SessionID string `mapstructure:"session_id" yaml:"session_id"`
|
|
Background bool `mapstructure:"background" yaml:"background"`
|
|
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
|
|
}
|
|
|
|
func (c EdgeConsoleConf) ResolveTarget() string {
|
|
if c.Target != "" {
|
|
return c.Target
|
|
}
|
|
if c.Agent != "" {
|
|
return c.Agent
|
|
}
|
|
return c.Model
|
|
}
|
|
|
|
// ResolveAgent is kept for callers still using the legacy console.agent name.
|
|
func (c EdgeConsoleConf) ResolveAgent() string {
|
|
return c.ResolveTarget()
|
|
}
|
|
|
|
type TransportConf struct {
|
|
EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"`
|
|
Token string `mapstructure:"token" yaml:"token"`
|
|
}
|
|
|
|
type TLSConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Cert string `mapstructure:"cert" yaml:"cert"`
|
|
Key string `mapstructure:"key" yaml:"key"`
|
|
CA string `mapstructure:"ca" yaml:"ca"`
|
|
}
|
|
|
|
type RuntimeConf struct {
|
|
// Concurrency is legacy compatibility metadata. Runtime admission must use
|
|
// provider/resource capacity rather than treating this as a node-wide gate.
|
|
Concurrency int `mapstructure:"concurrency" yaml:"concurrency"`
|
|
}
|
|
|
|
type SQLiteConf struct {
|
|
DSN string `mapstructure:"dsn" yaml:"dsn"`
|
|
}
|
|
|
|
type LoggingConf struct {
|
|
Level string `mapstructure:"level" yaml:"level"`
|
|
Pretty bool `mapstructure:"pretty" yaml:"pretty"`
|
|
Path string `mapstructure:"path" yaml:"path"`
|
|
}
|
|
|
|
type MetricsConf struct {
|
|
Port int `mapstructure:"port" yaml:"port"`
|
|
}
|
|
|
|
type AdaptersConf struct {
|
|
// Legacy single-instance fields. When non-empty they are normalised into
|
|
// OllamaInstances / VllmInstances / OpenAICompatInstances during config load
|
|
// so that all downstream code only needs to inspect the slice fields.
|
|
Ollama OllamaConf `mapstructure:"ollama" yaml:"ollama"`
|
|
Vllm VllmConf `mapstructure:"vllm" yaml:"vllm"`
|
|
OpenAICompat OpenAICompatConf `mapstructure:"openai_compat" yaml:"openai_compat"`
|
|
CLI CLIConf `mapstructure:"cli" yaml:"cli"`
|
|
Mock MockConf `mapstructure:"mock" yaml:"mock"`
|
|
|
|
// Multi-instance collections. Each entry carries a unique Name that acts as
|
|
// the stable adapter instance identity within the node. Names must be unique
|
|
// within each type collection; duplicate names are rejected at load time.
|
|
OllamaInstances []OllamaInstanceConf `mapstructure:"ollama_instances" yaml:"ollama_instances,omitempty"`
|
|
VllmInstances []VllmInstanceConf `mapstructure:"vllm_instances" yaml:"vllm_instances,omitempty"`
|
|
OpenAICompatInstances []OpenAICompatInstanceConf `mapstructure:"openai_compat_instances" yaml:"openai_compat_instances,omitempty"`
|
|
}
|
|
|
|
// OllamaInstanceConf is one named Ollama engine instance within a node.
|
|
type OllamaInstanceConf struct {
|
|
Name string `mapstructure:"name" yaml:"name"`
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
|
|
ContextSize int `mapstructure:"context_size" yaml:"context_size"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
// VllmInstanceConf is one named vLLM engine instance within a node.
|
|
type VllmInstanceConf struct {
|
|
Name string `mapstructure:"name" yaml:"name"`
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
type OpenAICompatConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Provider string `mapstructure:"provider" yaml:"provider"`
|
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
|
Headers map[string]string `mapstructure:"headers" yaml:"headers"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
type OpenAICompatInstanceConf struct {
|
|
Name string `mapstructure:"name" yaml:"name"`
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Provider string `mapstructure:"provider" yaml:"provider"`
|
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
|
Headers map[string]string `mapstructure:"headers" yaml:"headers"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
type OllamaConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
|
|
ContextSize int `mapstructure:"context_size" yaml:"context_size"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
type VllmConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
|
|
Capacity int `mapstructure:"capacity" yaml:"capacity"`
|
|
MaxQueue int `mapstructure:"max_queue" yaml:"max_queue"`
|
|
QueueTimeoutMS int `mapstructure:"queue_timeout_ms" yaml:"queue_timeout_ms"` // zero means no queue timeout
|
|
RequestTimeoutMS int `mapstructure:"request_timeout_ms" yaml:"request_timeout_ms"`
|
|
}
|
|
|
|
type CLIConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
Profiles map[string]CLIProfileConf `mapstructure:"profiles" yaml:"profiles"`
|
|
}
|
|
|
|
// MockConf defines the mock adapter instance. It has no execution fields and
|
|
// only serves as a flag to enable the mock adapter in the payload.
|
|
type MockConf struct {
|
|
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
|
|
}
|
|
|
|
type CLIProfileConf struct {
|
|
Command string `mapstructure:"command" yaml:"command"`
|
|
Args []string `mapstructure:"args" yaml:"args"`
|
|
Env []string `mapstructure:"env" yaml:"env"`
|
|
Persistent bool `mapstructure:"persistent" yaml:"persistent"`
|
|
Terminal bool `mapstructure:"terminal" yaml:"terminal"`
|
|
ResponseIdleTimeoutMS int `mapstructure:"response_idle_timeout_ms" yaml:"response_idle_timeout_ms"`
|
|
StartupIdleTimeoutMS int `mapstructure:"startup_idle_timeout_ms" yaml:"startup_idle_timeout_ms"`
|
|
OutputFormat string `mapstructure:"output_format" yaml:"output_format"`
|
|
CompletionMarker CompletionMarkerConf `mapstructure:"completion_marker" yaml:"completion_marker"`
|
|
Mode string `mapstructure:"mode" yaml:"mode"`
|
|
ResumeArgs []string `mapstructure:"resume_args" yaml:"resume_args"`
|
|
}
|
|
|
|
type CompletionMarkerConf struct {
|
|
Line string `mapstructure:"line" yaml:"line"`
|
|
Regex string `mapstructure:"regex" yaml:"regex"`
|
|
}
|
|
|
|
func (m CompletionMarkerConf) Empty() bool {
|
|
return m.Line == "" && m.Regex == ""
|
|
}
|
|
|
|
func Load(cfgFile string) (*NodeConfig, error) {
|
|
v := viper.New()
|
|
v.SetConfigFile(cfgFile)
|
|
setDefaults(v)
|
|
if err := v.ReadInConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg NodeConfig
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func LoadEdge(cfgFile string) (*EdgeConfig, error) {
|
|
v := viper.New()
|
|
v.SetConfigFile(cfgFile)
|
|
setEdgeDefaults(v)
|
|
if err := v.ReadInConfig(); err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg EdgeConfig
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if !v.InConfig("console.target") {
|
|
if v.InConfig("console.agent") {
|
|
cfg.Console.Target = cfg.Console.Agent
|
|
} else if v.InConfig("console.model") {
|
|
cfg.Console.Target = cfg.Console.Model
|
|
}
|
|
}
|
|
if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.LongContextThresholdTokens <= 0 {
|
|
return nil, fmt.Errorf("long_context_threshold_tokens must be positive")
|
|
}
|
|
|
|
// Collect all provider IDs from nodes[].providers[] for cross-referencing
|
|
// and validate uniqueness within each node.
|
|
providerIDs := make(map[string]struct{})
|
|
providerByID := make(map[string]NodeProviderConf)
|
|
for i := range cfg.Nodes {
|
|
kind, err := NormalizeAgentKind(cfg.Nodes[i].AgentKind)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("nodes[%d] alias=%q: %w", i, cfg.Nodes[i].Alias, err)
|
|
}
|
|
cfg.Nodes[i].AgentKind = kind
|
|
|
|
if err := normalizeAdapters(&cfg.Nodes[i].Adapters); err != nil {
|
|
name := cfg.Nodes[i].ID
|
|
if name == "" {
|
|
name = cfg.Nodes[i].Alias
|
|
}
|
|
return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err)
|
|
}
|
|
|
|
// Validate unique provider IDs within this node and across all nodes.
|
|
seenProviderIDs := make(map[string]struct{}, len(cfg.Nodes[i].Providers))
|
|
for j, p := range cfg.Nodes[i].Providers {
|
|
if err := p.Validate(); err != nil {
|
|
return nil, fmt.Errorf("nodes[%d].providers[%d]: %w", i, j, err)
|
|
}
|
|
if _, dup := seenProviderIDs[p.ID]; dup {
|
|
return nil, fmt.Errorf("nodes[%d].providers: duplicate provider id %q within node", i, p.ID)
|
|
}
|
|
seenProviderIDs[p.ID] = struct{}{}
|
|
if _, globalDup := providerIDs[p.ID]; globalDup {
|
|
return nil, fmt.Errorf("nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)", i, j, p.ID)
|
|
}
|
|
providerIDs[p.ID] = struct{}{}
|
|
providerByID[p.ID] = p
|
|
}
|
|
}
|
|
|
|
for i := range cfg.Nodes {
|
|
if err := CheckProviderLegacyConflict(i, cfg.Nodes[i].Alias, &cfg.Nodes[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Build the provider->servedModels index so Validate can check membership.
|
|
serveModels := buildProviderServedModelsIndex(cfg.Nodes)
|
|
|
|
// Validate models[].providers reference valid provider IDs.
|
|
seenModelIDs := make(map[string]struct{}, len(cfg.Models))
|
|
for i, m := range cfg.Models {
|
|
id := strings.TrimSpace(m.ID)
|
|
if id == "" {
|
|
return nil, fmt.Errorf("models[%d]: id must not be empty", i)
|
|
}
|
|
if _, dup := seenModelIDs[id]; dup {
|
|
return nil, fmt.Errorf("models: duplicate model id %q", id)
|
|
}
|
|
seenModelIDs[id] = struct{}{}
|
|
if err := m.Validate(providerIDs, serveModels); err != nil {
|
|
return nil, fmt.Errorf("models[%d]: %w", i, err)
|
|
}
|
|
if err := validateProviderLongContextBudget(m, providerByID); err != nil {
|
|
return nil, fmt.Errorf("models[%d]: %w", i, err)
|
|
}
|
|
}
|
|
|
|
return &cfg, 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
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func setDefaults(v *viper.Viper) {
|
|
v.SetDefault("transport.edge_addr", "localhost:9090")
|
|
v.SetDefault("reconnect.interval_sec", 10)
|
|
v.SetDefault("reconnect.max_attempts", 10)
|
|
v.SetDefault("logging.level", "info")
|
|
v.SetDefault("metrics.port", 9091)
|
|
}
|
|
|
|
func setEdgeDefaults(v *viper.Viper) {
|
|
v.SetDefault("server.listen", "0.0.0.0:9090")
|
|
v.SetDefault("bootstrap.listen", "0.0.0.0:18080")
|
|
v.SetDefault("bootstrap.artifact_dir", "artifacts")
|
|
v.SetDefault("openai.enabled", false)
|
|
v.SetDefault("openai.listen", "0.0.0.0:18081")
|
|
v.SetDefault("openai.bearer_token", "")
|
|
v.SetDefault("openai.adapter", "ollama")
|
|
v.SetDefault("openai.session_id", "openai")
|
|
v.SetDefault("openai.timeout_sec", 120)
|
|
v.SetDefault("openai.strict_output", true)
|
|
v.SetDefault("openai.strict_stream_buffer", false)
|
|
v.SetDefault("a2a.enabled", false)
|
|
v.SetDefault("a2a.listen", "0.0.0.0:8081")
|
|
v.SetDefault("a2a.path", "/a2a")
|
|
v.SetDefault("a2a.adapter", "cli")
|
|
v.SetDefault("a2a.session_id", "a2a")
|
|
v.SetDefault("a2a.timeout_sec", 120)
|
|
v.SetDefault("logging.level", "info")
|
|
v.SetDefault("metrics.port", 19092)
|
|
v.SetDefault("tls.enabled", false)
|
|
v.SetDefault("console.adapter", "cli")
|
|
v.SetDefault("console.target", "claude")
|
|
v.SetDefault("console.session_id", "default")
|
|
v.SetDefault("console.background", false)
|
|
v.SetDefault("console.timeout_sec", 120)
|
|
v.SetDefault("control_plane.enabled", false)
|
|
v.SetDefault("control_plane.wire_addr", "")
|
|
v.SetDefault("control_plane.reconnect_interval_sec", 5)
|
|
v.SetDefault("refresh.enabled", false)
|
|
v.SetDefault("refresh.listen", "127.0.0.1:19093")
|
|
v.SetDefault("long_context_threshold_tokens", 100000)
|
|
}
|