24 Commits

Author SHA1 Message Date
d9f6aad305 Adopt ReleaseKit v0.9.0 and refresh recovery guidance 2026-11-04 02:10:48 +00:00
fda0338134 Finalize v0.18.0 notes and retire completed plans 2026-11-04 00:33:44 +00:00
2b3d165ce2 Document the bundled OpenRouter configuration 2026-11-03 15:39:04 +00:00
43f55edb98 Test bundled OpenRouter smoke diagnostics 2026-11-03 15:36:26 +00:00
4950e1dee2 Test bundled profile request construction 2026-11-03 15:34:10 +00:00
d240364776 Test bundled configuration composition 2026-11-03 15:32:25 +00:00
6f57f2f30a Enable bundled OpenRouter configuration by default 2026-11-03 15:30:47 +00:00
2c50eca033 Add embedded OpenRouter configuration 2026-11-03 15:27:26 +00:00
ac44a4f325 Plan bundled OpenRouter configuration 2026-11-03 15:24:06 +00:00
9e05c3ac8b Clarify bundled profile composition and compatibility 2026-11-03 15:11:45 +00:00
2c83925c88 Plan inherited auto-router cost-tier profiles 2026-11-03 14:48:19 +00:00
e0cd63923d Set bundled OpenRouter smoke-test defaults 2026-11-03 14:40:58 +00:00
75204d6134 Set root identities and expand bundled profile plan 2026-11-03 14:26:50 +00:00
5ce6e01f27 Plan bundled OpenRouter backend and profiles 2026-11-03 13:11:54 +00:00
5cec25a40c Prepare v0.18.0 release documentation 2026-11-03 12:50:19 +00:00
855aae6daa Document recorded configuration normalization 2026-11-03 03:32:59 +00:00
43abbcb414 Test normalized configuration inspection and provenance 2026-11-03 03:31:37 +00:00
9f33fed823 Use resolved configuration for construction 2026-11-03 03:29:41 +00:00
7a98feb399 Record prompt field normalization 2026-11-03 03:28:31 +00:00
626134e5e1 Record backend and profile normalization 2026-11-03 03:27:17 +00:00
e7ed315671 Update ConfigKit to version 1.1.0 2026-11-03 03:24:54 +00:00
d7d9a7dd65 Target normalization release notes at v0.18.0 2026-11-03 03:23:28 +00:00
18f6a474e0 Plan recorded field normalization 2026-11-03 03:17:55 +00:00
fb7ebe6a6c Plan field-level configuration normalization 2026-11-03 03:10:04 +00:00
36 changed files with 1244 additions and 271 deletions

View File

@@ -26,6 +26,8 @@ source-relative prompt content, supplies an inline input, and prepares through
a scoped view. The example also demonstrates collection inventories and
namespace-filtered discovery, without contacting a provider or requiring
credentials.
The example disables the bundled OpenRouter configuration to keep its
inventories limited to its own offline fixtures.
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
ordered file/embedded sources, programmatic mappings, and how to replace an
engine when source files change. Applications adapting the prior keyed
@@ -43,6 +45,11 @@ boundary and constraints that framework work must preserve.
## Release Guidance
The [v0.18.0 release notes](docs/releases/v0.18.0.md) cover the default-on
OpenRouter backend and profiles, recorded configuration normalization, and
ConfigKit v1.1. The notes explain inventory changes and the bundle opt-out;
the Go 1.27 minimum remains unchanged.
The [v0.17.0 release notes](docs/releases/v0.17.0.md) cover the vanity module
path, ConfigKit v1, identified resource lists, removal of prompt-version
metadata, and the Go 1.27 minimum. This is a breaking pre-v1 migration.

View File

@@ -0,0 +1,115 @@
package promptkit_test
import (
"context"
"fmt"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
underlay := configkit.Input{Source: configkit.Mapping("caller underlay", map[string]any{
"profiles": []any{map[string]any{"id": "gpt-sol-latest", "model": "underlay-model"}},
})}
underlayEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}}})
if err != nil {
t.Fatal(err)
}
underlayProfile, err := underlayEngine.InspectProfile(context.Background(), "gpt-sol-latest")
if err != nil || underlayProfile.EffectiveModelParams.Model != "underlay-model" || underlayProfile.EffectiveModelParams.BackendID != "" {
t.Fatalf("underlay replacement = %+v, %v", underlayProfile, err)
}
main := configkit.Input{Source: configkit.Mapping("caller main", map[string]any{
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}, map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
"profiles": []any{
map[string]any{"id": "gpt-sol-latest", "model": "main-model"},
map[string]any{"id": "custom", "extends": map[string]any{"id": "gpt-luna-latest"}, "model": "custom-model"},
map[string]any{"id": "auto", "backend": "other", "model": "other-model"},
},
"collections": map[string]any{"openrouter": map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": "https://collection.invalid/v1"}},
"profiles": []any{map[string]any{"id": "profile", "backend": "openrouter/local", "model": "collection-model"}},
}},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Main: &main}})
if err != nil {
t.Fatal(err)
}
inspect := func(id string) promptkit.ExecutionTarget {
t.Helper()
profile, err := engine.InspectProfile(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return profile.EffectiveModelParams
}
if target := inspect("gpt-sol-latest"); target.Model != "main-model" || target.BackendID != "" {
t.Fatalf("replacement = %+v", target)
}
if target := inspect("custom"); target.Model != "custom-model" || target.BackendID != "openrouter" || target.Endpoint != "https://local.invalid/v1" {
t.Fatalf("inherited = %+v", target)
}
if target := inspect("gpt-luna-latest"); target.Endpoint != "https://local.invalid/v1" || target.APIKeyEnv != "" {
t.Fatalf("backend replacement = %+v", target)
}
if target := inspect("openrouter/profile"); target.BackendID != "openrouter/local" || target.Model != "collection-model" {
t.Fatalf("collection = %+v", target)
}
for _, tier := range []string{"low", "medium", "high", "xhigh", "max"} {
target := inspect("auto-" + tier)
if target.BackendID != "other" || target.Model != "other-model" || !strings.Contains(fmt.Sprint(target.ExtraParams), "cost_tier:"+tier) || !strings.Contains(fmt.Sprint(target.ExtraParams), "id:auto-router") {
t.Errorf("tier %s = %+v", tier, target)
}
}
}
func TestBundledCompositionSequenceModes(t *testing.T) {
for _, test := range []struct {
name, mode, id string
wantErr bool
count int
}{
{"duplicate append", "append", "auto", true, 0},
{"distinct append", "append", "custom", false, 18},
{"replace", "replace", "custom", false, 1},
} {
t.Run(test.name, func(t *testing.T) {
input := configkit.Input{Source: configkit.Mapping("sequence", map[string]any{
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": test.mode}}},
"profiles": []any{map[string]any{"id": test.id, "model": "custom-model"}},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
if test.wantErr {
if err == nil {
t.Fatal("expected duplicate rejection")
}
return
}
if err != nil {
t.Fatal(err)
}
profiles, err := engine.ListProfiles(context.Background())
if err != nil || len(profiles) != test.count {
t.Fatalf("profiles = %v, %v", profiles, err)
}
})
}
}
func TestBundledCompositionRejectsStrictConflictsAndMissingBackends(t *testing.T) {
profile := configkit.Input{Source: configkit.Mapping("profile", map[string]any{"profiles": []any{map[string]any{"id": "auto", "model": "caller"}}})}
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile, profile}}}}})
if err == nil {
t.Fatal("strict group accepted duplicate records")
}
input := configkit.Input{Source: configkit.Mapping("replace backends", map[string]any{
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"backends"}, "mode": "replace"}}},
"backends": []any{map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
})}
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}); err == nil {
t.Fatal("missing bundled backend was accepted")
}
}

View File

@@ -0,0 +1,90 @@
package promptkit_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func bundledPromptConfig(client *http.Client) promptkit.Config {
input := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{"prompts": []any{map[string]any{
"id": "prompt", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "none"},
}}})}
return promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}
}
func TestBundledProfilePreparationAndExecution(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
engine, err := promptkit.NewEngine(bundledPromptConfig(nil), promptkit.WithLLMClient(client))
if err != nil {
t.Fatal(err)
}
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrProfileRequired) {
t.Fatalf("missing profile = %v", err)
}
temperature := 0.3
request := promptkit.RunRequest{PromptID: "prompt", ProfileID: "gpt-luna-latest", Execution: &promptkit.ExecutionTargetOverride{Temperature: &temperature}}
prepared, err := engine.PrepareExecution(context.Background(), request)
if err != nil {
t.Fatal(err)
}
details := prepared.Details()
if details.SelectedProfileID != "gpt-luna-latest" || details.EffectiveModelParams.Model != "~openai/gpt-luna-latest" || details.EffectiveModelParams.BackendID != "openrouter" || details.EffectiveModelParams.Temperature != temperature {
t.Fatalf("prepared target = %+v", details.EffectiveModelParams)
}
if _, err := engine.Run(context.Background(), request); err != nil {
t.Fatal(err)
}
if len(client.requests) != 1 || client.requests[0].Target.Model != "~openai/gpt-luna-latest" || client.requests[0].Target.BackendID != "openrouter" {
t.Fatalf("requests = %+v", client.requests)
}
}
func TestBundledProfileHTTPPayloads(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
for _, test := range []struct{ id, model, tier string }{
{"auto-low", "openrouter/auto", "low"}, {"auto-medium", "openrouter/auto", "medium"}, {"auto-high", "openrouter/auto", "high"}, {"auto-xhigh", "openrouter/auto", "xhigh"}, {"auto-max", "openrouter/auto", "max"},
{"auto", "openrouter/auto", ""}, {"free", "openrouter/free", ""}, {"gpt-luna-latest", "~openai/gpt-luna-latest", ""},
} {
t.Run(test.id, func(t *testing.T) {
var body map[string]any
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Header.Get("Authorization") != "Bearer synthetic-key" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
})
engine, err := promptkit.NewEngine(bundledPromptConfig(&http.Client{Transport: transport}))
if err != nil {
t.Fatal(err)
}
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ProfileID: test.id}); err != nil {
t.Fatal(err)
}
if body["model"] != test.model || body["extra_params"] != nil {
t.Fatalf("request = %+v", body)
}
if test.tier == "" {
if _, exists := body["plugins"]; exists {
t.Fatalf("unexpected plugins = %+v", body)
}
return
}
want := []any{map[string]any{"id": "auto-router", "cost_tier": test.tier}}
if !reflect.DeepEqual(body["plugins"], want) {
t.Fatalf("plugins = %#v, want %#v", body["plugins"], want)
}
})
}
}

