iop/apps/edge/internal/node/mapper.go
toki 745cca3ef0 feat: config validation and roadmap updates
- Add validate_test.go for edge validation
- Update config.go and config_test.go with new config handling
- Update roadmap and SDD documents for operational observability provider management
- Update edge node mapper and config tests
- Add inflight accounting recovery task and archive
2026-06-30 08:01:50 +09:00

250 lines
7.7 KiB
Go

package node
import (
"fmt"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
// BuildConfigPayload converts a NodeRecord's adapter config to the proto payload
// sent to node during registration.
//
// Provider-first entries in rec.Providers are compiled first: each provider id
// becomes the adapter instance key. Legacy adapter instances in rec.Adapters are
// appended afterwards. An instance key that appears in both produces an error.
// Providers with a non-empty Adapter field reference a legacy instance and are
// skipped during provider-first compile.
//
// After config normalisation, OllamaInstances, VllmInstances, and
// OpenAICompatInstances already contain the promoted legacy single-instance
// entries, so only the slice fields are iterated here. Duplicate instance names
// produce an error.
func BuildConfigPayload(rec *NodeRecord) (*iop.NodeConfigPayload, error) {
payload := &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{
Concurrency: int32(rec.Runtime.Concurrency),
},
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: "mock",
Enabled: true,
Config: &iop.AdapterConfig_Mock{Mock: &iop.MockAdapterConfig{}},
})
// flat namespace to detect conflicts between provider ids and legacy adapter keys
seenFlat := make(map[string]struct{})
for _, p := range rec.Providers {
if p.Adapter != "" {
// references a legacy adapter instance; skip provider-first compile
continue
}
if _, dup := seenFlat[p.ID]; dup {
return nil, fmt.Errorf("duplicate provider id %q", p.ID)
}
seenFlat[p.ID] = struct{}{}
ac, err := providerToAdapterConfig(p)
if err != nil {
return nil, err
}
payload.Adapters = append(payload.Adapters, ac)
}
seen := make(map[string]struct{})
for _, inst := range rec.Adapters.OllamaInstances {
if !inst.Enabled {
continue
}
key := "ollama:" + inst.Name
if _, dup := seen[key]; dup {
return nil, fmt.Errorf("duplicate ollama instance name %q", inst.Name)
}
seen[key] = struct{}{}
if _, conflict := seenFlat[inst.Name]; conflict {
return nil, fmt.Errorf("ollama adapter instance key %q conflicts with provider id", inst.Name)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: "ollama",
Enabled: true,
Name: inst.Name,
Config: &iop.AdapterConfig_Ollama{
Ollama: &iop.OllamaAdapterConfig{
BaseUrl: inst.BaseURL,
ContextSize: int32(inst.ContextSize),
Capacity: int32(inst.Capacity),
MaxQueue: int32(inst.MaxQueue),
QueueTimeoutMs: int32(inst.QueueTimeoutMS),
RequestTimeoutMs: int32(inst.RequestTimeoutMS),
},
},
})
}
for _, inst := range rec.Adapters.VllmInstances {
if !inst.Enabled {
continue
}
key := "vllm:" + inst.Name
if _, dup := seen[key]; dup {
return nil, fmt.Errorf("duplicate vllm instance name %q", inst.Name)
}
seen[key] = struct{}{}
if _, conflict := seenFlat[inst.Name]; conflict {
return nil, fmt.Errorf("vllm adapter instance key %q conflicts with provider id", inst.Name)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: "vllm",
Enabled: true,
Name: inst.Name,
Config: &iop.AdapterConfig_Vllm{
Vllm: &iop.VllmAdapterConfig{
Endpoint: inst.Endpoint,
Capacity: int32(inst.Capacity),
MaxQueue: int32(inst.MaxQueue),
QueueTimeoutMs: int32(inst.QueueTimeoutMS),
RequestTimeoutMs: int32(inst.RequestTimeoutMS),
},
},
})
}
for _, inst := range rec.Adapters.OpenAICompatInstances {
if !inst.Enabled {
continue
}
key := "openai_compat:" + inst.Name
if _, dup := seen[key]; dup {
return nil, fmt.Errorf("duplicate openai_compat instance name %q", inst.Name)
}
seen[key] = struct{}{}
if _, conflict := seenFlat[inst.Name]; conflict {
return nil, fmt.Errorf("openai_compat adapter instance key %q conflicts with provider id", inst.Name)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: "openai_compat",
Enabled: true,
Name: inst.Name,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: inst.Provider,
Endpoint: inst.Endpoint,
Headers: inst.Headers,
Capacity: int32(inst.Capacity),
MaxQueue: int32(inst.MaxQueue),
QueueTimeoutMs: int32(inst.QueueTimeoutMS),
RequestTimeoutMs: int32(inst.RequestTimeoutMS),
},
},
})
}
if rec.Adapters.CLI.Enabled {
if _, conflict := seenFlat["cli"]; conflict {
return nil, fmt.Errorf("cli adapter instance key %q conflicts with provider id", "cli")
}
profiles := make(map[string]*iop.CLIProfileConfig, len(rec.Adapters.CLI.Profiles))
for name, p := range rec.Adapters.CLI.Profiles {
profiles[name] = cliProfileToProto(p)
}
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
Type: "cli",
Enabled: true,
Config: &iop.AdapterConfig_Cli{
Cli: &iop.CLIAdapterConfig{Profiles: profiles},
},
})
}
return payload, nil
}
// providerToAdapterConfig compiles a provider-first NodeProviderConf into an
// AdapterConfig using the provider id as the instance key.
func providerToAdapterConfig(p config.NodeProviderConf) (*iop.AdapterConfig, error) {
typeName := config.NormalizeProviderType(p.Type)
switch typeName {
case "ollama":
return &iop.AdapterConfig{
Type: "ollama",
Enabled: true,
Name: p.ID,
Config: &iop.AdapterConfig_Ollama{
Ollama: &iop.OllamaAdapterConfig{
BaseUrl: p.BaseURL,
ContextSize: int32(p.ContextSize),
Capacity: int32(p.Capacity),
MaxQueue: int32(p.MaxQueue),
QueueTimeoutMs: int32(p.QueueTimeoutMS),
RequestTimeoutMs: int32(p.RequestTimeoutMS),
},
},
}, nil
case "openai_compat":
return &iop.AdapterConfig{
Type: "openai_compat",
Enabled: true,
Name: p.ID,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
Provider: p.Provider,
Endpoint: p.Endpoint,
Headers: p.Headers,
Capacity: int32(p.Capacity),
MaxQueue: int32(p.MaxQueue),
QueueTimeoutMs: int32(p.QueueTimeoutMS),
RequestTimeoutMs: int32(p.RequestTimeoutMS),
},
},
}, nil
case "cli":
// provider id becomes both the adapter instance key and the profile key
return &iop.AdapterConfig{
Type: "cli",
Enabled: true,
Name: p.ID,
Config: &iop.AdapterConfig_Cli{
Cli: &iop.CLIAdapterConfig{
Profiles: map[string]*iop.CLIProfileConfig{
p.ID: {
Command: p.Command,
Args: append([]string(nil), p.Args...),
Env: append([]string(nil), p.Env...),
Mode: p.Mode,
OutputFormat: p.OutputFormat,
ResumeArgs: append([]string(nil), p.ResumeArgs...),
},
},
},
},
}, nil
default:
return nil, fmt.Errorf("provider %q: unsupported type %q for provider-first compile", p.ID, p.Type)
}
}
func cliProfileToProto(p config.CLIProfileConf) *iop.CLIProfileConfig {
out := &iop.CLIProfileConfig{
Command: p.Command,
Args: append([]string(nil), p.Args...),
Env: append([]string(nil), p.Env...),
Persistent: p.Persistent,
Terminal: p.Terminal,
ResponseIdleTimeoutMs: int32(p.ResponseIdleTimeoutMS),
StartupIdleTimeoutMs: int32(p.StartupIdleTimeoutMS),
OutputFormat: p.OutputFormat,
Mode: p.Mode,
ResumeArgs: append([]string(nil), p.ResumeArgs...),
}
if !p.CompletionMarker.Empty() {
out.CompletionMarker = &iop.CLICompletionMarker{
Line: p.CompletionMarker.Line,
Regex: p.CompletionMarker.Regex,
}
}
return out
}