Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f6aad305 | |||
| fda0338134 | |||
| 2b3d165ce2 | |||
| 43f55edb98 | |||
| 4950e1dee2 | |||
| d240364776 | |||
| 6f57f2f30a | |||
| 2c50eca033 | |||
| ac44a4f325 | |||
| 9e05c3ac8b | |||
| 2c83925c88 | |||
| e0cd63923d | |||
| 75204d6134 | |||
| 5ce6e01f27 | |||
| 5cec25a40c | |||
| 855aae6daa | |||
| 43abbcb414 | |||
| 9f33fed823 | |||
| 7a98feb399 | |||
| 626134e5e1 | |||
| e7ed315671 | |||
| d7d9a7dd65 | |||
| 18f6a474e0 | |||
| fb7ebe6a6c |
@@ -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.
|
||||
|
||||
115
bundled_composition_contract_test.go
Normal file
115
bundled_composition_contract_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
90
bundled_execution_contract_test.go
Normal file
90
bundled_execution_contract_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
124
bundled_openrouter_contract_test.go
Normal file
124
bundled_openrouter_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
127
bundled_smoke_contract_test.go
Normal file
127
bundled_smoke_contract_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
65
docs/releases/v0.18.0.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
18
engine.go
18
engine.go
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
4
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
16
internal/configuration/bundled.go
Normal file
16
internal/configuration/bundled.go
Normal 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")}}}
|
||||
}
|
||||
60
internal/configuration/bundled/openrouter.yaml
Normal file
60
internal/configuration/bundled/openrouter.yaml
Normal 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}]}
|
||||
47
internal/configuration/bundled_test.go
Normal file
47
internal/configuration/bundled_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
internal/configuration/resolve_prompts.go
Normal file
68
internal/configuration/resolve_prompts.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
internal/configuration/resolve_targets.go
Normal file
102
internal/configuration/resolve_targets.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user