View File

@@ -0,0 +1,124 @@
package promptkit_test
import (
"context"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
type rejectBundleTransport struct{}
func (rejectBundleTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("unexpected HTTP request")
}
func TestBundledOpenRouterOptionsAndInventory(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
for _, test := range []struct {
name string
options []promptkit.Option
enabled bool
}{
{"default", nil, true}, {"enabled", []promptkit.Option{promptkit.WithBundledOpenRouter(true)}, true},
{"disabled", []promptkit.Option{promptkit.WithBundledOpenRouter(false)}, false},
{"last disables", []promptkit.Option{promptkit.WithBundledOpenRouter(true), promptkit.WithBundledOpenRouter(false)}, false},
{"last enables", []promptkit.Option{promptkit.WithBundledOpenRouter(false), promptkit.WithBundledOpenRouter(true)}, true},
} {
t.Run(test.name, func(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: rejectBundleTransport{}}}, test.options...)
if err != nil {
t.Fatal(err)
}
profiles, err := engine.ListProfiles(context.Background())
if err != nil {
t.Fatal(err)
}
backends, err := engine.ListBackends(context.Background())
if err != nil {
t.Fatal(err)
}
prompts, err := engine.ListPrompts(context.Background())
if err != nil {
t.Fatal(err)
}
collections, err := engine.ListCollections(context.Background())
if err != nil {
t.Fatal(err)
}
want := 0
if test.enabled {
want = 17
}
if len(profiles) != want || len(backends) != boolCount(test.enabled) || len(prompts) != 0 || len(collections) != 0 {
t.Fatalf("inventories: %v %v %v %v", profiles, backends, prompts, collections)
}
if test.enabled {
wantProfiles := []string{"auto", "auto-high", "auto-low", "auto-max", "auto-medium", "auto-xhigh", "claude-fable-latest", "claude-haiku-latest", "claude-opus-latest", "claude-sonnet-latest", "free", "gemini-flash-latest", "gemini-pro-latest", "gpt-astra-latest", "gpt-luna-latest", "gpt-sol-latest", "gpt-terra-latest"}
if !reflect.DeepEqual(profiles, wantProfiles) || !reflect.DeepEqual(backends, []string{"openrouter"}) {
t.Fatalf("bundle inventory: %v %v", profiles, backends)
}
for id, model := range map[string]string{"auto": "openrouter/auto", "auto-max": "openrouter/auto", "gpt-luna-latest": "~openai/gpt-luna-latest", "free": "openrouter/free"} {
profile, err := engine.InspectProfile(context.Background(), id)
if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.BackendID != "openrouter" {
t.Fatalf("profile %s = %+v, %v", id, profile, err)
}
}
view, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(fmtSources(view.Sources()), "Promptkit bundled OpenRouter") {
t.Fatal("bundle source missing")
}
if explanation, found := view.Explain(configkit.Keys("backends").Index(0).Key("endpoint")); !found || !strings.Contains(explanation.Effective.YAML, "https://openrouter.ai/api/v1") || len(explanation.History) == 0 {
t.Fatalf("bundle explanation = %+v", explanation)
}
}
})
}
}
func boolCount(value bool) int {
if value {
return 1
}
return 0
}
func fmtSources(sources []configkit.SourceInfo) string {
var b strings.Builder
for _, source := range sources {
b.WriteString(source.Label)
}
return b.String()
}
func TestBundlePreservesCallerUnderlayBackingArray(t *testing.T) {
source := configkit.Mapping("caller", map[string]any{"profiles": []any{map[string]any{"id": "caller", "model": "model"}}})
groups := make([]configkit.Underlay, 1, 2)
groups[0] = configkit.Underlay{Inputs: []configkit.Input{{Source: source}}}
before := append([]configkit.Underlay(nil), groups...)
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: groups}}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(groups, before) || !reflect.DeepEqual(groups[:2][1], configkit.Underlay{}) {
t.Fatal("caller underlays were modified")
}
}
func TestDisabledBundleKeepsCallerOpenRouter(t *testing.T) {
input := configkit.Input{Source: configkit.Mapping("caller", map[string]any{"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}}})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}
backends, err := engine.ListBackends(context.Background())
if err != nil || !reflect.DeepEqual(backends, []string{"openrouter"}) {
t.Fatalf("backends = %v, %v", backends, err)
}
}

View File

@@ -0,0 +1,127 @@
package promptkit_test
import (
"context"
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestBundledSmokeHTTPDefaults(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
var urls []string
var models []string
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
urls = append(urls, request.URL.String())
body := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
if request.Method == http.MethodPost {
var payload map[string]any
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
models = append(models, payload["model"].(string))
content := "PROMPTKIT_OK"
if _, structured := payload["response_format"]; structured {
content = `{"status":"ok"}`
}
encoded, err := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": content}}}})
if err != nil {
t.Fatal(err)
}
body = string(encoded)
} else if strings.HasSuffix(request.URL.Path, "/key") && request.Header.Get("Authorization") != "Bearer synthetic-key" {
t.Errorf("authentication header missing")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
})
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatal(err)
}
before, _ := engine.ListProfiles(context.Background())
inspectionBefore, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
for _, kind := range []promptkit.SmokeTestKind{promptkit.SmokeTestReachability, promptkit.SmokeTestAuthentication, promptkit.SmokeTestTextInference, promptkit.SmokeTestStructuredOutput} {
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: kind, BackendID: "openrouter"})
if err != nil || report == nil || !report.Completed || report.Failure != "" {
t.Fatalf("%s report = %+v, %v", kind, report, err)
}
if kind == promptkit.SmokeTestAuthentication && (report.Facts.CredentialsAccepted == nil || !*report.Facts.CredentialsAccepted) {
t.Fatalf("authentication facts = %+v", report.Facts)
}
if kind == promptkit.SmokeTestTextInference || kind == promptkit.SmokeTestStructuredOutput {
if report.Target.ProfileID != "gpt-luna-latest" || report.Target.EffectiveSettings.Model != "~openai/gpt-luna-latest" {
t.Fatalf("target = %+v", report.Target)
}
}
}
wantURLs := []string{"https://openrouter.ai/api/v1", "https://openrouter.ai/api/v1/key", "https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions"}
if !reflect.DeepEqual(urls, wantURLs) || !reflect.DeepEqual(models, []string{"~openai/gpt-luna-latest", "~openai/gpt-luna-latest"}) {
t.Fatalf("urls %v, models %v", urls, models)
}
after, _ := engine.ListProfiles(context.Background())
if !reflect.DeepEqual(before, after) {
t.Fatalf("inventory changed: %v %v", before, after)
}
inspectionAfter, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
diff, err := inspectionBefore.Diff(inspectionAfter)
if err != nil || len(diff) != 0 {
t.Fatalf("inspection changed: %+v, %v", diff, err)
}
}
func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) {
replacement := configkit.Input{Source: configkit.Mapping("replacement", map[string]any{"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}}})}
replacementClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
replacementEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &replacement}}, promptkit.WithLLMClient(replacementClient))
if err != nil {
t.Fatal(err)
}
replacementReport, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
if err != nil || replacementReport.Target.ProfileID != "gpt-luna-latest" || replacementReport.Target.EffectiveSettings.Model != "replacement" {
t.Fatalf("replaced default = %+v, %v", replacementReport, err)
}
replacementClient.response.Content = "unexpected"
mismatch, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
if err != nil || mismatch == nil || !mismatch.Completed {
t.Fatalf("concluded mismatch = %+v, %v", mismatch, err)
}
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://example.invalid/v1", "smoke_tests": map[string]any{"default_profile": "custom"}}},
"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}, map[string]any{"id": "custom", "backend": "openrouter", "model": "custom-model"}},
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "free", "messages": []any{map[string]any{"role": "user", "content": "private application marker"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
})}
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithLLMClient(client))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
request promptkit.SmokeTestRequest
profile, model string
}{
{promptkit.SmokeTestRequest{BackendID: "openrouter"}, "custom", "custom-model"},
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt"}, "free", "openrouter/free"},
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt", ProfileID: "gpt-luna-latest"}, "gpt-luna-latest", "replacement"},
} {
test.request.Kind = promptkit.SmokeTestTextInference
report, err := engine.SmokeTest(context.Background(), test.request)
if err != nil || report.Target.ProfileID != test.profile || report.Target.EffectiveSettings.Model != test.model {
t.Fatalf("report = %+v, %v", report, err)
}
if strings.Contains(client.requests[len(client.requests)-1].Prompt.Messages[0].Content, "private application marker") {
t.Fatal("application content used by diagnostic")
}
}
}

View File

