Use explicit IDs in configuration source builders

This commit is contained in:
2026-11-02 04:01:01 +00:00
parent a6f4d67e15
commit fd6e9742a1
6 changed files with 136 additions and 42 deletions

View File

@@ -14,13 +14,15 @@ const BackendRakestrawHome = backend.RakestrawHomeID
// BackendLocal is a conventional ID for a caller-configured local backend.
const BackendLocal = "local"
// BackendConfig is a presence-aware convenience value for a keyed backend
// BackendConfig is a presence-aware convenience value for an identified backend
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
// inputs for explicit null. BackendConfig has no stable JSON representation.
type BackendConfig struct {
// ID is the local backend identity within its resource list.
ID string
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
Endpoint *string
// APIKeyEnv optionally names an environment variable, not a credential.
@@ -71,15 +73,16 @@ type AuthenticationProbeConfig struct {
Endpoint *string
}
// BackendSource creates a labeled ConfigKit mapping from keyed backend values.
// BackendSource creates a labeled ConfigKit mapping containing backend records
// in caller order. IDs are explicit and duplicates are left for loading to reject.
// It encodes fields without validating them; NewEngine validates the final
// effective configuration after all selected source layers are combined.
// Mapping captures a copy of supported input values at creation; unsupported
// values fail when the source is loaded.
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source {
definitions := make(map[string]any, len(backends))
for id, config := range backends {
fields := make(map[string]any)
func BackendSource(label string, backends []BackendConfig) configkit.Source {
definitions := make([]any, 0, len(backends))
for _, config := range backends {
fields := map[string]any{"id": config.ID}
if config.Endpoint != nil {
fields["endpoint"] = *config.Endpoint
}
@@ -119,7 +122,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
}
fields["smoke_tests"] = smokeTests
}
definitions[id] = fields
definitions = append(definitions, fields)
}
return configkit.Mapping(label, map[string]any{"backends": definitions})
}

View File

@@ -40,16 +40,16 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
lowerName := name
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{
"profile": {APIKeyEnv: &lowerName},
Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{
{ID: "profile", APIKeyEnv: &lowerName},
}),
}}})
env := " " + name + " "
if test.clear {
env = ""
}
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{
"profile": {APIKeyEnv: &env},
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{
{ID: "profile", APIKeyEnv: &env},
})}
var calls int
var auth string
@@ -81,13 +81,15 @@ func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T)
}},
})}
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
localBackend.ID = backendID
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
{Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{
localBackend,
})},
{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
"offline": {Backend: &backendID, Model: &model},
{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
{ID: "offline", Backend: &backendID, Model: &model},
})},
}}},
Main: &prompt,
@@ -113,13 +115,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
model := "configured-model"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{
openRouterID: {Endpoint: &openRouterEndpoint},
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
{ID: openRouterID, Endpoint: &openRouterEndpoint},
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
})},
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
"openrouter-profile": {Backend: &openRouterID, Model: &model},
"rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model},
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
})},
}}},
}})
@@ -168,8 +170,8 @@ prompts:
t.Fatalf("select operator inputs: %v", err)
}
programmaticModel := "programmatic-model"
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{
"application": {Model: &programmaticModel},
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{
{ID: "application", Model: &programmaticModel},
})}
for _, test := range []struct {
name string
@@ -225,11 +227,11 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
backendID: {Endpoint: &endpoint},
{Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
{ID: backendID, Endpoint: &endpoint},
})},
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
"application-profile": {Backend: &backendID, Model: &model},
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
{ID: "application-profile", Backend: &backendID, Model: &model},
})},
}}},
}})
@@ -251,8 +253,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
reachabilityEndpoint := " https://probe.example/ready?check=1 "
authenticationKind := " OPENROUTER_KEY "
authenticationEndpoint := "https://probe.example/key?scope=current"
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
"local": {
source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
{
ID: "local",
Endpoint: &backendEndpoint,
SmokeTests: &promptkit.BackendSmokeTestConfig{
DefaultProfile: &defaultProfile,

View File

@@ -384,6 +384,14 @@ tests. Other callers are migrated in later stages.
Validation: the public construction/source-builder tests demonstrate the new
input shape and ownership. Do not add map compatibility or builder-only ID rules.
Completion: profile and backend builders now accept ordered explicit-ID slices,
preserve duplicates and owned values, and encode profile inheritance by sibling
identity. Migrated their owning construction callers and added focused tests
for order, copying, duplicate rejection, and real profile inheritance. Validation
passed: the isolated source-builder contract tests and `go build .` on Go 1.27.0,
formatting, and `git diff --check`. The full root test package remains deferred
until later callers are converted to the new signatures.
## Stage 9: Remove Internal Prompt-Version Propagation
Scope: domain values, prompt requirement snapshots, hashes, use cases, and

View File

@@ -13,19 +13,20 @@ import (
)
func aliasPrompt() map[string]any {
return map[string]any{"prompts": map[string]any{"prepared": promptDefinitionWithVersion("1", map[string]any{
return map[string]any{"prompts": []any{map[string]any{
"id": "prepared",
"default_profile": "child",
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
})}}
}}}
}
func TestProfileInheritanceConfiguredBaseAliasWorkflow(t *testing.T) {
baseID, baseEndpoint, baseModel, reasoning, timeout := "base", "https://profile.example/v1", "synthetic-model", "high", 120
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("application alias", map[string]promptkit.ProfileConfig{
"base": {Endpoint: &baseEndpoint, Model: &baseModel},
"child": {Extends: &baseID, ReasoningEffort: &reasoning, TimeoutSeconds: &timeout},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("application alias", []promptkit.ProfileConfig{
{ID: "base", Endpoint: &baseEndpoint, Model: &baseModel},
{ID: "child", Extends: &baseID, ReasoningEffort: &reasoning, TimeoutSeconds: &timeout},
})}}}},
Main: &configkit.Input{Source: configkit.Mapping("prompt", aliasPrompt())},
}})

