438 lines
16 KiB
Go
438 lines
16 KiB
Go
// Package configuration adapts ConfigKit's effective configuration to
|
|
// Promptkit's definition and execution boundaries.
|
|
package configuration
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
|
)
|
|
|
|
type document struct {
|
|
Backends map[string]backendConfig `yaml:"backends"`
|
|
Profiles map[string]profileConfig `yaml:"profiles"`
|
|
Prompts map[string]promptConfig `yaml:"prompts"`
|
|
}
|
|
|
|
type backendConfig struct {
|
|
Endpoint *string `yaml:"endpoint"`
|
|
APIKeyEnv *string `yaml:"api_key_env"`
|
|
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
|
QueueCapacity *int `yaml:"queue_capacity"`
|
|
ExtraParams map[string]any `yaml:"extra_params"`
|
|
SmokeTests *backendSmokeTestConfig `yaml:"smoke_tests"`
|
|
}
|
|
|
|
type backendSmokeTestConfig struct {
|
|
DefaultProfile *string `yaml:"default_profile"`
|
|
Reachability *reachabilityProbeConfig `yaml:"reachability"`
|
|
Authentication *authenticationProbeConfig `yaml:"authentication"`
|
|
}
|
|
|
|
type reachabilityProbeConfig struct {
|
|
Endpoint *string `yaml:"endpoint"`
|
|
}
|
|
|
|
type authenticationProbeConfig struct {
|
|
Kind *string `yaml:"kind"`
|
|
Endpoint *string `yaml:"endpoint"`
|
|
}
|
|
|
|
type profileConfig struct {
|
|
Backend *string `yaml:"backend"`
|
|
Endpoint *string `yaml:"endpoint"`
|
|
Model *string `yaml:"model"`
|
|
Temperature *float64 `yaml:"temperature"`
|
|
TopP *float64 `yaml:"top_p"`
|
|
MaxTokens *int `yaml:"max_tokens"`
|
|
TimeoutSeconds *int `yaml:"timeout_seconds"`
|
|
ServiceTier *string `yaml:"service_tier"`
|
|
ReasoningEffort *string `yaml:"reasoning_effort"`
|
|
APIKeyEnv *string `yaml:"api_key_env"`
|
|
APIKeyRequired *bool `yaml:"api_key_required"`
|
|
ExtraParams map[string]any `yaml:"extra_params"`
|
|
}
|
|
|
|
type promptConfig struct {
|
|
Version *string `yaml:"version"`
|
|
DefaultProfile *string `yaml:"default_profile"`
|
|
Description *string `yaml:"description"`
|
|
SessionID *string `yaml:"session_id"`
|
|
Inputs []promptInputConfig `yaml:"inputs"`
|
|
Messages []promptMessageConfig `yaml:"messages"`
|
|
Output *promptOutputConfig `yaml:"output"`
|
|
}
|
|
|
|
type promptInputConfig struct {
|
|
Name string `yaml:"name"`
|
|
Required bool `yaml:"required"`
|
|
ContentType *string `yaml:"content_type"`
|
|
Description *string `yaml:"description"`
|
|
}
|
|
|
|
type promptMessageConfig struct {
|
|
Role *string `yaml:"role"`
|
|
Content *string `yaml:"content"`
|
|
ContentFile *string `yaml:"content_file"`
|
|
CacheControl *cacheControlConfig `yaml:"cache_control"`
|
|
}
|
|
|
|
type cacheControlConfig struct {
|
|
Type *string `yaml:"type"`
|
|
TTL *string `yaml:"ttl"`
|
|
}
|
|
|
|
type promptOutputConfig struct {
|
|
Format *string `yaml:"format"`
|
|
ValidationMode *string `yaml:"validation_mode"`
|
|
SchemaPath *string `yaml:"schema_path"`
|
|
RepairAttempts int `yaml:"repair_attempts"`
|
|
}
|
|
|
|
// fieldState distinguishes omission, an explicit value, and an explicit null.
|
|
// Inherited values are explicit because ConfigKit has already expanded them.
|
|
type fieldState uint8
|
|
|
|
const (
|
|
fieldAbsent fieldState = iota
|
|
fieldValue
|
|
fieldNull
|
|
)
|
|
|
|
type loaded struct {
|
|
result *configkit.Result[document]
|
|
backends map[string]map[string]fieldState
|
|
profiles map[string]map[string]fieldState
|
|
promptMetadata map[string][]promptInputPresence
|
|
prompts map[string]*promptEntry
|
|
registry *backend.Registry
|
|
indexedProfiles map[string]domain.ExecutionProfile
|
|
profileIDs []string
|
|
promptIDs []domain.PromptIdentity
|
|
}
|
|
|
|
type resourceBases struct {
|
|
backends configkit.Path
|
|
profiles configkit.Path
|
|
prompts configkit.Path
|
|
}
|
|
|
|
func rootResourceBases() resourceBases {
|
|
return resourceBases{
|
|
backends: configkit.Keys("backends"),
|
|
profiles: configkit.Keys("profiles"),
|
|
prompts: configkit.Keys("prompts"),
|
|
}
|
|
}
|
|
|
|
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
|
|
var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"}
|
|
var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"}
|
|
|
|
// Load captures one immutable effective configuration for internal consumers.
|
|
func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
|
return load(main, underlays)
|
|
}
|
|
|
|
// load performs one ConfigKit lifecycle over the selected source tiers.
|
|
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
|
bases := rootResourceBases()
|
|
state := &loaded{
|
|
backends: make(map[string]map[string]fieldState),
|
|
profiles: make(map[string]map[string]fieldState),
|
|
promptMetadata: make(map[string][]promptInputPresence),
|
|
}
|
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
|
Underlays: underlays,
|
|
Definition: configkit.Definition[document]{
|
|
Secret: secretProviderPath,
|
|
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
|
captureFields(config.Backends, bases.backends, backendFields, resolution, state.backends)
|
|
captureFields(config.Profiles, bases.profiles, profileFields, resolution, state.profiles)
|
|
capturePromptMetadata(config.Prompts, bases.prompts, resolution, state.promptMetadata)
|
|
resolveBackends(config.Backends, bases.backends, resolution)
|
|
resolveProfiles(config.Profiles, bases.profiles, resolution)
|
|
resolvePrompts(config.Prompts, bases.prompts, resolution)
|
|
return nil
|
|
},
|
|
Validate: func(config *document, validation *configkit.Validation) {
|
|
validateEffective(config, bases, validation)
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load configuration: %w", err)
|
|
}
|
|
state.result = result
|
|
if err := state.materializePrompts(result.Config.Prompts, bases.prompts); err != nil {
|
|
return nil, fmt.Errorf("load configuration: %w", err)
|
|
}
|
|
if err := state.buildExecutionProfiles(); err != nil {
|
|
return nil, fmt.Errorf("load configuration: %w", err)
|
|
}
|
|
state.profileIDs = sortedKeys(state.indexedProfiles)
|
|
for _, id := range sortedKeys(state.prompts) {
|
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: state.prompts[id].definition.Version})
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
type promptInputPresence struct {
|
|
contentType fieldState
|
|
description fieldState
|
|
}
|
|
|
|
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
|
for _, id := range sortedKeys(prompts) {
|
|
inputs := prompts[id].Inputs
|
|
states := make([]promptInputPresence, len(inputs))
|
|
base := basePath.Key(id).Key("inputs")
|
|
for index := range inputs {
|
|
path := base.Index(index)
|
|
states[index] = promptInputPresence{
|
|
contentType: captureFieldState(path.Key("content_type"), resolution),
|
|
description: captureFieldState(path.Key("description"), resolution),
|
|
}
|
|
}
|
|
destination[id] = states
|
|
}
|
|
}
|
|
|
|
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
|
info, exists := resolution.InputInfo(path)
|
|
switch {
|
|
case !exists || !info.Explicit:
|
|
return fieldAbsent
|
|
case info.Null:
|
|
return fieldNull
|
|
default:
|
|
return fieldValue
|
|
}
|
|
}
|
|
|
|
func captureFields[T any](entries map[string]T, basePath configkit.Path, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
|
for id := range entries {
|
|
states := make(map[string]fieldState, len(fields))
|
|
for _, field := range fields {
|
|
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
|
switch {
|
|
case !exists || !info.Explicit:
|
|
states[field] = fieldAbsent
|
|
case info.Null:
|
|
states[field] = fieldNull
|
|
default:
|
|
states[field] = fieldValue
|
|
}
|
|
}
|
|
destination[id] = states
|
|
}
|
|
}
|
|
|
|
func sortedKeys[T any](entries map[string]T) []string {
|
|
keys := make([]string, 0, len(entries))
|
|
for key := range entries {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
|
|
if value != nil && *value != nil && **value != normalized {
|
|
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
for _, id := range sortedKeys(backends) {
|
|
backend := backends[id]
|
|
base := basePath.Key(id)
|
|
if backend.Endpoint != nil {
|
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
|
if err != nil {
|
|
resolution.Error(base.Key("endpoint"), err.Error())
|
|
} else {
|
|
normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
}
|
|
}
|
|
if backend.APIKeyEnv != nil {
|
|
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
|
}
|
|
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
|
smokePath := base.Key("smoke_tests")
|
|
if smokeTests.Reachability != nil {
|
|
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
|
}
|
|
if smokeTests.Authentication != nil {
|
|
if smokeTests.Authentication.Kind != nil {
|
|
normalizeString(&smokeTests.Authentication.Kind,
|
|
smokePath.Key("authentication").Key("kind"), resolution,
|
|
strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind)))
|
|
}
|
|
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
|
smokePath.Key("authentication").Key("endpoint"), resolution)
|
|
}
|
|
}
|
|
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
|
|
capacity := 1024
|
|
if resolution.IsExplicit(base.Key("queue_capacity")) {
|
|
configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
} else {
|
|
configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
}
|
|
}
|
|
backends[id] = backend
|
|
}
|
|
}
|
|
|
|
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
|
if value == nil || *value == nil {
|
|
return
|
|
}
|
|
normalized := strings.TrimSpace(**value)
|
|
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
|
if normalized == "" || err != nil || request.URL == nil ||
|
|
(strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") ||
|
|
request.URL.Hostname() == "" {
|
|
resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
|
return
|
|
}
|
|
normalizeString(value, path, resolution, normalized)
|
|
}
|
|
|
|
func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
for _, id := range sortedKeys(profiles) {
|
|
profile := profiles[id]
|
|
base := basePath.Key(id)
|
|
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
|
if err != nil {
|
|
resolution.Error(base.Key("endpoint"), err.Error())
|
|
} else {
|
|
normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
}
|
|
}
|
|
for _, field := range []struct {
|
|
name string
|
|
value **string
|
|
}{
|
|
{"service_tier", &profile.ServiceTier},
|
|
{"reasoning_effort", &profile.ReasoningEffort},
|
|
{"api_key_env", &profile.APIKeyEnv},
|
|
} {
|
|
if *field.value != nil {
|
|
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
}
|
|
}
|
|
profiles[id] = profile
|
|
}
|
|
}
|
|
|
|
func validateEffective(config *document, bases resourceBases, validation *configkit.Validation) {
|
|
for _, id := range sortedKeys(config.Backends) {
|
|
backend := config.Backends[id]
|
|
base := bases.backends.Key(id)
|
|
if strings.TrimSpace(id) == "" {
|
|
validation.Error(base, "backend ID must not be blank")
|
|
}
|
|
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
|
validation.Error(base.Key("endpoint"), "endpoint is required")
|
|
}
|
|
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
|
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
|
}
|
|
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
|
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
|
}
|
|
if backend.QueueCapacity != nil {
|
|
if *backend.QueueCapacity < 0 {
|
|
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
|
}
|
|
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
|
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
|
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
|
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
|
}
|
|
}
|
|
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
|
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
|
smokePath := base.Key("smoke_tests")
|
|
if smokeTests.DefaultProfile != nil && strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
|
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
|
}
|
|
if authentication := smokeTests.Authentication; authentication != nil {
|
|
kindPath := smokePath.Key("authentication").Key("kind")
|
|
if authentication.Kind == nil {
|
|
validation.Error(kindPath, "authentication kind is required")
|
|
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
|
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, id := range sortedKeys(config.Profiles) {
|
|
profile := config.Profiles[id]
|
|
base := bases.profiles.Key(id)
|
|
if strings.TrimSpace(id) == "" {
|
|
validation.Error(base, "profile ID must not be blank")
|
|
}
|
|
if profile.Backend != nil && *profile.Backend != "" {
|
|
if _, exists := config.Backends[*profile.Backend]; !exists {
|
|
validation.Error(base.Key("backend"), "backend is not defined")
|
|
}
|
|
}
|
|
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
|
validation.Error(base.Key("model"), "model must not be blank")
|
|
}
|
|
if profile.Temperature != nil {
|
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
|
validation.Error(base.Key("temperature"), err.Error())
|
|
}
|
|
}
|
|
if profile.TopP != nil {
|
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
|
validation.Error(base.Key("top_p"), err.Error())
|
|
}
|
|
}
|
|
if profile.MaxTokens != nil {
|
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
|
validation.Error(base.Key("max_tokens"), err.Error())
|
|
}
|
|
}
|
|
if profile.TimeoutSeconds != nil {
|
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
|
validation.Error(base.Key("timeout_seconds"), err.Error())
|
|
}
|
|
}
|
|
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
|
}
|
|
}
|
|
|
|
func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) {
|
|
if values == nil {
|
|
return
|
|
}
|
|
if _, err := jsonvalue.CopyMap(values); err != nil {
|
|
validation.Error(path, "extra parameters must be a JSON-compatible map with nonempty keys")
|
|
return
|
|
}
|
|
if backend {
|
|
for key := range values {
|
|
if llm.IsReservedOpenAIChatRequestField(key) {
|
|
validation.Error(path.Key(key), "extra parameter conflicts with a request field")
|
|
}
|
|
}
|
|
}
|
|
}
|