@@ -81,6 +81,34 @@ func TestConfigurationInspectionDiffIgnoresSourceHistory(t *testing.T) {
}
}
func TestConfigurationInspectionDiffUsesNormalizedValues(t *testing.T) {
inspect := func(endpoint string) promptkit.ConfigurationInspection {
t.Helper()
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("backend", map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": endpoint}},
})}}}}}})
if err != nil {
t.Fatal(err)
}
view, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
return view
}
left := inspect(" HTTPS://example.invalid/v1 ")
equal := inspect("https://example.invalid/v1")
differences, err := left.Diff(equal)
if err != nil || len(differences) != 0 {
t.Fatalf("normalized diff = %+v, %v", differences, err)
}
changed := inspect("https://other.invalid/v1")
differences, err = left.Diff(changed)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/backends" {
t.Fatalf("changed diff = %+v, %v", differences, err)
}
}
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
"sequence": []any{"earlier", "unchanged"},

View File

@@ -21,7 +21,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
"backends": []any{map[string]any{
"id": "local", "endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "api_key_env": "OPTIONAL_KEY",
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
}},
"collections": map[string]any{"literal": map[string]any{
@@ -42,7 +42,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
}},
})}}},
},
}})
}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatalf("construct layered engine: %v", err)
}
@@ -64,6 +64,11 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
t.Fatalf("literal-key path = %q", inheritedPath)
}
secretPath := configkit.Keys("backends").Index(0).Key("extra_params").Key("token")
endpointPath := configkit.Keys("backends").Index(0).Key("endpoint")
endpoint, found := view.Explain(endpointPath)
if !found || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") || strings.Contains(fmt.Sprintf("%+v", view.Metadata().History(endpointPath)), "private-sentinel") {
t.Fatalf("normalized endpoint explanation = %+v", endpoint)
}
secret, found := view.Explain(secretPath)
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)

View File

@@ -291,7 +291,7 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
"https://backend.example/v1",
"not-yet-listed",
"https://probe.example/ready?check=1",
" OPENROUTER_KEY ",
"openrouter_key",
"https://probe.example/key?scope=current",
} {
if !strings.Contains(contents, want) {

View File

@@ -3,7 +3,7 @@
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
and removes prompt-version labels.
Promptkit does not install a provider catalog; applications choose configuration
Promptkit installs a default-on bundled OpenRouter catalog; applications choose configuration
inputs explicitly, and execution selects one prompt by its exact ID. This guide
explains the shape changes and decisions an application must make; it is not an
automatic migration tool. The [framework format reference](../formats.md) owns
@@ -134,9 +134,9 @@ Replace former maps passed to `BackendSource` in the same way, setting each
`BackendConfig.ID`. Duplicates are retained for loading to reject. Resource
inspection paths now address list indexes, for example
`configkit.Keys("profiles").Index(0).Key("model")`. Configuration inspection
and diffs expose composed values and provenance, while `InspectProfile` and
other interpreted views expose Promptkit normalization and defaults; the views
are not promised to be identical.
and diffs expose resolved values, recorded normalization and provenance.
`InspectProfile` additionally applies backend completion; request preparation
can apply further overrides, so the views are not promised to be identical.
Remove `PromptVersion` and `PromptIdentity.Version` uses and the corresponding
`prompt_version` JSON members. Prompt hashes are opaque equality values and may

View File

@@ -18,7 +18,7 @@ import "go.maximumdirect.net/promptkit"
For migration from the former module path, see
[Update Go Imports](configuration-migration.md#update-go-imports).
Configuration APIs use `go.maximumdirect.net/configkit` v1.0.0. Its types are
Configuration APIs use `go.maximumdirect.net/configkit` v1.1.0. Its types are
not interchangeable with the old ConfigKit module path.
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
@@ -52,6 +52,32 @@ Create an engine with [`NewEngine`](../../engine.go). Select each directory
explicitly and place its inputs in an ordered ConfigKit underlay. A group
strictly composes its files; later groups can overlay earlier ones:
The bundled OpenRouter backend and profiles are installed in a lower underlay
by default. Select a profile explicitly, for example:
```go
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
"prompts": []any{map[string]any{
"id": "my-prompt",
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
}},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
if err != nil { return err }
result, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "my-prompt", ProfileID: "gpt-luna-latest"})
```
The backend looks up
`OPENROUTER_API_KEY` when available; credentials are not required at engine
construction. For the complete inventory and diagnostic defaults see the
[format reference](../formats.md#bundled-openrouter-configuration). Use
`promptkit.WithBundledOpenRouter(false)` for application-only inventories.
Caller records replace matching bundled records in full; use `extends` with a
distinct ID to inherit. Replacing `auto` also changes its cost-tier children's
inherited target, while their plugins remain. External collections remain
supported.
```go
promptInputs, err := promptkit.DirectoryInputs("prompts")
if err != nil { return err }
@@ -211,7 +237,7 @@ sensitive application data.
## Inspect The Captured Configuration
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
operator-selected report of composed configuration, source descriptions,
operator-selected report of resolved configuration, source descriptions,
diagnostics, and path history. Configuration inspection uses list indexes in
the collection's nested path rather than qualified runtime IDs. For a profile
at index `i` in collection `team`, construct the path with `configkit.Keys`:
@@ -230,8 +256,9 @@ if found && explanation.Effective.Redacted {
the application decides whether those reports may be disclosed. Credential-
shaped provider keys are redacted, but arbitrary prompt and provider content
is not necessarily safe to share. Inspection uses the engine's captured result
and does not reopen sources or resolve credentials. It retains composed values
and provenance rather than Promptkit's normalized/defaulted interpretation.
and does not reopen sources or resolve credentials. It retains effective values,
including recorded field normalization and queue defaulting, alongside source
provenance.
`InspectProfile` instead shows backend-completed execution settings for one profile, and `Prepare` or
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
and validation resources. None of those views is a substitute for the others.
@@ -263,11 +290,13 @@ for _, difference := range differences {
```
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
and ConfigKit's [v1.0.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md#effective-configuration-diffs)
and ConfigKit's [v1.1.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs)
for the exact comparison contract. The operation compares only frozen effective
configuration values: it does not reread external files or compare their
contents, resolved credential contents, later execution preparation, or
request-specific overrides. An empty diff is not proof of equivalent execution.
request-specific overrides. Inputs normalizing to the same effective value do
not create a value diff; lists are compared atomically. An empty diff is not
proof of equivalent execution.
Records can contain ordinary application data, so use the application's
disclosure policy when presenting them.
@@ -662,9 +691,9 @@ the engine to pick up changed definitions or message files.
### Choose A Profile Ownership Model
Supply backend and profile definitions through the configuration sources your
application selects. PromptKit does not install provider catalogs or built-in
profile inventories; conventional backend ID constants are names only. Define
the model, endpoint, credentials lookup, and settings that your application
application selects, alongside the default-on bundled OpenRouter inventory.
Conventional backend ID constants are names only. Define or replace the model,
endpoint, credentials lookup, and settings that your application
intends to support. A profile may inherit a sibling profile by its local key in
the same root or collection list; its backend reference uses the exact
configured identity. Caller-selected source layers determine the final values. The

View File

@@ -6,7 +6,7 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
how to select sources and use an engine; the
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md)
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md)
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
This reference describes what Promptkit does with the final effective values.
@@ -14,8 +14,8 @@ A configuration document may contain top-level `backends`, `profiles`, and
`prompts` lists, plus a `collections` mapping of namespaced resource groups.
Each resource has an explicit literal `id`, not a filename-derived identity.
IDs and references must be nonblank and remain exact: Promptkit does not trim or
re-key them. Unknown fields are errors. Promptkit installs no provider or
profile catalog; callers select the definitions they need.
re-key them. Unknown fields are errors. Promptkit installs a default-on bundled
OpenRouter underlay; callers still explicitly select a profile.
```yaml
backends:
@@ -86,9 +86,10 @@ list. It is not a qualified or cross-collection reference.
no label; an explicitly empty string is retained. It does not select a resource
or change execution. Runtime lookups
use the captured effective resource indexes. `InspectConfiguration` is an
explicit operator view of composed settings and provenance. Normalization and
defaults appear in Promptkit's interpreted execution views, not as changes to
that composed tree. Source history is not a second runtime resolver, and the
explicit operator view of resolved settings and provenance. Selected field
normalizations and the derived queue default are recorded in that tree, with
source history retained. Request-time completion remains separate. Source
history is not a second runtime resolver, and the
retained effective shape is not flattened or rewritten into those indexes.
For examples of explicitly selecting native collection inputs and inspecting
@@ -331,9 +332,58 @@ For example, a runtime `extra_params["extends"]` key is ordinary provider
data, not a configuration inheritance instruction. An explicit zero timeout
does not disable the caller context or HTTP transport timeout.
### Bundled OpenRouter Configuration
The default bundle installs root backend `openrouter`, endpoint
`https://openrouter.ai/api/v1`, optional credential environment name
`OPENROUTER_API_KEY`, and smoke metadata selecting `gpt-luna-latest` with the
`openrouter_key` authentication adapter. Existing diagnostic endpoint fallback
applies. No prompt, implicit profile selection, or tuning defaults are installed.
Disable the entire bundle with `WithBundledOpenRouter(false)`.
The root profile IDs and their models are:
| Profile | Model |
| --- | --- |
| `gpt-astra-latest` | `~openai/gpt-astra-latest` |
| `gpt-sol-latest` | `~openai/gpt-sol-latest` |
| `gpt-terra-latest` | `~openai/gpt-terra-latest` |
| `gpt-luna-latest` | `~openai/gpt-luna-latest` |
| `claude-fable-latest` | `~anthropic/claude-fable-latest` |
| `claude-opus-latest` | `~anthropic/claude-opus-latest` |
| `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
| `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
| `gemini-pro-latest` | `~google/gemini-pro-latest` |
| `gemini-flash-latest` | `~google/gemini-flash-latest` |
| `auto` | `openrouter/auto` |
| `free` | `openrouter/free` |
The profiles `auto-low`, `auto-medium`, `auto-high`, `auto-xhigh`, and
`auto-max` extend `auto` and add an `auto-router` plugin with the corresponding
`cost_tier`. Replacing `auto` changes their inherited backend and model while
retaining their plugins. These are root IDs even when a model contains `/`.
For example, a caller can replace `auto` as a complete record and extend another
bundled profile under a new ID:
```yaml
profiles:
- id: auto
backend: openrouter
model: openrouter/free
- id: my-luna
extends: {id: gpt-luna-latest}
timeout_seconds: 60
```
See OpenRouter's [alias examples](https://openrouter.ai/~openai/gpt-luna-latest),
[auto router and cost tiers](https://openrouter.ai/docs/guides/routing/routers/auto-router),
and [free router](https://openrouter.ai/docs/guides/routing/routers/free-router)
for provider behavior and availability.
### Source And Profile Precedence
`NewEngine` composes caller underlays in their given order, followed by an
`NewEngine` composes the bundled OpenRouter underlay first by default, then
caller underlays in their given order, followed by an
optional main input. Later layers overlay earlier ones. Inputs within one
underlay group are strictly composed: conflicting contributions are errors,
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
@@ -363,7 +413,7 @@ references are exact IDs and use their qualified form when they select a
collection resource. There is no Promptkit-specific depth limit or second
base-chain resolver. Missing references and cycles fail at construction. For
generic inheritance and layer mechanics, consult
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
## Schemas

View File

@@ -12,13 +12,12 @@ source semantics. Internal packages are not consumer APIs.
`internal/configuration` is the single adapter from ConfigKit's final
effective configuration to execution-owned definitions. It performs one
layered load, captures effective presence (including explicit null), builds
detached normalized interpretations during validation, and validates all effective
layered load, captures effective presence (including explicit null), records
field-level normalization and derived defaults during resolution, and validates all effective
root and collection backend, profile, and prompt values. Replaced lower-layer
domain values are not separately validated. It indexes root resources under
their unqualified IDs and collection resources under their full
`namespace/local-key` IDs while retaining ConfigKit's effective tree in its
authored shape. It builds an immutable backend index and presence-aware profile
`namespace/local-key` IDs while retaining ConfigKit's resolved tree. It builds an immutable backend index and presence-aware profile
targets, applying final backend defaults only after profile inheritance. Runtime
lookups use those final indexes and exact stored references; they do not rewrite
or resolve resources from ConfigKit source history. The runtime/domain packages
@@ -27,7 +26,7 @@ do not depend on ConfigKit.
ConfigKit owns source headers, imports, composition, and source evidence.
Promptkit does not preparse contributions: it validates the final effective
values once, captures the indexed identities and their original resource
contexts, and retains ConfigKit's composed result for explicit operator inspection.
contexts, and retains ConfigKit's resolved result for explicit operator inspection.
Accepted non-main source headers are consumed during composition; the main
source declaration remains available in that inspection view. Collection
release labels are descriptive metadata, not selection inputs. The
@@ -37,12 +36,12 @@ owns the exact source-header and collection contracts.
The `ConfigurationInspection.Diff` facade method compares two retained results
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
once to ConfigKit v1.0.0 and returns its public difference records. Comparison
once to ConfigKit v1.1.0 and returns its public difference records. Comparison
uses only frozen effective values and does not reopen sources, resolve runtime
credentials, or enter the execution path. ConfigKit applies sensitivity from
both results; reports may still contain ordinary application data. See the
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
and ConfigKit's [v1.0.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md#effective-configuration-diffs).
and ConfigKit's [v1.1.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs).
After resource materialization and effective-profile snapshot construction, the
adapter captures lexically ordered identity inventories for backends, profiles,
@@ -53,7 +52,9 @@ captured values without consulting ConfigKit or copying resource definitions.
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
files under an explicit root, provide resource context, and leave imports
disabled. Each returned slice belongs to one strict-composition group; file
order does not confer priority. Caller underlays are composed in their given
order does not confer priority. Engine assembly prepends a dedicated embedded
OpenRouter underlay unless disabled, without modifying caller-owned slices.
Caller underlays are composed in their given
order, followed by the optional main input. The
[configuration tests](../../internal/configuration/load_test.go) exercise the
real loader and final-value handoff.
@@ -85,12 +86,11 @@ returning and retains no source capability for public inspection.
Prompt materialization shares a lazy metadata snapshot across root and collection
resources during construction, avoiding a full metadata copy per collection.
Normalization and defaults are applied to execution-owned interpretations,
including message roles and cache controls. ConfigKit retains the composed
values and origins, rather than synthetic Promptkit normalization events. List
indexes identify fields for presence, validation, sensitivity, and resource
context; full IDs select captured runtime indexes. Recording a sequence-wide
normalization would replace origins of unchanged content-file fields.
Normalization and defaults are recorded at individual resolved field paths,
including message roles and cache controls. List indexes identify fields for
presence, validation, sensitivity, and resource context; full IDs select
captured runtime indexes. Unchanged content-file fields retain their origins.
Backend-to-profile and request-time completion remain separate runtime work.
The runner selects full definitions from this index for preparation and uses
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the

View File

@@ -26,17 +26,31 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
## ConfigKit
Selected module: `go.maximumdirect.net/configkit` at `v1.0.0`.
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL 3.0](../../LICENSE) |
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53/LICENSE).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53).
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
## ReleaseKit
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (maintainer tool).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL 3.0](../../LICENSE) |
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
## jsonschema