View File

@@ -2,12 +2,15 @@ package promptkit
import "go.maximumdirect.net/configkit"
// ProfileConfig is a presence-aware convenience value for a keyed execution
// ProfileConfig is a presence-aware convenience value for an identified execution
// profile. Nil fields are omitted; non-nil pointers preserve zero and blank
// values. A nil ExtraParams map is omitted; a non-nil empty map supplies an
// empty mapping, not a clear operation. Raw ConfigKit Mapping inputs can
// express explicit null. ProfileConfig has no stable JSON representation.
// Extends, when supplied, names a sibling profile in the same resource list.
type ProfileConfig struct {
// ID is the local profile identity within its resource list.
ID string
Extends, Backend, Endpoint, Model *string
Temperature, TopP *float64
MaxTokens, TimeoutSeconds *int
@@ -17,17 +20,18 @@ type ProfileConfig struct {
ExtraParams map[string]any
}
// ProfileSource creates a labeled ConfigKit mapping from keyed profile values.
// ProfileSource creates a labeled ConfigKit mapping containing profile records
// in caller order. IDs are explicit and duplicates are left for loading to reject.
// It encodes fields without validating them; NewEngine validates the final
// effective configuration after all selected source layers are combined.
// Mapping captures a copy of supported input values at creation; unsupported
// values fail when the source is loaded.
func ProfileSource(label string, profiles map[string]ProfileConfig) configkit.Source {
definitions := make(map[string]any, len(profiles))
for id, config := range profiles {
fields := make(map[string]any)
func ProfileSource(label string, profiles []ProfileConfig) configkit.Source {
definitions := make([]any, 0, len(profiles))
for _, config := range profiles {
fields := map[string]any{"id": config.ID}
if config.Extends != nil {
fields["extends"] = *config.Extends
fields["extends"] = map[string]any{"id": *config.Extends}
}
if config.Backend != nil {
fields["backend"] = *config.Backend
@@ -65,7 +69,7 @@ func ProfileSource(label string, profiles map[string]ProfileConfig) configkit.So
if config.ExtraParams != nil {
fields["extra_params"] = providerMapForMapping(config.ExtraParams)
}
definitions[id] = fields
definitions = append(definitions, fields)
}
return configkit.Mapping(label, map[string]any{"profiles": definitions})
}

75
sources_contract_test.go Normal file
View File

@@ -0,0 +1,75 @@
package promptkit_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
"go.maximumdirect.net/configkit"
)
func TestProgrammaticSourcesPreserveOrderAndRejectDuplicates(t *testing.T) {
endpoint := "https://example.invalid/v1"
params := map[string]any{"visible": "original"}
backendSource := promptkit.BackendSource("backends", []promptkit.BackendConfig{
{ID: "zeta", Endpoint: &endpoint, ExtraParams: params}, {ID: "alpha", Endpoint: &endpoint},
})
profileSource := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{
{ID: "zeta", ExtraParams: params}, {ID: "alpha"},
})
params["visible"] = "changed"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: profileSource}}}},
}})
if err != nil {
t.Fatal(err)
}
view, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
var report bytes.Buffer
if err := view.WriteEffective(&report); err != nil {
t.Fatal(err)
}
contents := report.String()
if strings.Count(contents, "original") != 2 || strings.Contains(contents, "changed") {
t.Fatalf("source ownership was lost: %s", contents)
}
for _, kind := range []string{"backends", "profiles"} {
for index, id := range []string{"zeta", "alpha"} {
entry, found := view.Explain(configkit.Keys(kind).Index(index).Key("id"))
if !found || !strings.Contains(entry.Effective.YAML, id) {
t.Fatalf("%s entry %d = %+v, want %s", kind, index, entry, id)
}
}
}
for _, source := range []configkit.Source{
promptkit.BackendSource("duplicate backends", []promptkit.BackendConfig{{ID: "duplicate", Endpoint: &endpoint}, {ID: "duplicate", Endpoint: &endpoint}}),
promptkit.ProfileSource("duplicate profiles", []promptkit.ProfileConfig{{ID: "duplicate"}, {ID: "duplicate"}}),
} {
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}})
if !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("duplicate source error = %v, want ErrInvalidConfig", err)
}
}
}
func TestProgrammaticProfileVariantInherits(t *testing.T) {
parent, endpoint, model, timeout := "base", "https://example.invalid/v1", "synthetic-model", 15
source := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{
{ID: parent, Endpoint: &endpoint, Model: &model},
{ID: "child", Extends: &parent, TimeoutSeconds: &timeout},
})
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}})
if err != nil {
t.Fatal(err)
}
profile, err := engine.InspectProfile(context.Background(), "child")
if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.Endpoint != endpoint || profile.EffectiveModelParams.TimeoutSeconds != timeout {
t.Fatalf("inherited profile = %+v, %v", profile, err)
}
}