View File

@@ -74,8 +74,8 @@ Successful publication needs no separate remote read-back or CI wait.
For optional local diagnosis, use `./scripts/releasekit.sh check VERSION`.
It is not a required step before publication. The upstream
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/check.md)
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md)
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/check.md)
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/cli.md)
own shared checks and exit-status definitions.
## Deliver The Hosted Release
@@ -97,14 +97,26 @@ For a later retry, use a clean checkout of the original tag with its recorded
module files and supply the token there. In a separate checkout, for example:
```sh
git switch --detach vMAJOR.MINOR.PATCH
./scripts/releasekit.sh deliver vMAJOR.MINOR.PATCH
(
set -e
release_version=vMAJOR.MINOR.PATCH
release_tag_ref="refs/tags/$release_version"
git fetch origin "$release_tag_ref:$release_tag_ref"
release_tag_commit=$(git rev-parse --verify "$release_tag_ref^{commit}")
git switch --detach "$release_tag_commit"
release_worktree_status=$(git status --porcelain --untracked-files=all)
if [ -n "$release_worktree_status" ]; then
printf '%s\n' 'Delivery retry requires a clean tagged checkout.' >&2
exit 1
fi
./scripts/releasekit.sh deliver "$release_version"
)
```
Resolve uncertain hosted writes before retrying the same immutable version.
Matching content is accepted; conflicts are not overwritten. Do not rerun
`publish` or move tags to repair delivery. Follow the upstream
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/delivery.md)
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/delivery.md)
for result interpretation and recovery. These commands apply to tags containing
this delivery configuration; older releases retain their original workflows.
@@ -131,13 +143,13 @@ and explicitly select the matching attempt:
Discovery is local; reconciliation observes the original destination without
rerunning validation, changing tags, or delivering hosted content. Neither is
a routine gate after successful publication. Follow the upstream
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/release.md#resolve-publication-failures)
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/release.md#resolve-publication-failures)
for outcomes, incomplete evidence, and older attempts without records.
Records are stored under Git's common directory by default and can contain
credential-bearing endpoints. Keep them private, out of source control and
shared logs; retain them while needed for recovery and remove them when that
value expires. See the [evidence guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
value expires. See the [evidence guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
for storage and retention details.
Current absence does not prove a version was never published. Remove a retained

65
docs/releases/v0.18.0.md Normal file
View File

@@ -0,0 +1,65 @@
# Promptkit v0.18.0
Configuration inspection now exposes Promptkit's recorded field normalization
and derived queue-capacity defaults through ConfigKit v1.1.0. Engines also
include a default-on OpenRouter configuration. The minimum supported Go
version remains 1.27.0.
## Bundled OpenRouter Backend And Profiles
New engines include an embedded OpenRouter backend and seventeen root profiles:
ten latest-family aliases, the auto and free routers, and five auto-router
cost-tier variants. Select a profile explicitly in a prompt or request; the
bundle does not supply an implicit execution profile or contact providers
during construction. The backend uses `OPENROUTER_API_KEY` when available under
the existing optional-credential rules.
The bundle is the lowest-priority underlay. Applications can replace its records
or derive named variants through ordinary configuration, and external collections
remain supported. Cost-tier variants inherit the effective `auto` profile, so
replacing that parent changes their inherited target while retaining plugins.
The [bundle reference](../formats.md#bundled-openrouter-configuration) owns the
inventory and composition details.
Explicit smoke diagnostics now have OpenRouter authentication metadata and an
inference fallback profile. They remain opt-in and may incur provider costs.
Latest aliases and routers can change upstream; configured slugs do not pin
the concrete model, pricing, or availability. See the
[consumer guide](../consumers/pkg-promptkit.md#construct-an-engine) for selection
and opt-out examples.
## Recorded Configuration Normalization
Inspection shows normalized backend and profile settings, prompt metadata, and
configured message roles and cache controls, with field-level resolution
history. Existing source evidence remains available, and unchanged content-file
origins continue to select their resource context.
Configuration diffs compare normalized effective values, so spelling-only
changes that normalize identically may no longer appear. Lists remain atomic in
diffs. Inspection does not include backend-to-profile completion or request
overrides; see the [configuration inspection guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration).
## Compatibility And Upgrade
New engines install a bundled OpenRouter backend and root profiles by default.
Inventory listings and inspection sources therefore change, and references to
bundled IDs that previously failed may now resolve. Pass
`promptkit.WithBundledOpenRouter(false)` to retain application-only behavior.
Caller records can replace bundled definitions through ordinary composition;
same-ID replacements replace complete records rather than merging selected fields.
No configuration-format migration is required from v0.17.0.
Consumers that display or compare configuration inspection results should account
for normalized values and derived defaults replacing their earlier authored
representations. Request-time overrides and caller-appended messages retain
their existing processing.
```sh
go get go.maximumdirect.net/promptkit@v0.18.0
```
See the [framework format reference](../formats.md) for current configuration
semantics. Consumers upgrading from versions before v0.17.0 should also follow
the [configuration and API migration guide](../consumers/configuration-migration.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/docs/oss/attribution.md)

View File

@@ -38,30 +38,8 @@ consumers.
## Ideas
### Field-Level Normalization With Preserved List Provenance
Reevaluate normalizing resource fields directly in ConfigKit's effective
configuration after upstream ConfigKit supports recording changes within a
list without replacing the source attribution of unchanged fields.
This could let configuration inspection show PromptKit-normalized values and
field-level change history while preserving the origins needed to resolve
relative resources such as `content_file`. Assess whether that inspection
benefit justifies changing the execution-owned normalization approach.
Include normalization of roles and cache-control values in configured messages
in this reevaluation. This could make inspection reflect the values used for
execution and reduce separate normalization paths. Upstream support would need
to preserve untouched field origins within nested message lists as well as outer
resource lists. PromptKit would continue owning the normalization rules;
ConfigKit would record their effects and provenance. Caller-appended messages
would still need request-time normalization because they do not pass through
ConfigKit.
Consider moving these transformations only if it improves inspection consistency
and simplifies implementation without changing execution behavior.
Upstream support is the reconsideration trigger, not a dependency of the
current ConfigKit migration or a commitment to adopt the enhancement.
No unselected ideas are currently recorded here. Deliberately postponed ideas
remain in the [deferred catalog](deferred.md).
## Entry Format

View File

@@ -101,8 +101,9 @@ type Engine struct {
}
// ConfigurationSources selects ordered ConfigKit underlays and an optional
// highest-priority main input. Each underlay's inputs strictly compose within
// their group; later underlays overlay earlier ones. A nil Main uses
// highest-priority main input. By default a bundled OpenRouter underlay precedes
// the caller underlays; [WithBundledOpenRouter] can disable it. Inputs within
// each underlay strictly compose; later underlays overlay earlier ones. A nil Main uses
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
// may fail construction if invalid.
type ConfigurationSources struct {
@@ -154,6 +155,15 @@ type engineOptions struct {
internal internalengine.Options
}
// WithBundledOpenRouter enables or disables the built-in OpenRouter underlay.
// It is enabled by default; when repeated, the last option wins.
func WithBundledOpenRouter(enabled bool) Option {
return optionFunc(func(options *engineOptions) error {
options.internal.SetBundledOpenRouter(enabled)
return nil
})
}
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
// [Engine.RunPrepared].
//
@@ -213,7 +223,9 @@ func WithSchemaFile(path string) Option {
// NewEngine constructs an Engine from configuration and options.
//
// ConfigKit composes caller-selected underlays in their given order, followed
// by the optional main input. Construction validates the final effective
// by the optional main input, after the default-on bundled OpenRouter underlay.
// The bundle adds a root backend and profiles to inventories, but no prompts
// or implicit profile selection. Construction validates the final effective
// backend, profile, and prompt definitions and captures referenced prompt
// content. A missing prompt collection is allowed. Schema documents and
// request artifacts remain runtime resources.

View File

@@ -98,7 +98,7 @@ func run() (summary, error) {
engine, err := promptkit.NewEngine(promptkit.Config{
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
HTTPClient: &http.Client{Transport: transport},
}, promptkit.WithSchemaFS(assets, "schemas"))
}, promptkit.WithBundledOpenRouter(false), promptkit.WithSchemaFS(assets, "schemas")) // Keep the example's configuration self-contained.
if err != nil {
return summary{}, err
}

View File

@@ -63,7 +63,7 @@ func run(ctx context.Context, exampleDir string, out io.Writer) error {
{Inputs: []configkit.Input{support, review}},
{Inputs: []configkit.Input{override}},
},
}})
}}, promptkit.WithBundledOpenRouter(false)) // Keep this example's inventory caller-owned.
if err != nil {
return err
}

View File

@@ -54,7 +54,7 @@ func run(ctx context.Context, exampleDir string, out io.Writer) error {
{ID: "offline-example", Endpoint: &endpoint, Model: &model},
})}}}},
Main: &configkit.Input{Source: configkit.File(filepath.Join(exampleDir, "prompt.yaml"))},
}}, promptkit.WithLLMClient(deterministicClient{}))
}}, promptkit.WithBundledOpenRouter(false), promptkit.WithLLMClient(deterministicClient{})) // Use only this example's offline configuration.
if err != nil {
return err
}

4
go.mod
View File

@@ -4,12 +4,12 @@ go 1.27.0
require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
go.maximumdirect.net/configkit v1.0.0
go.maximumdirect.net/configkit v1.1.0
)
require (
github.com/yuin/goldmark/v2 v2.1.6 // indirect
go.maximumdirect.net/releasekit v0.8.0 // indirect
go.maximumdirect.net/releasekit v0.9.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/text v0.14.0 // indirect

8
go.sum
View File

@@ -4,10 +4,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.maximumdirect.net/configkit v1.0.0 h1:cguQnxkaRk3ClQOZNzzhTdc4bfzbraGKv8CkoCp+HLc=
go.maximumdirect.net/configkit v1.0.0/go.mod h1:B6fJ5xBLBF1nuywqfLeEI4sLm29UkIcDLu8i6I6gGXE=
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
go.maximumdirect.net/releasekit v0.8.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=

View File

@@ -0,0 +1,16 @@
package configuration
import (
"embed"
"go.maximumdirect.net/configkit"
)
//go:embed bundled/openrouter.yaml
var bundledFiles embed.FS
// BundledOpenRouterUnderlay returns the built-in OpenRouter configuration source.
func BundledOpenRouterUnderlay() configkit.Underlay {
filesystem := configkit.NewFilesystem("Promptkit bundled OpenRouter", bundledFiles)
return configkit.Underlay{Inputs: []configkit.Input{{Source: filesystem.File("bundled/openrouter.yaml")}}}
}

View File

@@ -0,0 +1,60 @@
backends:
- id: openrouter
endpoint: https://openrouter.ai/api/v1
api_key_env: OPENROUTER_API_KEY
smoke_tests:
default_profile: gpt-luna-latest
authentication:
kind: openrouter_key
profiles:
- id: gpt-astra-latest
backend: openrouter
model: ~openai/gpt-astra-latest
- id: gpt-sol-latest
backend: openrouter
model: ~openai/gpt-sol-latest
- id: gpt-terra-latest
backend: openrouter
model: ~openai/gpt-terra-latest
- id: gpt-luna-latest
backend: openrouter
model: ~openai/gpt-luna-latest
- id: claude-fable-latest
backend: openrouter
model: ~anthropic/claude-fable-latest
- id: claude-opus-latest
backend: openrouter
model: ~anthropic/claude-opus-latest
- id: claude-sonnet-latest
backend: openrouter
model: ~anthropic/claude-sonnet-latest
- id: claude-haiku-latest
backend: openrouter
model: ~anthropic/claude-haiku-latest
- id: gemini-pro-latest
backend: openrouter
model: ~google/gemini-pro-latest
- id: gemini-flash-latest
backend: openrouter
model: ~google/gemini-flash-latest
- id: auto
backend: openrouter
model: openrouter/auto
- id: free
backend: openrouter
model: openrouter/free
- id: auto-low
extends: {id: auto}
extra_params: {plugins: [{id: auto-router, cost_tier: low}]}
- id: auto-medium
extends: {id: auto}
extra_params: {plugins: [{id: auto-router, cost_tier: medium}]}
- id: auto-high
extends: {id: auto}
extra_params: {plugins: [{id: auto-router, cost_tier: high}]}
- id: auto-xhigh
extends: {id: auto}
extra_params: {plugins: [{id: auto-router, cost_tier: xhigh}]}
- id: auto-max
extends: {id: auto}
extra_params: {plugins: [{id: auto-router, cost_tier: max}]}

View File

@@ -0,0 +1,47 @@
package configuration
import (
"context"
"fmt"
"strings"
"testing"
"go.maximumdirect.net/configkit"
)
func TestBundledOpenRouterUnderlay(t *testing.T) {
state, err := Load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{BundledOpenRouterUnderlay()})
if err != nil {
t.Fatal(err)
}
backend, err := state.GetBackend("openrouter")
if err != nil || backend.Endpoint != "https://openrouter.ai/api/v1" || backend.APIKeyEnv != "OPENROUTER_API_KEY" || backend.SmokeTests == nil || backend.SmokeTests.DefaultProfile == nil || *backend.SmokeTests.DefaultProfile != "gpt-luna-latest" || backend.SmokeTests.Authentication == nil || *backend.SmokeTests.Authentication.Kind != "openrouter_key" {
t.Fatalf("backend = %+v, %v", backend, err)
}
models := map[string]string{
"gpt-astra-latest": "~openai/gpt-astra-latest", "gpt-sol-latest": "~openai/gpt-sol-latest", "gpt-terra-latest": "~openai/gpt-terra-latest", "gpt-luna-latest": "~openai/gpt-luna-latest",
"claude-fable-latest": "~anthropic/claude-fable-latest", "claude-opus-latest": "~anthropic/claude-opus-latest", "claude-sonnet-latest": "~anthropic/claude-sonnet-latest", "claude-haiku-latest": "~anthropic/claude-haiku-latest",
"gemini-pro-latest": "~google/gemini-pro-latest", "gemini-flash-latest": "~google/gemini-flash-latest", "auto": "openrouter/auto", "free": "openrouter/free",
}
for _, tier := range []string{"low", "medium", "high", "xhigh", "max"} {
models["auto-"+tier] = "openrouter/auto"
}
if len(state.profileIDs) != len(models) {
t.Fatalf("profiles = %v", state.profileIDs)
}
for id, model := range models {
profile, err := state.GetProfile(context.Background(), id)
if err != nil || profile.ResolvedTarget.Model != model || profile.ResolvedTarget.BackendID != "openrouter" {
t.Fatalf("profile %s = %+v, %v", id, profile, err)
}
if tier, ok := strings.CutPrefix(id, "auto-"); ok {
plugins := fmt.Sprint(profile.ResolvedTarget.ExtraParams)
if !strings.Contains(plugins, "cost_tier:"+tier) || !strings.Contains(plugins, "id:auto-router") {
t.Fatalf("profile %s plugins = %+v", id, profile.ResolvedTarget.ExtraParams)
}
}
}
if !strings.Contains(fmt.Sprint(state.result.Sources()), "Promptkit bundled OpenRouter") {
t.Fatalf("source evidence = %+v", state.result.Sources())
}
}

View File

@@ -8,7 +8,7 @@ import (
"go.maximumdirect.net/configkit"
)
func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) {
func TestTargetResolutionNormalizesNestedValues(t *testing.T) {
state, err := load(mapping("padded target", map[string]any{
"backends": []any{map[string]any{
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 1,
@@ -33,20 +33,32 @@ func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) {
t.Fatalf("interpreted profile = %+v, %v", profile, err)
}
composed := state.result.Config.Backends[0]
if *composed.Endpoint != " HTTPS://example.invalid/v1 " || composed.QueueCapacity != nil ||
*composed.SmokeTests.Reachability.Endpoint != " https://example.invalid/health " ||
*composed.SmokeTests.Authentication.Kind != " NONE " || *composed.SmokeTests.Authentication.Endpoint != " https://example.invalid/key " ||
*state.result.Config.Profiles[0].ServiceTier != " standard " || *state.result.Config.Profiles[0].APIKeyEnv != " TOKEN " {
t.Fatalf("composed values were mutated: %+v", state.result.Config)
if *composed.Endpoint != "https://example.invalid/v1" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 ||
*composed.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
*composed.SmokeTests.Authentication.Kind != "none" || *composed.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" ||
*state.result.Config.Profiles[0].ServiceTier != "standard" || *state.result.Config.Profiles[0].APIKeyEnv != "TOKEN" {
t.Fatalf("resolved values were not normalized: %+v", state.result.Config)
}
path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint")
field, found := state.result.Metadata().Lookup(path)
if !found || len(field.Origin.Locations) == 0 || field.Origin.Operation == configkit.ChangeNormalization {
if !found || field.Origin.Operation != configkit.ChangeNormalization {
t.Fatalf("nested field origin changed: %+v", field)
}
for _, path := range []configkit.Path{
configkit.Keys("backends").Index(0).Key("endpoint"),
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("reachability").Key("endpoint"),
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("kind"),
configkit.Keys("profiles").Index(0).Key("service_tier"),
configkit.Keys("profiles").Index(0).Key("api_key_env"),
} {
field, found := state.result.Metadata().Lookup(path)
if !found || field.Origin.Operation != configkit.ChangeNormalization {
t.Errorf("normalization at %v: %+v", path, field)
}
}
}
func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t *testing.T) {
func TestPromptResolutionNormalizesOutputAndRejectsDuplicateNames(t *testing.T) {
definition := map[string]any{
"id": "example", "inputs": []any{map[string]any{"name": " document "}},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
@@ -61,8 +73,8 @@ func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t *
t.Fatalf("interpreted prompt = %+v, %+v, %v", selected, inspection, err)
}
composed := state.result.Config.Prompts[0]
if *composed.Output.SchemaPath != " schema.json " || composed.Inputs[0].Name != " document " {
t.Fatalf("composed prompt changed: %+v", composed)
if *composed.Output.SchemaPath != "schema.json" || composed.Inputs[0].Name != "document" {
t.Fatalf("prompt was not normalized: %+v", composed)
}
definition["inputs"] = []any{map[string]any{"name": " document "}, map[string]any{"name": "document"}}
_, err = load(mapping("duplicate inputs", map[string]any{"prompts": []any{definition}}), nil)
@@ -70,3 +82,71 @@ func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t *
t.Fatalf("duplicate normalized names were not rejected: %v", err)
}
}
func TestPromptResolutionRecordsMessageFields(t *testing.T) {
loadPrompt := func(role, cacheType, ttl string) *loaded {
t.Helper()
state, err := load(mapping("prompt", map[string]any{"prompts": []any{map[string]any{
"id": "example", "inputs": []any{map[string]any{"name": " input ", "content_type": nil, "description": " text "}},
"messages": []any{map[string]any{"role": role, "content": "", "cache_control": map[string]any{"type": cacheType, "ttl": ttl}}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}), nil)
if err != nil {
t.Fatal(err)
}
return state
}
padded := loadPrompt(" USER ", " ephemeral ", " 1h ")
canonical := loadPrompt("user", "ephemeral", "1h")
definition, inspection, err := padded.prompt("example")
if err != nil {
t.Fatal(err)
}
other, otherInspection, err := canonical.prompt("example")
if err != nil || inspection.PromptHash != otherInspection.PromptHash || definition.Templates[0].Role != other.Templates[0].Role || definition.Templates[0].Content != "" {
t.Fatalf("equivalent prompt mismatch: %+v %+v %v", definition, other, err)
}
base := configkit.Keys("prompts").Index(0)
for _, path := range []configkit.Path{base.Key("inputs").Index(0).Key("name"), base.Key("inputs").Index(0).Key("description"), base.Key("messages").Index(0).Key("role"), base.Key("messages").Index(0).Key("cache_control").Key("type"), base.Key("messages").Index(0).Key("cache_control").Key("ttl")} {
field, found := padded.result.Metadata().Lookup(path)
if !found || field.Origin.Operation != configkit.ChangeNormalization {
t.Errorf("normalization at %v: %+v", path, field)
}
}
if padded.promptMetadata["example"][0].contentType != fieldNull {
t.Fatal("null input metadata was not retained")
}
}
func TestResolutionRecordsTargetDependencies(t *testing.T) {
backend := func(id string) map[string]any {
return map[string]any{"id": id, "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 2, "queue_capacity": nil}
}
state, err := load(mapping("targets", map[string]any{
"backends": []any{backend("root")},
"collections": map[string]any{"scoped": map[string]any{"backends": []any{backend("local")}}},
}), nil)
if err != nil {
t.Fatal(err)
}
for _, base := range []configkit.Path{configkit.Keys("backends").Index(0), configkit.Keys("collections", "scoped", "backends").Index(0)} {
endpoint, ok := state.result.Explain(base.Key("endpoint"))
if !ok || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") {
t.Fatalf("endpoint explanation: %+v", endpoint)
}
history := state.result.Metadata().History(base.Key("queue_capacity"))
found := false
for _, change := range history {
if change.Origin.Operation == configkit.ChangeDerivedDefault {
for _, dependency := range change.Dependencies {
if dependency.String() == base.Key("concurrency_limit").String() {
found = true
}
}
}
}
if !found {
t.Fatalf("missing queue dependency: %+v", history)
}
}
}

View File

@@ -4,7 +4,6 @@ package configuration
import (
"fmt"
"net/http"
"regexp"
"sort"
"strings"
@@ -120,7 +119,6 @@ const (
type loaded struct {
result *configkit.Result[document]
interpreted document
backends map[string]map[string]fieldState
profiles map[string]map[string]fieldState
promptMetadata map[string][]promptInputPresence
@@ -243,11 +241,15 @@ func configurationDefinition(state *loaded) configkit.Definition[document] {
captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
}
for _, resources := range resourceSets(config) {
resolveBackends(resources.backends, resources.bases.backends, resolution)
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
}
return nil
},
Validate: func(config *document, validation *configkit.Validation) {
state.interpreted = interpretConfiguration(config, validation)
validateEffective(&state.interpreted, validation)
validateEffective(config, validation)
},
}
}
@@ -274,7 +276,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
if err := state.buildExecutionProfiles(); err != nil {
return nil, fmt.Errorf("load configuration: %w", err)
}
state.backendIDs = sortedKeys(indexedBackends(&state.interpreted))
state.backendIDs = sortedKeys(indexedBackends(&state.result.Config))
state.profileIDs = sortedKeys(state.indexedProfiles)
for _, id := range sortedKeys(state.prompts) {
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
@@ -372,6 +374,29 @@ func captureFields[T any](entries []T, identity func(T) string, basePath configk
}
}
// eligibleResourceList leaves invalid identities to the existing validation
// boundaries, without mutating a list that cannot be recorded by identity.
func eligibleResourceList[T any](entries []T, identity func(T) string) bool {
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
id := identity(entry)
if !domain.IsValidLocalResourceKey(id) {
return false
}
if _, exists := seen[id]; exists {
return false
}
seen[id] = struct{}{}
}
return true
}
func recordStringNormalization(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) {
if *value != nil && **value != normalized {
configkit.Normalize(resolution, path, value, &normalized, "Normalize configured value")
}
}
func sortedKeys[T any](entries map[string]T) []string {
keys := make([]string, 0, len(entries))
for key := range entries {
@@ -381,113 +406,6 @@ func sortedKeys[T any](entries map[string]T) []string {
return keys
}
func interpretConfiguration(config *document, validation *configkit.Validation) document {
interpreted := *config
interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation)
interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation)
interpreted.Prompts = interpretPrompts(config.Prompts)
if config.Collections != nil {
interpreted.Collections = make(map[string]collectionConfig, len(config.Collections))
for namespace, original := range config.Collections {
collection := original
base := configkit.Keys("collections", namespace)
collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation)
collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation)
collection.Prompts = interpretPrompts(original.Prompts)
interpreted.Collections[namespace] = collection
}
}
return interpreted
}
func interpretBackends(source []backendConfig, basePath configkit.Path, validation *configkit.Validation) []backendConfig {
if source == nil {
return nil
}
backends := append([]backendConfig{}, source...)
for index := range backends {
backend := &backends[index]
base := basePath.Index(index)
if backend.Endpoint != nil {
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
if err != nil {
validation.Error(base.Key("endpoint"), err.Error())
} else {
backend.Endpoint = &endpoint
}
}
if backend.APIKeyEnv != nil {
value := strings.TrimSpace(*backend.APIKeyEnv)
backend.APIKeyEnv = &value
}
if backend.SmokeTests != nil {
smokeTests := *backend.SmokeTests
backend.SmokeTests = &smokeTests
smokePath := base.Key("smoke_tests")
if smokeTests.Reachability != nil {
reachability := *smokeTests.Reachability
smokeTests.Reachability = &reachability
normalizeDiagnosticEndpoint(&reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), validation)
}
if smokeTests.Authentication != nil {
authentication := *smokeTests.Authentication
smokeTests.Authentication = &authentication
if authentication.Kind != nil {
kind := strings.ToLower(strings.TrimSpace(*authentication.Kind))
authentication.Kind = &kind
}
normalizeDiagnosticEndpoint(&authentication.Endpoint, smokePath.Key("authentication").Key("endpoint"), validation)
}
}
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
capacity := 1024
backend.QueueCapacity = &capacity
}
}
return backends
}
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, validation *configkit.Validation) {
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() == "" {
validation.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
return
}
*value = &normalized
}
func interpretProfiles(source []profileConfig, basePath configkit.Path, validation *configkit.Validation) []profileConfig {
if source == nil {
return nil
}
profiles := append([]profileConfig{}, source...)
for index := range profiles {
profile := &profiles[index]
base := basePath.Index(index)
if profile.Endpoint != nil && *profile.Endpoint != "" {
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
if err != nil {
validation.Error(base.Key("endpoint"), err.Error())
} else {
profile.Endpoint = &endpoint
}
}
for _, value := range []**string{&profile.ServiceTier, &profile.ReasoningEffort, &profile.APIKeyEnv} {
if *value != nil {
normalized := strings.TrimSpace(**value)
*value = &normalized
}
}
}
return profiles
}
func validateEffective(config *document, validation *configkit.Validation) {
sets := resourceSets(config)
backendIDs := make(map[string]struct{})
@@ -502,6 +420,7 @@ func validateEffective(config *document, validation *configkit.Validation) {
for _, resources := range sets {
for index, backend := range resources.backends {
base := resources.bases.backends.Index(index)
validateBackendEndpoints(backend, base, validation)
if !domain.IsValidLocalResourceKey(backend.ID) {
validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash")
}
@@ -546,6 +465,11 @@ func validateEffective(config *document, validation *configkit.Validation) {
}
for index, profile := range resources.profiles {
base := resources.bases.profiles.Index(index)
if profile.Endpoint != nil && *profile.Endpoint != "" {
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint); err != nil {
validation.Error(base.Key("endpoint"), err.Error())
}
}
if !domain.IsValidLocalResourceKey(profile.ID) {
validation.Error(base.Key("id"), "profile ID must be a nonblank local key without a slash")
}

View File

@@ -153,6 +153,50 @@ func TestIdentifiedResourcesRejectDuplicateStrictPeers(t *testing.T) {
}
}
func TestInvalidIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
for _, kind := range []string{"backends", "profiles", "prompts"} {
for _, id := range []string{" ", "bad/id"} {
t.Run(kind+"/"+id, func(t *testing.T) {
record := map[string]any{"id": id}
switch kind {
case "backends":
record["endpoint"] = " HTTPS://example.invalid/v1 "
record["concurrency_limit"] = 1
case "profiles":
record["service_tier"] = " standard "
case "prompts":
record["description"] = " padded "
}
_, err := load(mapping("invalid", map[string]any{kind: []any{record}}), nil)
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
t.Fatalf("invalid identity error = %v", err)
}
})
}
}
}
func TestDuplicateIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
for _, kind := range []string{"backends", "profiles", "prompts"} {
t.Run(kind, func(t *testing.T) {
record := map[string]any{"id": "same"}
switch kind {
case "backends":
record["endpoint"] = " HTTPS://example.invalid/v1 "
record["concurrency_limit"] = 1
case "profiles":
record["service_tier"] = " standard "
case "prompts":
record["description"] = " padded "
}
_, err := load(mapping("duplicates", map[string]any{kind: []any{record, record}}), nil)
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
t.Fatalf("duplicate identity error = %v", err)
}
})
}
}
func TestFinalIdentitiesAreCheckedAfterApplicationResolution(t *testing.T) {
for _, kind := range []string{"backends", "profiles", "prompts"} {
for _, namespace := range []string{"", "*"} {
@@ -534,17 +578,25 @@ func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) {
t.Fatalf("backend normalization/default was not applied: %+v", backend)
}
composed := loaded.result.Config.Backends[0]
if composed.Endpoint == nil || *composed.Endpoint != " HTTP://localhost:8080/v1 " ||
composed.APIKeyEnv == nil || *composed.APIKeyEnv != " TOKEN_NAME " || composed.QueueCapacity != nil {
t.Fatalf("composed backend was changed: %+v", composed)
if composed.Endpoint == nil || *composed.Endpoint != "http://localhost:8080/v1" ||
composed.APIKeyEnv == nil || *composed.APIKeyEnv != "TOKEN_NAME" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 {
t.Fatalf("resolved backend was not normalized: %+v", composed)
}
if loaded.backends["local"]["queue_capacity"] != fieldNull {
t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"])
}
field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint"))
if !found || field.Origin.Operation == configkit.ChangeNormalization || len(field.Origin.Locations) == 0 {
if !found || field.Origin.Operation != configkit.ChangeNormalization {
t.Fatalf("endpoint origin was not preserved: %+v", field)
}
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("queue_capacity"))
if !found || field.Origin.Operation != configkit.ChangeDerivedDefault {
t.Fatalf("queue capacity default was not recorded: %+v", field)
}
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("concurrency_limit"))
if !found || field.Origin.Operation == configkit.ChangeNormalization || field.Origin.Operation == configkit.ChangeDerivedDefault {
t.Fatalf("unchanged concurrency acquired a resolution record: %+v", field)
}
}
func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
@@ -711,7 +763,7 @@ func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
t.Fatal(err)
}
backend, err := loaded.GetBackend("local")
if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity != nil || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity == nil || *loaded.result.Config.Backends[0].QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
}
}

View File

@@ -28,44 +28,6 @@ type promptEntry struct {
messages []*template.Template
}
func interpretPrompts(source []promptConfig) []promptConfig {
if source == nil {
return nil
}
prompts := append([]promptConfig{}, source...)
for index := range prompts {
definition := &prompts[index]
for _, value := range []**string{&definition.Description, &definition.SessionID} {
if *value != nil {
normalized := strings.TrimSpace(**value)
*value = &normalized
}
}
if definition.Inputs != nil {
definition.Inputs = append([]promptInputConfig{}, definition.Inputs...)
for index := range definition.Inputs {
input := &definition.Inputs[index]
input.Name = strings.TrimSpace(input.Name)
for _, value := range []**string{&input.ContentType, &input.Description} {
if *value != nil {
normalized := strings.TrimSpace(**value)
*value = &normalized
}
}
}
}
if definition.Output != nil {
output := *definition.Output
definition.Output = &output
if output.SchemaPath != nil {
normalized := strings.TrimSpace(*output.SchemaPath)
output.SchemaPath = &normalized
}
}
}
return prompts
}
func (state *loaded) materializePrompts() error {
state.prompts = make(map[string]*promptEntry)
var metadata *configkit.Metadata
@@ -76,7 +38,7 @@ func (state *loaded) materializePrompts() error {
}
return metadata.Lookup(path)
}
for _, resources := range resourceSets(&state.interpreted) {
for _, resources := range resourceSets(&state.result.Config) {
for index, raw := range resources.prompts {
promptBase := resources.bases.prompts.Index(index)
if strings.TrimSpace(raw.ID) == "" {
@@ -152,8 +114,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk
if message.Role == nil {
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
}
role, err := domain.NormalizeMessageRole(*message.Role)
if err != nil {
if _, err := domain.NormalizeMessageRole(*message.Role); err != nil {
return nil, fmt.Errorf("%s: %w", path.Key("role"), err)
}
if (message.Content != nil) == (message.ContentFile != nil) {
@@ -178,9 +139,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk
if message.CacheControl.TTL != nil {
control.TTL = *message.CacheControl.TTL
}
var err error
control, err = domain.NormalizeCacheControl(control)
if err != nil {
if _, err := domain.NormalizeCacheControl(control); err != nil {
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
}
}
@@ -190,7 +149,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk
}
entry.messages = append(entry.messages, compiled)
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
Role: role, Content: content, CacheControl: control,
Role: *message.Role, Content: content, CacheControl: control,
})
}
if raw.Output == nil {

View File

@@ -16,7 +16,7 @@ import (
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
files := fstest.MapFS{
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n description: ' description '\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
"app/body.md": {Data: []byte("Replacement {{.name}}")},
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n description: ' description '\n session_id: ' session '\n inputs:\n - name: ' document '\n content_type: ' text/plain '\n description: ' document source '\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
@@ -60,9 +60,9 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
t.Fatal("inherited prompt missing from effective list")
}
composed := state.result.Config.Collections["docs"].Prompts[childIndex]
if *composed.Description != " description " || *composed.SessionID != " session " || composed.Inputs[0].Name != " document " ||
*composed.Inputs[0].ContentType != " text/plain " || *composed.Inputs[0].Description != " document source " {
t.Fatalf("composed metadata was changed: %+v", composed)
if *composed.Description != "description" || *composed.SessionID != "session" || composed.Inputs[0].Name != "document" ||
*composed.Inputs[0].ContentType != "text/plain" || *composed.Inputs[0].Description != "document source" {
t.Fatalf("resolved metadata was not normalized: %+v", composed)
}
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts").Index(childIndex).Key("messages").Index(0).Key("content_file"))
if !ok || len(field.Origin.Locations) != 1 {

View File

@@ -0,0 +1,68 @@
package configuration
import (
"strings"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit/internal/domain"
)
func resolvePrompts(entries []promptConfig, base configkit.Path, resolution *configkit.Resolution) {
if !eligibleResourceList(entries, func(v promptConfig) string { return v.ID }) {
return
}
for i := range entries {
entry := &entries[i]
path := base.Index(i)
for _, field := range []struct {
value **string
name string
}{{&entry.Description, "description"}, {&entry.SessionID, "session_id"}} {
if *field.value != nil {
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
}
}
for j := range entry.Inputs {
input := &entry.Inputs[j]
inputPath := path.Key("inputs").Index(j)
if normalized := strings.TrimSpace(input.Name); normalized != input.Name {
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalized, "Normalize input name")
}
for _, field := range []struct {
value **string
name string
}{{&input.ContentType, "content_type"}, {&input.Description, "description"}} {
if *field.value != nil {
recordStringNormalization(field.value, inputPath.Key(field.name), resolution, strings.TrimSpace(**field.value))
}
}
}
if entry.Output != nil && entry.Output.SchemaPath != nil {
recordStringNormalization(&entry.Output.SchemaPath, path.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*entry.Output.SchemaPath))
}
for j := range entry.Messages {
message := &entry.Messages[j]
messagePath := path.Key("messages").Index(j)
if message.Role != nil {
if value, err := domain.NormalizeMessageRole(*message.Role); err == nil {
recordStringNormalization(&message.Role, messagePath.Key("role"), resolution, value)
}
}
if message.CacheControl == nil || message.CacheControl.Type == nil {
continue
}
cache := message.CacheControl
control := &domain.CacheControl{Type: domain.CacheControlType(*cache.Type)}
if cache.TTL != nil {
control.TTL = *cache.TTL
}
if normalized, err := domain.NormalizeCacheControl(control); err == nil {
cachePath := messagePath.Key("cache_control")
recordStringNormalization(&cache.Type, cachePath.Key("type"), resolution, string(normalized.Type))
if cache.TTL != nil {
recordStringNormalization(&cache.TTL, cachePath.Key("ttl"), resolution, normalized.TTL)
}
}
}
}
}

View File

@@ -0,0 +1,102 @@
package configuration
import (
"net/http"
"strings"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit/internal/domain"
)
func resolveBackends(entries []backendConfig, base configkit.Path, resolution *configkit.Resolution) {
if !eligibleResourceList(entries, func(v backendConfig) string { return v.ID }) {
return
}
for i := range entries {
entry := &entries[i]
path := base.Index(i)
if entry.Endpoint != nil {
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
}
}
if entry.APIKeyEnv != nil {
recordStringNormalization(&entry.APIKeyEnv, path.Key("api_key_env"), resolution, strings.TrimSpace(*entry.APIKeyEnv))
}
if entry.ConcurrencyLimit != nil && *entry.ConcurrencyLimit > 0 && entry.QueueCapacity == nil {
capacity := 1024
entry.QueueCapacity = &capacity
resolution.RecordDerivedDefault(path.Key("queue_capacity"), "Derive queue capacity", path.Key("concurrency_limit"))
}
if entry.SmokeTests == nil {
continue
}
smoke := entry.SmokeTests
smokePath := path.Key("smoke_tests")
if smoke.Reachability != nil {
resolveDiagnosticEndpoint(&smoke.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
}
if smoke.Authentication != nil {
auth := smoke.Authentication
authPath := smokePath.Key("authentication")
if auth.Kind != nil {
recordStringNormalization(&auth.Kind, authPath.Key("kind"), resolution, strings.ToLower(strings.TrimSpace(*auth.Kind)))
}
resolveDiagnosticEndpoint(&auth.Endpoint, authPath.Key("endpoint"), resolution)
}
}
}
func diagnosticEndpointValid(value string) bool {
normalized := strings.TrimSpace(value)
request, err := http.NewRequest(http.MethodGet, normalized, nil)
return normalized != "" && err == nil && request.URL != nil && (strings.EqualFold(request.URL.Scheme, "http") || strings.EqualFold(request.URL.Scheme, "https")) && request.URL.Hostname() != ""
}
func validateBackendEndpoints(entry backendConfig, path configkit.Path, validation *configkit.Validation) {
if entry.Endpoint != nil {
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err != nil {
validation.Error(path.Key("endpoint"), err.Error())
}
}
if entry.SmokeTests == nil {
return
}
smoke := entry.SmokeTests
path = path.Key("smoke_tests")
if smoke.Reachability != nil && smoke.Reachability.Endpoint != nil && !diagnosticEndpointValid(*smoke.Reachability.Endpoint) {
validation.Error(path.Key("reachability").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
}
if smoke.Authentication != nil && smoke.Authentication.Endpoint != nil && !diagnosticEndpointValid(*smoke.Authentication.Endpoint) {
validation.Error(path.Key("authentication").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
}
}
func resolveDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
if *value != nil && diagnosticEndpointValid(**value) {
recordStringNormalization(value, path, resolution, strings.TrimSpace(**value))
}
}
func resolveProfiles(entries []profileConfig, base configkit.Path, resolution *configkit.Resolution) {
if !eligibleResourceList(entries, func(v profileConfig) string { return v.ID }) {
return
}
for i := range entries {
entry := &entries[i]
path := base.Index(i)
if entry.Endpoint != nil && *entry.Endpoint != "" {
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
}
}
for _, field := range []struct {
value **string
name string
}{{&entry.ServiceTier, "service_tier"}, {&entry.ReasoningEffort, "reasoning_effort"}, {&entry.APIKeyEnv, "api_key_env"}} {
if *field.value != nil {
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
}
}
}
}

View File

@@ -12,8 +12,8 @@ import (
)
func (state *loaded) buildExecutionProfiles() error {
backendConfigs := indexedBackends(&state.interpreted)
profileConfigs := indexedProfiles(&state.interpreted)
backendConfigs := indexedBackends(&state.result.Config)
profileConfigs := indexedProfiles(&state.result.Config)
definitions := make(map[string]domain.Backend, len(backendConfigs))
for _, id := range sortedKeys(backendConfigs) {
config := backendConfigs[id]

View File

@@ -41,13 +41,17 @@ type ConfigurationSources struct {
// Options collects construction choices from the public facade.
type Options struct {
llmClient llm.Client
artifactReader artifact.Reader
validator validate.Validator
validatorSource bool
artifactSource bool
llmClient llm.Client
artifactReader artifact.Reader
validator validate.Validator
validatorSource bool
artifactSource bool
disableBundledOpenRouter bool
}
// SetBundledOpenRouter selects whether the built-in OpenRouter source is installed.
func (o *Options) SetBundledOpenRouter(enabled bool) { o.disableBundledOpenRouter = !enabled }
// SetLLMClient selects the client collaborator for one engine.
func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client }
@@ -124,6 +128,11 @@ func New(cfg Config, options Options) (*Engine, error) {
return nil, err
}
underlays := cfg.Sources.Underlays
if !options.disableBundledOpenRouter {
underlays = make([]configkit.Underlay, 1, len(cfg.Sources.Underlays)+1)
underlays[0] = configuration.BundledOpenRouterUnderlay()
underlays = append(underlays, cfg.Sources.Underlays...)
}
main := configkit.Input{Source: configkit.NoSource()}
if cfg.Sources.Main != nil {
main = *cfg.Sources.Main

View File

@@ -226,7 +226,7 @@ func newNamespaceListingEngine(t *testing.T) *promptkit.Engine {
"profiles": []any{map[string]any{"id": "root-profile", "backend": "root-backend", "model": "root-model"}},
"prompts": []any{listingContractPrompt("root-prompt")},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
var loadErr *configkit.LoadError
if errors.As(err, &loadErr) {
@@ -464,7 +464,7 @@ func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) {
}
func TestListIdentitiesEmptyEffectiveConfiguration(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{})
engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}

View File

@@ -23,7 +23,7 @@ func TestProgrammaticSourcesPreserveOrderAndRejectDuplicates(t *testing.T) {
params["visible"] = "changed"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: profileSource}}}},
}})
}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}