Compare commits
72 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 | |||
| a0441fb2d1 | |||
| 32547e1e29 | |||
| bcc9ebd2e0 | |||
| c7d4318122 | |||
| 175140ae5a | |||
| 66b98831d3 | |||
| 3e3cc677fb | |||
| 8896d8733b | |||
| 7bbdb55888 | |||
| 79dfd97afa | |||
| 995c309560 | |||
| ea4fec9b1b | |||
| 1de3e1fe38 | |||
| 061f01c081 | |||
| 937b9723d4 | |||
| 367af5feca | |||
| fd73cd0662 | |||
| 81a55bc62c | |||
| 2bc976de10 | |||
| fc568aa0b2 | |||
| b0bfe857fe | |||
| 2ccbee3758 | |||
| 0eee3ec962 | |||
| 130c4cc447 | |||
| f661176b06 | |||
| 3e67743fbf | |||
| 6b8eefd18b | |||
| 682cb84c82 | |||
| dbc345e566 | |||
| afcfae2aea | |||
| eaca50763b | |||
| cb83b8aca6 | |||
| 0b026861a5 | |||
| da8496b45b | |||
| e01b23259a | |||
| 500b0aa1c8 | |||
| fd6e9742a1 | |||
| a6f4d67e15 | |||
| c3ce15a4ab | |||
| 8e8f61f53f | |||
| b7565affe5 | |||
| aba890aa86 | |||
| 0b271267a0 | |||
| a4e97bf102 | |||
| 6ae005ed3a | |||
| 07a55e45f2 | |||
| 2b3875008b | |||
| a51d12d898 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,7 +22,7 @@
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# Local ReleaseKit executable
|
# Local ReleaseKit delivery credentials
|
||||||
.releasekit/
|
.releasekit/
|
||||||
|
|
||||||
# env file
|
# env file
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -4,7 +4,7 @@ Promptkit is a reusable Go library for preparing and executing prompt-defined
|
|||||||
LLM workflows. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
go.maximumdirect.net/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `promptkit` package provides the supported public engine. Consumers
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
@@ -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
|
a scoped view. The example also demonstrates collection inventories and
|
||||||
namespace-filtered discovery, without contacting a provider or requiring
|
namespace-filtered discovery, without contacting a provider or requiring
|
||||||
credentials.
|
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,
|
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
||||||
ordered file/embedded sources, programmatic mappings, and how to replace an
|
ordered file/embedded sources, programmatic mappings, and how to replace an
|
||||||
engine when source files change. Applications adapting the prior keyed
|
engine when source files change. Applications adapting the prior keyed
|
||||||
@@ -43,10 +45,22 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## 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.
|
||||||
|
|
||||||
|
Consumers using the former Go module path should follow the
|
||||||
|
[module-path migration guidance](docs/consumers/configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
||||||
diagnostics, configuration collections, scoped engine views, and discovery.
|
diagnostics, configuration collections, scoped engine views, and discovery.
|
||||||
Upgrading consumers should review the breaking changes to bundled configuration
|
Upgrading consumers should also review the current identified-resource and
|
||||||
and prompt version selection and follow the linked migration guide.
|
ConfigKit v1 changes in the [migration guide](docs/consumers/configuration-migration.md).
|
||||||
|
|
||||||
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
||||||
validation of concrete requests with `Engine.ValidateRequest`.
|
validation of concrete requests with `Engine.ValidateRequest`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
artifactadapter "go.maximumdirect.net/promptkit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -71,7 +71,7 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
message := "Hello {{.name}}"
|
message := "Hello {{.name}}"
|
||||||
response := "ok"
|
response := "ok"
|
||||||
output := map[string]any{"format": "text", "validation_mode": "none"}
|
output := map[string]any{"format": "text", "validation_mode": "none"}
|
||||||
profile := map[string]any{"endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
profile := map[string]any{"id": "profile", "endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
||||||
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
||||||
var expectedMessage string
|
var expectedMessage string
|
||||||
|
|
||||||
@@ -99,9 +99,8 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := map[string]any{
|
config := map[string]any{
|
||||||
"profiles": map[string]any{"profile": profile},
|
"profiles": []any{profile},
|
||||||
"prompts": map[string]any{"workflow": map[string]any{
|
"prompts": []any{map[string]any{"id": "workflow",
|
||||||
"version": "v1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": message}},
|
"messages": []any{map[string]any{"role": "user", "content": message}},
|
||||||
"output": output,
|
"output": output,
|
||||||
@@ -128,7 +127,7 @@ func verifyAssembledWorkflowFixture(b *testing.B, fixture assembledWorkflowFixtu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.PromptID != "workflow" || prepared.PromptVersion == nil || *prepared.PromptVersion != "v1" || len(prepared.Messages) != 1 ||
|
if prepared.PromptID != "workflow" || len(prepared.Messages) != 1 ||
|
||||||
prepared.Messages[0].Content != fixture.expectedMessage {
|
prepared.Messages[0].Content != fixture.expectedMessage {
|
||||||
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
||||||
}
|
}
|
||||||
|
|||||||
21
backends.go
21
backends.go
@@ -1,8 +1,8 @@
|
|||||||
package promptkit
|
package promptkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
||||||
@@ -14,13 +14,15 @@ const BackendRakestrawHome = backend.RakestrawHomeID
|
|||||||
// BackendLocal is a conventional ID for a caller-configured local backend.
|
// BackendLocal is a conventional ID for a caller-configured local backend.
|
||||||
const BackendLocal = "local"
|
const BackendLocal = "local"
|
||||||
|
|
||||||
// BackendConfig is a presence-aware convenience value for a keyed backend
|
// BackendConfig is a presence-aware convenience value for an identified backend
|
||||||
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
||||||
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
||||||
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
||||||
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
||||||
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
||||||
type BackendConfig struct {
|
type BackendConfig struct {
|
||||||
|
// ID is the local backend identity within its resource list.
|
||||||
|
ID string
|
||||||
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
||||||
Endpoint *string
|
Endpoint *string
|
||||||
// APIKeyEnv optionally names an environment variable, not a credential.
|
// APIKeyEnv optionally names an environment variable, not a credential.
|
||||||
@@ -71,15 +73,16 @@ type AuthenticationProbeConfig struct {
|
|||||||
Endpoint *string
|
Endpoint *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendSource creates a labeled ConfigKit mapping from keyed backend values.
|
// BackendSource creates a labeled ConfigKit mapping containing backend records
|
||||||
|
// in caller order. IDs are explicit and duplicates are left for loading to reject.
|
||||||
// It encodes fields without validating them; NewEngine validates the final
|
// It encodes fields without validating them; NewEngine validates the final
|
||||||
// effective configuration after all selected source layers are combined.
|
// effective configuration after all selected source layers are combined.
|
||||||
// Mapping captures a copy of supported input values at creation; unsupported
|
// Mapping captures a copy of supported input values at creation; unsupported
|
||||||
// values fail when the source is loaded.
|
// values fail when the source is loaded.
|
||||||
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source {
|
func BackendSource(label string, backends []BackendConfig) configkit.Source {
|
||||||
definitions := make(map[string]any, len(backends))
|
definitions := make([]any, 0, len(backends))
|
||||||
for id, config := range backends {
|
for _, config := range backends {
|
||||||
fields := make(map[string]any)
|
fields := map[string]any{"id": config.ID}
|
||||||
if config.Endpoint != nil {
|
if config.Endpoint != nil {
|
||||||
fields["endpoint"] = *config.Endpoint
|
fields["endpoint"] = *config.Endpoint
|
||||||
}
|
}
|
||||||
@@ -119,7 +122,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
|||||||
}
|
}
|
||||||
fields["smoke_tests"] = smokeTests
|
fields["smoke_tests"] = smokeTests
|
||||||
}
|
}
|
||||||
definitions[id] = fields
|
definitions = append(definitions, fields)
|
||||||
}
|
}
|
||||||
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
||||||
@@ -247,11 +247,12 @@ func newBackendCapacityEngine(
|
|||||||
promptID, content = "input-prompt", `{{input "input"}}`
|
promptID, content = "input-prompt", `{{input "input"}}`
|
||||||
inputs = []any{map[string]any{"name": "input", "required": true}}
|
inputs = []any{map[string]any{"name": "input", "required": true}}
|
||||||
}
|
}
|
||||||
backend := map[string]any{"endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
backend := map[string]any{"id": "limited", "endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
||||||
if queueCapacity != nil {
|
if queueCapacity != nil {
|
||||||
backend["queue_capacity"] = *queueCapacity
|
backend["queue_capacity"] = *queueCapacity
|
||||||
}
|
}
|
||||||
definition := map[string]any{
|
definition := map[string]any{
|
||||||
|
"id": promptID,
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
@@ -265,9 +266,9 @@ func newBackendCapacityEngine(
|
|||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("capacity fixture", map[string]any{
|
Source: configkit.Mapping("capacity fixture", map[string]any{
|
||||||
"backends": map[string]any{"limited": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"profile": map[string]any{"backend": "limited", "model": "model"}},
|
"profiles": []any{map[string]any{"id": "profile", "backend": "limited", "model": "model"}},
|
||||||
"prompts": map[string]any{promptID: promptDefinitionWithVersion("1", definition)},
|
"prompts": []any{definition},
|
||||||
}),
|
}),
|
||||||
}}}, options...)
|
}}}, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
|||||||
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
if got, want := difference.Path.String(), "/profiles/offline/model"; got != want {
|
if got, want := difference.Path.String(), "/profiles"; got != want {
|
||||||
t.Fatalf("difference path = %q, want %q", got, want)
|
t.Fatalf("difference path = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
||||||
@@ -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) {
|
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
||||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||||
"sequence": []any{"earlier", "unchanged"},
|
"sequence": []any{"earlier", "unchanged"},
|
||||||
@@ -94,18 +122,14 @@ func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare presence and sequence values: %v", err)
|
t.Fatalf("compare presence and sequence values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("atomic backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
difference := differences[0]
|
||||||
nullable, ok := byPath["/backends/local/extra_params/nullable"]
|
if !difference.Left.Present || !difference.Right.Present ||
|
||||||
if !ok || nullable.Left.Present || !nullable.Right.Present || strings.TrimSpace(nullable.Right.YAML) != "null" {
|
strings.Contains(difference.Left.YAML, "nullable:") || !strings.Contains(difference.Right.YAML, "nullable: null") ||
|
||||||
t.Fatalf("absent-to-null difference = (%+v, %t)", nullable, ok)
|
!strings.Contains(difference.Left.YAML, "earlier") || !strings.Contains(difference.Right.YAML, "later") {
|
||||||
}
|
t.Fatalf("atomic sequence snapshots = %+v", difference)
|
||||||
sequence, ok := byPath["/backends/local/extra_params/sequence"]
|
|
||||||
if !ok || !sequence.Left.Present || !sequence.Right.Present ||
|
|
||||||
!strings.Contains(sequence.Left.YAML, "earlier") || !strings.Contains(sequence.Right.YAML, "later") {
|
|
||||||
t.Fatalf("atomic sequence difference = (%+v, %t)", sequence, ok)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,22 +147,19 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive values: %v", err)
|
t.Fatalf("compare sensitive values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("sensitive backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
secret := differences[0]
|
||||||
secret, ok := byPath["/backends/local/extra_params/token"]
|
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||||
if !ok || !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
t.Fatalf("sensitive difference = %+v", secret)
|
||||||
t.Fatalf("sensitive difference = (%+v, %t)", secret, ok)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
||||||
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
||||||
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
||||||
}
|
}
|
||||||
visible, ok := byPath["/backends/local/extra_params/visible"]
|
if !strings.Contains(secret.Left.YAML, "before-value") || !strings.Contains(secret.Right.YAML, "after-value") {
|
||||||
if !ok || visible.Left.Redacted || visible.Right.Redacted || visible.HiddenChanged ||
|
t.Fatalf("public sibling missing from snapshots: %+v", secret)
|
||||||
!strings.Contains(visible.Left.YAML, "before-value") || !strings.Contains(visible.Right.YAML, "after-value") {
|
|
||||||
t.Fatalf("public sibling difference = (%+v, %t)", visible, ok)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
differences[0].Path = configkit.Keys("caller", "mutation")
|
differences[0].Path = configkit.Keys("caller", "mutation")
|
||||||
@@ -148,18 +169,15 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
||||||
}
|
}
|
||||||
if len(again) != 2 {
|
if len(again) != 1 {
|
||||||
t.Fatalf("repeat difference count = %d, want 2: %+v", len(again), again)
|
t.Fatalf("repeat difference count = %d, want 1: %+v", len(again), again)
|
||||||
}
|
}
|
||||||
againByPath := differencesByPath(again)
|
againByPath := differencesByPath(again)
|
||||||
if _, exists := againByPath["/caller/mutation"]; exists {
|
if _, exists := againByPath["/caller/mutation"]; exists {
|
||||||
t.Fatal("mutating returned path reached retained inspection")
|
t.Fatal("mutating returned path reached retained inspection")
|
||||||
}
|
}
|
||||||
if _, exists := againByPath["/backends/local/extra_params/token"]; !exists {
|
if _, exists := againByPath["/backends"]; !exists {
|
||||||
t.Fatalf("original sensitive path was lost after mutation: %+v", again)
|
t.Fatalf("original backend path was lost after mutation: %+v", again)
|
||||||
}
|
|
||||||
if _, exists := againByPath["/backends/local/extra_params/visible"]; !exists {
|
|
||||||
t.Fatalf("original public path was lost after mutation: %+v", again)
|
|
||||||
}
|
}
|
||||||
for _, difference := range again {
|
for _, difference := range again {
|
||||||
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
||||||
@@ -183,7 +201,7 @@ func TestConfigurationInspectionDiffMasksSensitiveShapeReplacement(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive shape replacement: %v", err)
|
t.Fatalf("compare sensitive shape replacement: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 1 || differences[0].Path.String() != "/backends/local/extra_params/details" {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("shape replacement differences = %+v", differences)
|
t.Fatalf("shape replacement differences = %+v", differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
@@ -202,16 +220,16 @@ func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationIns
|
|||||||
|
|
||||||
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backend := map[string]any{"endpoint": "https://example.invalid/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}
|
||||||
if extraParams != nil {
|
if extraParams != nil {
|
||||||
backend["extra_params"] = extraParams
|
backend["extra_params"] = extraParams
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"offline": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local",
|
"id": "offline", "backend": "local",
|
||||||
"model": model,
|
"model": model,
|
||||||
}},
|
}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}})
|
}})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigurationInspection is a read-only view of one Engine's captured
|
// ConfigurationInspection is a read-only view of one Engine's captured
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,34 +12,37 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{
|
||||||
"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"},
|
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
||||||
}},
|
}},
|
||||||
"collections": map[string]any{"literal": map[string]any{
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{
|
||||||
"endpoint": "https://example.invalid/v1",
|
map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "namespaced-private-sentinel"}},
|
||||||
"extra_params": map[string]any{"token": "namespaced-private-sentinel"},
|
map[string]any{"id": "child", "extends": map[string]any{"id": "local"}},
|
||||||
}},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
map[string]any{"id": "base.v1", "backend": "local", "model": "default-model"},
|
||||||
"id": map[string]any{"extends": "base.v1"},
|
map[string]any{"id": "id", "extends": map[string]any{"id": "base.v1"}},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
||||||
"collections": map[string]any{"literal": map[string]any{"profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}}},
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "replacement-private-sentinel"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "base.v1", "backend": "local", "model": "operator-model"}},
|
||||||
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct layered engine: %v", err)
|
t.Fatalf("construct layered engine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -46,29 +50,43 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("inspect configuration: %v", err)
|
t.Fatalf("inspect configuration: %v", err)
|
||||||
}
|
}
|
||||||
modelPath := configkit.Keys("collections", "literal", "profiles", "base.v1", "model")
|
modelPath := configkit.Keys("collections", "literal", "profiles").Index(0).Key("model")
|
||||||
model, found := view.Explain(modelPath)
|
model, found := view.Explain(modelPath)
|
||||||
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 {
|
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) == 0 {
|
||||||
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
||||||
}
|
}
|
||||||
inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model")
|
inheritedPath := configkit.Keys("collections", "literal", "profiles").Index(1).Key("model")
|
||||||
inherited, found := view.Explain(inheritedPath)
|
inherited, found := view.Explain(inheritedPath)
|
||||||
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
||||||
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
||||||
}
|
}
|
||||||
if inheritedPath.String() != "/collections/literal/profiles/id/model" {
|
if inheritedPath.String() != "/collections/literal/profiles/1/model" {
|
||||||
t.Fatalf("literal-key path = %q", inheritedPath)
|
t.Fatalf("literal-key path = %q", inheritedPath)
|
||||||
}
|
}
|
||||||
secretPath := configkit.Keys("backends", "local", "extra_params", "token")
|
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)
|
secret, found := view.Explain(secretPath)
|
||||||
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
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)
|
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
||||||
}
|
}
|
||||||
namespacedSecretPath := configkit.Keys("collections", "literal", "backends", "local", "extra_params", "token")
|
namespacedSecretPath := configkit.Keys("collections", "literal", "backends").Index(0).Key("extra_params").Key("token")
|
||||||
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
||||||
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
||||||
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
||||||
}
|
}
|
||||||
|
inheritedSecretPath := configkit.Keys("collections", "literal", "backends").Index(1).Key("extra_params").Key("token")
|
||||||
|
inheritedSecret, found := view.Explain(inheritedSecretPath)
|
||||||
|
if !found || !inheritedSecret.Effective.Redacted || strings.Contains(inheritedSecret.Effective.YAML, "replacement-private-sentinel") {
|
||||||
|
t.Fatalf("inherited provider value was not redacted: (%+v, %t)", inheritedSecret, found)
|
||||||
|
}
|
||||||
|
metadata := fmt.Sprintf("%+v", view.Metadata().History(inheritedSecretPath))
|
||||||
|
if strings.Contains(metadata, "replacement-private-sentinel") || strings.Contains(metadata, "namespaced-private-sentinel") {
|
||||||
|
t.Fatal("metadata disclosed a sensitive provider value")
|
||||||
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
if err := view.WriteEffective(&effective); err != nil {
|
if err := view.WriteEffective(&effective); err != nil {
|
||||||
t.Fatalf("write effective configuration: %v", err)
|
t.Fatalf("write effective configuration: %v", err)
|
||||||
@@ -104,9 +122,26 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationInspectionFailureDoesNotDiscloseSensitiveValues(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
|
Source: configkit.Mapping("invalid backend", map[string]any{
|
||||||
|
"collections": map[string]any{"private": map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "backend", "endpoint": "invalid endpoint", "extra_params": map[string]any{"token": "failure-private-sentinel"}},
|
||||||
|
}}},
|
||||||
|
}),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid backend error = %v, want load error", err)
|
||||||
|
}
|
||||||
|
if text := fmt.Sprintf("%v %+v", err, loadErr.Diagnostics()); strings.Contains(text, "failure-private-sentinel") {
|
||||||
|
t.Fatalf("failure diagnostics disclosed a sensitive value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("main", map[string]any{"version": "1"}),
|
Source: configkit.Mapping("main", map[string]any{"version": "2"}),
|
||||||
}}})
|
}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine with accepted declaration: %v", err)
|
t.Fatalf("construct engine with accepted declaration: %v", err)
|
||||||
@@ -117,7 +152,7 @@ func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T
|
|||||||
}
|
}
|
||||||
path := configkit.Keys("version")
|
path := configkit.Keys("version")
|
||||||
field, found := inspection.Explain(path)
|
field, found := inspection.Explain(path)
|
||||||
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "1") {
|
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "2") {
|
||||||
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
||||||
}
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
@@ -150,9 +185,8 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
t.Fatalf("zero explanation writer = %v", err)
|
t.Fatalf("zero explanation writer = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, model := "https://configured.example/v1", "configured-model"
|
profile := configkit.Input{Source: configkit.Mapping("inspection test profile", map[string]any{
|
||||||
profile := configkit.Input{Source: promptkit.ProfileSource("inspection test profile", map[string]promptkit.ProfileConfig{
|
"profiles": []any{map[string]any{"id": "inspection-profile", "endpoint": "https://configured.example/v1", "model": "configured-model"}},
|
||||||
"inspection-profile": {Endpoint: &endpoint, Model: &model},
|
|
||||||
})}
|
})}
|
||||||
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
||||||
@@ -169,14 +203,15 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("effective writer error = %v", err)
|
t.Fatalf("effective writer error = %v", err)
|
||||||
}
|
}
|
||||||
if err := view.WriteExplanation(writer, configkit.Keys("profiles", "inspection-profile", "model")); !errors.Is(err, writeErr) {
|
if err := view.WriteExplanation(writer, configkit.Keys("profiles").Index(0).Key("model")); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("explanation writer error = %v", err)
|
t.Fatalf("explanation writer error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
||||||
|
const promptDocument = "prompts:\n - id: captured\n default_profile: offline\n messages:\n - role: user\n content_file: body.tmpl\n output:\n format: text\n validation_mode: none\n"
|
||||||
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
||||||
@@ -184,7 +219,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("select inputs: %v", err)
|
t.Fatalf("select inputs: %v", err)
|
||||||
}
|
}
|
||||||
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
||||||
@@ -195,16 +230,16 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
client := countingLLMClient{calls: &modelCalls}
|
client := countingLLMClient{calls: &modelCalls}
|
||||||
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("offline profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": model}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine: %v", err)
|
t.Fatalf("construct engine: %v", err)
|
||||||
}
|
}
|
||||||
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("comparison profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("comparison profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &otherModel},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": otherModel}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,7 +261,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
if err != nil || len(differences) != 1 {
|
if err != nil || len(differences) != 1 {
|
||||||
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
||||||
}
|
}
|
||||||
if differences[0].Path.String() != "/profiles/offline/model" ||
|
if differences[0].Path.String() != "/profiles" ||
|
||||||
!strings.Contains(differences[0].Left.YAML, model) ||
|
!strings.Contains(differences[0].Left.YAML, model) ||
|
||||||
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
||||||
t.Fatalf("captured difference = %+v", differences[0])
|
t.Fatalf("captured difference = %+v", differences[0])
|
||||||
@@ -238,7 +273,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys("prompts", "captured", "messages").Index(0).Key("content_file")); !found {
|
if _, found := view.Explain(configkit.Keys("prompts").Index(0).Key("messages").Index(0).Key("content_file")); !found {
|
||||||
t.Fatal("captured prompt content path was not inspectable")
|
t.Fatal("captured prompt content path was not inspectable")
|
||||||
}
|
}
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectionInput mounts source at the selected collection's configuration
|
// CollectionInput mounts source at the selected collection's configuration
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceDocument struct {
|
type sourceDocument struct {
|
||||||
@@ -37,10 +37,10 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "collection.yaml")
|
mainPath := filepath.Join(directory, "collection.yaml")
|
||||||
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
||||||
if err := os.WriteFile(mainPath, []byte("configkit:\n imports:\n - path: defaults.yaml\n"), 0o600); err != nil {
|
if err := os.WriteFile(mainPath, []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(defaultsPath, []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
if err := os.WriteFile(defaultsPath, []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
||||||
@@ -50,7 +50,7 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -58,15 +58,15 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with native collection import: %v", err)
|
t.Fatalf("construct engine with native collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\n")},
|
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
|
||||||
"defaults.yaml": {Data: []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
"defaults.yaml": {Data: []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
||||||
}
|
}
|
||||||
filesystem := configkit.NewFilesystem("embedded collection", files)
|
filesystem := configkit.NewFilesystem("embedded collection", files)
|
||||||
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
||||||
@@ -77,7 +77,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -85,7 +85,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with embedded collection import: %v", err)
|
t.Fatalf("construct engine with embedded collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||||
@@ -38,18 +38,19 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := simpleConfig("hello")
|
cfg := simpleConfig("hello")
|
||||||
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
||||||
|
endpoint, model := "http://example.test/v1", "model"
|
||||||
lowerName := name
|
lowerName := name
|
||||||
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
||||||
Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{
|
Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &lowerName},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &lowerName},
|
||||||
}),
|
}),
|
||||||
}}})
|
}}})
|
||||||
env := " " + name + " "
|
env := " " + name + " "
|
||||||
if test.clear {
|
if test.clear {
|
||||||
env = ""
|
env = ""
|
||||||
}
|
}
|
||||||
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{
|
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &env},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &env},
|
||||||
})}
|
})}
|
||||||
var calls int
|
var calls int
|
||||||
var auth string
|
var auth string
|
||||||
@@ -73,21 +74,23 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
||||||
backendID, model := "workstation", "offline-model"
|
backendID, model := "workstation", "offline-model"
|
||||||
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
||||||
"prompts": map[string]any{"hello": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "1",
|
"id": "hello",
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}},
|
}},
|
||||||
})}
|
})}
|
||||||
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
||||||
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
|
localBackend.ID = backendID
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend,
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Backend: &backendID, Model: &model},
|
{ID: "offline", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
Main: &prompt,
|
Main: &prompt,
|
||||||
@@ -113,13 +116,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
|
|||||||
model := "configured-model"
|
model := "configured-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
|
||||||
openRouterID: {Endpoint: &openRouterEndpoint},
|
{ID: openRouterID, Endpoint: &openRouterEndpoint},
|
||||||
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
|
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
|
||||||
"openrouter-profile": {Backend: &openRouterID, Model: &model},
|
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
|
||||||
"rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model},
|
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -141,12 +144,11 @@ func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *test
|
|||||||
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
||||||
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
profiles:
|
profiles:
|
||||||
application:
|
- id: application
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
model: default-model
|
model: default-model
|
||||||
prompts:
|
prompts:
|
||||||
hello:
|
- id: hello
|
||||||
version: "1"
|
|
||||||
default_profile: application
|
default_profile: application
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -160,16 +162,16 @@ prompts:
|
|||||||
t.Fatalf("select embedded defaults: %v", err)
|
t.Fatalf("select embedded defaults: %v", err)
|
||||||
}
|
}
|
||||||
operatorDirectory := t.TempDir()
|
operatorDirectory := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n application:\n model: operator-model\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n - id: application\n endpoint: https://example.invalid/v1\n model: operator-model\n"), 0o644); err != nil {
|
||||||
t.Fatalf("write operator input: %v", err)
|
t.Fatalf("write operator input: %v", err)
|
||||||
}
|
}
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("select operator inputs: %v", err)
|
t.Fatalf("select operator inputs: %v", err)
|
||||||
}
|
}
|
||||||
programmaticModel := "programmatic-model"
|
programmaticModel, programmaticEndpoint := "programmatic-model", "https://example.invalid/v1"
|
||||||
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{
|
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{
|
||||||
"application": {Model: &programmaticModel},
|
{ID: "application", Endpoint: &programmaticEndpoint, Model: &programmaticModel},
|
||||||
})}
|
})}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -205,7 +207,7 @@ prompts:
|
|||||||
|
|
||||||
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("invalid", map[string]any{"profiles": map[string]any{"broken": map[string]any{"unknown": true}}}),
|
Source: configkit.Mapping("invalid", map[string]any{"profiles": []any{map[string]any{"id": "broken", "unknown": true}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadErr *configkit.LoadError
|
var loadErr *configkit.LoadError
|
||||||
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
||||||
@@ -225,11 +227,11 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
|
|||||||
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
|
||||||
backendID: {Endpoint: &endpoint},
|
{ID: backendID, Endpoint: &endpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
|
||||||
"application-profile": {Backend: &backendID, Model: &model},
|
{ID: "application-profile", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -251,8 +253,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
|||||||
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
||||||
authenticationKind := " OPENROUTER_KEY "
|
authenticationKind := " OPENROUTER_KEY "
|
||||||
authenticationEndpoint := "https://probe.example/key?scope=current"
|
authenticationEndpoint := "https://probe.example/key?scope=current"
|
||||||
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
|
source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
|
||||||
"local": {
|
{
|
||||||
|
ID: "local",
|
||||||
Endpoint: &backendEndpoint,
|
Endpoint: &backendEndpoint,
|
||||||
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
||||||
DefaultProfile: &defaultProfile,
|
DefaultProfile: &defaultProfile,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeLLMClient struct {
|
type fakeLLMClient struct {
|
||||||
@@ -60,11 +60,10 @@ func newSimpleEngine(t *testing.T, content string, options ...promptkit.Option)
|
|||||||
func simpleConfig(content string) promptkit.Config {
|
func simpleConfig(content string) promptkit.Config {
|
||||||
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
||||||
"profiles": map[string]any{"profile": map[string]any{
|
"profiles": []any{map[string]any{"id": "profile",
|
||||||
"endpoint": "http://example.test/v1", "model": "model",
|
"endpoint": "http://example.test/v1", "model": "model",
|
||||||
}},
|
}},
|
||||||
"prompts": map[string]any{"prompt": map[string]any{
|
"prompts": []any{map[string]any{"id": "prompt",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
@@ -72,7 +72,6 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
|||||||
}
|
}
|
||||||
return &PreparedRun{
|
return &PreparedRun{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: copyStringPtr(prepared.PromptVersion),
|
|
||||||
PromptHash: prepared.PromptHash,
|
PromptHash: prepared.PromptHash,
|
||||||
SelectedProfileID: prepared.SelectedProfileID,
|
SelectedProfileID: prepared.SelectedProfileID,
|
||||||
SelectedBackendID: prepared.SelectedBackendID,
|
SelectedBackendID: prepared.SelectedBackendID,
|
||||||
@@ -99,7 +98,6 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
|||||||
RawOutput: result.RawOutput,
|
RawOutput: result.RawOutput,
|
||||||
Validation: fromDomainValidationResult(result.Validation),
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
PromptID: result.PromptID,
|
PromptID: result.PromptID,
|
||||||
PromptVersion: copyStringPtr(result.PromptVersion),
|
|
||||||
PromptHash: result.PromptHash,
|
PromptHash: result.PromptHash,
|
||||||
SessionID: result.SessionID,
|
SessionID: result.SessionID,
|
||||||
RenderedPromptHash: result.RenderedPromptHash,
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
@@ -257,7 +255,6 @@ func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInsp
|
|||||||
}
|
}
|
||||||
return &PromptInspection{
|
return &PromptInspection{
|
||||||
PromptID: inspection.PromptID,
|
PromptID: inspection.PromptID,
|
||||||
PromptVersion: copyStringPtr(inspection.PromptVersion),
|
|
||||||
PromptHash: inspection.PromptHash,
|
PromptHash: inspection.PromptHash,
|
||||||
DefaultProfileID: inspection.DefaultProfileID,
|
DefaultProfileID: inspection.DefaultProfileID,
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToDomainAppendedMessages(t *testing.T) {
|
func TestToDomainAppendedMessages(t *testing.T) {
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
# Configuration And API Migration
|
# Configuration And API Migration
|
||||||
|
|
||||||
Promptkit no longer installs a provider catalog or selects prompt definitions
|
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
|
||||||
by descriptive version labels. Applications now choose configuration inputs
|
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
|
||||||
explicitly, and execution selects one prompt by its exact ID. This guide
|
and removes prompt-version labels.
|
||||||
|
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
|
explains the shape changes and decisions an application must make; it is not an
|
||||||
automatic migration tool. The [framework format reference](../formats.md) owns
|
automatic migration tool. The [framework format reference](../formats.md) owns
|
||||||
the current configuration contract, and the root package GoDoc owns exact API
|
the current configuration contract, and the root package GoDoc owns exact API
|
||||||
signatures and errors.
|
signatures and errors.
|
||||||
|
|
||||||
|
## Update Go Imports
|
||||||
|
|
||||||
|
Promptkit's module and public package import path is now
|
||||||
|
`go.maximumdirect.net/promptkit`, replacing
|
||||||
|
`gitea.maximumdirect.net/eric/promptkit`. Update application, library, test, and
|
||||||
|
generated imports, then select a release using the new declaration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@VERSION
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `VERSION` with a release that declares the vanity module path.
|
||||||
|
Earlier tags, including v0.16.0, retain their original module declaration and
|
||||||
|
use the former import path. No permanent `replace` directive is needed for the
|
||||||
|
cutover. Source browser, licensing, and hosted-release links continue to use
|
||||||
|
the Forge repository URLs, independently of Go's module identifier.
|
||||||
|
|
||||||
|
The package name remains `promptkit`. The path change itself does not rename
|
||||||
|
exported APIs or change their behavior, but Go treats packages imported through
|
||||||
|
the two paths as distinct. Update dependent packages that exchange Promptkit
|
||||||
|
types together and review any old-path dependencies retained after tidying.
|
||||||
|
The configuration and API adaptations below are also part of this migration.
|
||||||
|
|
||||||
## Select And Compose The Configuration You Need
|
## Select And Compose The Configuration You Need
|
||||||
|
|
||||||
Supply each backend, profile, prompt, schema, and collection source that the
|
Supply each backend, profile, prompt, schema, and collection source that the
|
||||||
@@ -30,21 +56,19 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Collection resource keys are local keys, while their runtime identities are
|
Collection resource IDs are local IDs, while their runtime identities are
|
||||||
qualified. For example, `collections.openrouter.profiles.gemini-flash-latest`
|
qualified. For example, a profile with `id: gemini-flash-latest` in collection
|
||||||
is selected as `openrouter/gemini-flash-latest`. Stored backend, prompt-default,
|
`openrouter` is selected as `openrouter/gemini-flash-latest`. Stored backend,
|
||||||
and other resource references must use their exact intended IDs; Promptkit does
|
prompt-default, and other resource references must use their exact intended IDs;
|
||||||
not rewrite references when a source is mounted. Profile `extends` remains a
|
Promptkit does not rewrite references when a source is mounted. `extends`
|
||||||
sibling local key in the same collection, not a cross-collection reference.
|
selects a sibling ID in the same list, not a cross-collection reference.
|
||||||
Review collisions and all references when moving resources into collections.
|
Review collisions and all references when moving resources into collections.
|
||||||
|
|
||||||
The root `version` is an optional ConfigKit source header. A collection's
|
The optional source-root `version` header now accepts `2` or `"2"`; headerless
|
||||||
`release_version` and a prompt's `version` are optional descriptive labels;
|
sources remain valid. A collection's `release_version` remains an optional
|
||||||
neither selects a resource or changes execution. Preserve a label only when it
|
label. Prompt-version labels have been removed: choose one definition per ID
|
||||||
is useful to your application. A formerly version-keyed prompt mapping must be
|
and store any application-specific release label outside Promptkit. In a
|
||||||
reduced to one deliberately chosen definition per prompt ID, with its old label
|
standalone imported prompt item, root `version` is a source header, not a label.
|
||||||
retained as prompt metadata if desired. Promptkit cannot choose a winner from a
|
|
||||||
consumer's version history.
|
|
||||||
|
|
||||||
For example, change a version-keyed definition:
|
For example, change a version-keyed definition:
|
||||||
|
|
||||||
@@ -60,12 +84,11 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
to one direct definition. Its optional `version` is only a label:
|
to one identified definition:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
report:
|
- id: report
|
||||||
version: "2.0"
|
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
content: Review this report.
|
content: Review this report.
|
||||||
@@ -74,6 +97,51 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The same conversion applies to backends and profiles. Sibling inheritance uses
|
||||||
|
identity references, and a later same-ID record replaces the whole lower record:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
- id: base
|
||||||
|
endpoint: https://example.invalid/v1
|
||||||
|
model: example-model
|
||||||
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
|
timeout_seconds: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a lower resource's fields, add a distinct inherited variant as
|
||||||
|
above; when replacing the same ID, supply the complete intended record. Generic
|
||||||
|
append, replace, imports, and inheritance are described by the
|
||||||
|
[ConfigKit v1 configuration contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
|
||||||
|
Source-root controls are now `config:`, not `configkit:`. Imports are optional
|
||||||
|
unless the selected import declares `required: true`.
|
||||||
|
|
||||||
|
Update ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0: its Go
|
||||||
|
types are incompatible with the old module path. Promptkit now requires Go
|
||||||
|
1.27.0 or newer. Source builders take ordered slices with explicit IDs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
endpoint, model := "https://example.invalid/v1", "example-model"
|
||||||
|
base := "base"
|
||||||
|
source := promptkit.ProfileSource("application", []promptkit.ProfileConfig{
|
||||||
|
{ID: "base", Endpoint: &endpoint, Model: &model},
|
||||||
|
{ID: "fast", Extends: &base},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
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 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
|
||||||
|
change when the removed field no longer participates in hashing.
|
||||||
|
|
||||||
## Identity Mapping
|
## Identity Mapping
|
||||||
|
|
||||||
The reviewed catalog identities have slash-free profile keys. Their documented
|
The reviewed catalog identities have slash-free profile keys. Their documented
|
||||||
@@ -100,8 +168,8 @@ Gemini profile in the same `openrouter` collection:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
standard:
|
- id: standard
|
||||||
extends: gemini-flash-latest
|
extends: {id: gemini-flash-latest}
|
||||||
```
|
```
|
||||||
|
|
||||||
An optional auto profile may instead select the exact backend and model values:
|
An optional auto profile may instead select the exact backend and model values:
|
||||||
@@ -110,7 +178,7 @@ An optional auto profile may instead select the exact backend and model values:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
auto:
|
- id: auto
|
||||||
backend: openrouter/default
|
backend: openrouter/default
|
||||||
model: openrouter/auto
|
model: openrouter/auto
|
||||||
```
|
```
|
||||||
@@ -142,10 +210,8 @@ if err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
If the former format contained multiple versions under one ID, choose the one
|
If the former format contained multiple versions under one ID, choose the one
|
||||||
definition the application should install, and optionally keep its label in
|
definition the application should install. Promptkit no longer retains or
|
||||||
the prompt's `version` field. That label remains descriptive metadata and is
|
returns its former descriptive label.
|
||||||
reported by inspection or results where applicable; it never chooses the
|
|
||||||
definition.
|
|
||||||
|
|
||||||
The public inventory APIs can help an operator inspect a captured effective
|
The public inventory APIs can help an operator inspect a captured effective
|
||||||
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ file contracts.
|
|||||||
Import the package as:
|
Import the package as:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "gitea.maximumdirect.net/eric/promptkit"
|
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.1.0. Its types are
|
||||||
|
not interchangeable with the old ConfigKit module path.
|
||||||
|
|
||||||
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
demonstrates explicit collection composition and inventory. Applications
|
demonstrates explicit collection composition and inventory. Applications
|
||||||
adapting older provider or versioned-prompt configuration can use the
|
adapting older provider or versioned-prompt configuration can use the
|
||||||
@@ -28,7 +34,8 @@ programs.
|
|||||||
## Go Compatibility
|
## Go Compatibility
|
||||||
|
|
||||||
The `go` directive in the selected release's [module metadata](../../go.mod)
|
The `go` directive in the selected release's [module metadata](../../go.mod)
|
||||||
declares its minimum supported consumer Go version. Maintainers build and
|
declares its minimum supported consumer Go version, currently Go 1.27.0.
|
||||||
|
Maintainers build and
|
||||||
validate releases with the locally selected Go patch release; the minimum
|
validate releases with the locally selected Go patch release; the minimum
|
||||||
does not simply track that patch version.
|
does not simply track that patch version.
|
||||||
|
|
||||||
@@ -45,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
|
explicitly and place its inputs in an ordered ConfigKit underlay. A group
|
||||||
strictly composes its files; later groups can overlay earlier ones:
|
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
|
```go
|
||||||
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
@@ -108,8 +141,8 @@ defaults, err := promptkit.FilesystemInputs("embedded defaults", applicationFS,
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
profile := promptkit.ProfileSource("application choices", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("application choices", []promptkit.ProfileConfig{
|
||||||
"summary": {Model: &selectedModel},
|
{ID: "summary", Endpoint: &selectedEndpoint, Model: &selectedModel},
|
||||||
})
|
})
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
@@ -120,7 +153,9 @@ engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.Configura
|
|||||||
}})
|
}})
|
||||||
```
|
```
|
||||||
|
|
||||||
Only add the operator group when the application selects that directory; an
|
Each later same-ID resource replaces the complete earlier record; use a distinct
|
||||||
|
inherited sibling for field-level customization. Only add the operator group
|
||||||
|
when the application selects that directory; an
|
||||||
explicitly selected missing or unreadable root is an error. Each helper finds
|
explicitly selected missing or unreadable root is an error. Each helper finds
|
||||||
`.yaml` and `.yml` files recursively and disables imports by default. The
|
`.yaml` and `.yml` files recursively and disables imports by default. The
|
||||||
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
||||||
@@ -131,8 +166,8 @@ labeled mapping as an input. A mapping owns a snapshot of supported values:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
document := configkit.Mapping("application configuration", map[string]any{
|
document := configkit.Mapping("application configuration", map[string]any{
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"local-summary": map[string]any{
|
map[string]any{"id": "local-summary",
|
||||||
"backend": "local", "model": "example-model",
|
"backend": "local", "model": "example-model",
|
||||||
"temperature": nil, // Suppress an inherited provider control.
|
"temperature": nil, // Suppress an inherited provider control.
|
||||||
},
|
},
|
||||||
@@ -202,14 +237,15 @@ sensitive application data.
|
|||||||
## Inspect The Captured Configuration
|
## Inspect The Captured Configuration
|
||||||
|
|
||||||
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
||||||
operator-selected report of effective configuration, source descriptions,
|
operator-selected report of resolved configuration, source descriptions,
|
||||||
diagnostics, and path history. For a literal ID containing a slash, construct
|
diagnostics, and path history. Configuration inspection uses list indexes in
|
||||||
the semantic path with `configkit.Keys`:
|
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`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
path := configkit.Keys("profiles", "team/summary", "model")
|
path := configkit.Keys("collections", "team", "profiles").Index(i).Key("model")
|
||||||
explanation, found := view.Explain(path)
|
explanation, found := view.Explain(path)
|
||||||
if found && explanation.Effective.Redacted {
|
if found && explanation.Effective.Redacted {
|
||||||
// Apply the application's disclosure policy before sharing the report.
|
// Apply the application's disclosure policy before sharing the report.
|
||||||
@@ -220,8 +256,10 @@ if found && explanation.Effective.Redacted {
|
|||||||
the application decides whether those reports may be disclosed. Credential-
|
the application decides whether those reports may be disclosed. Credential-
|
||||||
shaped provider keys are redacted, but arbitrary prompt and provider content
|
shaped provider keys are redacted, but arbitrary prompt and provider content
|
||||||
is not necessarily safe to share. Inspection uses the engine's captured result
|
is not necessarily safe to share. Inspection uses the engine's captured result
|
||||||
and does not reopen sources or resolve credentials. `InspectProfile` instead
|
and does not reopen sources or resolve credentials. It retains effective values,
|
||||||
shows backend-completed execution settings for one profile, and `Prepare` or
|
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,
|
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
||||||
and validation resources. None of those views is a substitute for the others.
|
and validation resources. None of those views is a substitute for the others.
|
||||||
|
|
||||||
@@ -252,11 +290,13 @@ for _, difference := range differences {
|
|||||||
```
|
```
|
||||||
|
|
||||||
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
||||||
and ConfigKit's [v0.8.0 effective-configuration diff guidance](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.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
|
for the exact comparison contract. The operation compares only frozen effective
|
||||||
configuration values: it does not reread external files or compare their
|
configuration values: it does not reread external files or compare their
|
||||||
contents, resolved credential contents, later execution preparation, or
|
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
|
Records can contain ordinary application data, so use the application's
|
||||||
disclosure policy when presenting them.
|
disclosure policy when presenting them.
|
||||||
|
|
||||||
@@ -620,7 +660,7 @@ copy, and credential behavior. The
|
|||||||
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
||||||
framework defaults.
|
framework defaults.
|
||||||
|
|
||||||
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with keyed
|
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with identified
|
||||||
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
||||||
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
||||||
|
|
||||||
@@ -633,13 +673,13 @@ and `WithBackend` options have no compatibility wrappers. Use
|
|||||||
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
||||||
ConfigKit file inputs for individual files, and `ProfileSource`,
|
ConfigKit file inputs for individual files, and `ProfileSource`,
|
||||||
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
||||||
are keys in configuration, not registrations. Choose caller underlays in
|
are fields in configuration, not registrations. Choose caller underlays in
|
||||||
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
||||||
highest-priority input.
|
highest-priority input.
|
||||||
|
|
||||||
Move embedded IDs into keyed `profiles` and `prompts` maps; each prompt ID now
|
Move resource IDs into identified `backends`, `profiles`, and `prompts` lists;
|
||||||
contains one installed definition, with any descriptive `version` label inside
|
each prompt ID identifies one installed definition without a version label.
|
||||||
that definition. Convert `base_profile` to ConfigKit `extends`. For the current
|
Convert `base_profile` to ConfigKit `extends: {id: parent}`. For the current
|
||||||
collection identity mapping, ID-only prompt selection, and required
|
collection identity mapping, ID-only prompt selection, and required
|
||||||
caller-owned choices, see the
|
caller-owned choices, see the
|
||||||
[configuration migration guide](configuration-migration.md). Exact current
|
[configuration migration guide](configuration-migration.md). Exact current
|
||||||
@@ -651,11 +691,11 @@ the engine to pick up changed definitions or message files.
|
|||||||
### Choose A Profile Ownership Model
|
### Choose A Profile Ownership Model
|
||||||
|
|
||||||
Supply backend and profile definitions through the configuration sources your
|
Supply backend and profile definitions through the configuration sources your
|
||||||
application selects. PromptKit does not install provider catalogs or built-in
|
application selects, alongside the default-on bundled OpenRouter inventory.
|
||||||
profile inventories; conventional backend ID constants are names only. Define
|
Conventional backend ID constants are names only. Define or replace the model,
|
||||||
the model, endpoint, credentials lookup, and settings that your application
|
endpoint, credentials lookup, and settings that your application
|
||||||
intends to support. A profile may inherit a sibling profile by its local key in
|
intends to support. A profile may inherit a sibling profile by its local key in
|
||||||
the same root or collection mapping; its backend reference uses the exact
|
the same root or collection list; its backend reference uses the exact
|
||||||
configured identity. Caller-selected source layers determine the final values. The
|
configured identity. Caller-selected source layers determine the final values. The
|
||||||
[format reference](../formats.md) describes the definition fields and
|
[format reference](../formats.md) describes the definition fields and
|
||||||
composition rules.
|
composition rules.
|
||||||
@@ -728,8 +768,8 @@ it and shared backend identity or capacity policy is unnecessary:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, model := "http://localhost:8000/v1", "example-model"
|
endpoint, model := "http://localhost:8000/v1", "example-model"
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Endpoint: &endpoint, Model: &model},
|
{ID: "local-summary", Endpoint: &endpoint, Model: &model},
|
||||||
})
|
})
|
||||||
// Add configkit.Input{Source: profile} to a caller underlay.
|
// Add configkit.Input{Source: profile} to a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -744,11 +784,13 @@ identity, endpoint, and concurrency limit:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
backendID, model := promptkit.BackendLocal, "example-model"
|
backendID, model := promptkit.BackendLocal, "example-model"
|
||||||
backend := promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend.ID = backendID
|
||||||
|
backend := promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
|
localBackend,
|
||||||
})
|
})
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Backend: &backendID, Model: &model},
|
{ID: "local-summary", Backend: &backendID, Model: &model},
|
||||||
})
|
})
|
||||||
// Add both sources as inputs in a caller underlay.
|
// Add both sources as inputs in a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -759,13 +801,14 @@ capacity of 1024.
|
|||||||
|
|
||||||
#### Configure A Complete Backend
|
#### Configure A Complete Backend
|
||||||
|
|
||||||
Use a keyed `BackendConfig` value for authentication, extra request parameters, an
|
Use an identified `BackendConfig` value for authentication, extra request parameters, an
|
||||||
explicit queue capacity, a custom ID, or multiple local endpoints:
|
explicit queue capacity, a custom ID, or multiple local endpoints:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
||||||
backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendConfig{
|
backend := promptkit.BackendSource("GPU backend", []promptkit.BackendConfig{
|
||||||
"local-gpu": {
|
{
|
||||||
|
ID: "local-gpu",
|
||||||
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
||||||
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
||||||
ExtraParams: map[string]any{"provider_option": "enabled"},
|
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||||
@@ -774,7 +817,7 @@ backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendCo
|
|||||||
```
|
```
|
||||||
|
|
||||||
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
||||||
`ProfileConfig.Backend` pointer or a keyed file profile. All backend
|
`ProfileConfig.Backend` pointer or an identified file profile. All backend
|
||||||
contributions use the same ConfigKit layering rules, including overlays of
|
contributions use the same ConfigKit layering rules, including overlays of
|
||||||
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
||||||
defines exact omission and construction behavior.
|
defines exact omission and construction behavior.
|
||||||
@@ -804,7 +847,7 @@ backend := promptkit.BackendConfig{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The pointer distinguishes an explicit zero from omission. Use keyed
|
The pointer distinguishes an explicit zero from omission. Use identified
|
||||||
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
||||||
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
||||||
profiles and custom backends without a configured limit remain unrestricted.
|
profiles and custom backends without a configured limit remain unrestricted.
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ the maintained examples when they are affected:
|
|||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -w path/to/changed.go
|
gofmt -w path/to/changed.go
|
||||||
python3 scripts/check-examples.py
|
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,12 +76,12 @@ describes its requirements and recovery. For an optional diagnostic of a
|
|||||||
committed candidate without publication, run `./scripts/releasekit.sh check
|
committed candidate without publication, run `./scripts/releasekit.sh check
|
||||||
VERSION`; this is not a required pre-publication step.
|
VERSION`; this is not a required pre-publication step.
|
||||||
|
|
||||||
The standalone `python3 scripts/check-examples.py` command checks semantic
|
The example workflows are covered by the Go suite; use
|
||||||
expectations for the three offline consumer examples. The focused
|
`go test ./examples/go-library/...` to run them separately during development.
|
||||||
`python3 scripts/test-release.py` command exercises the release tooling against
|
The focused `python3 scripts/test-release.py` command exercises the release
|
||||||
disposable local repositories; use it when changing that tooling, not as a
|
tooling against disposable local repositories; use it when changing that
|
||||||
recurring release gate. Tests and examples require no provider credentials or
|
tooling, not as a recurring release gate. Tests and examples require no provider
|
||||||
live inference calls.
|
credentials or live inference calls.
|
||||||
|
|
||||||
Ordinary validation can run on intended uncommitted work. Release validation
|
Ordinary validation can run on intended uncommitted work. Release validation
|
||||||
uses committed candidate contents and the selected local Go installation.
|
uses committed candidate contents and the selected local Go installation.
|
||||||
|
|||||||
154
docs/formats.md
154
docs/formats.md
@@ -6,31 +6,28 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
|
|||||||
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
||||||
how to select sources and use an engine; the
|
how to select sources and use an engine; the
|
||||||
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
owns outbound wire behavior. [ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit)
|
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.
|
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||||
This reference describes what Promptkit does with the final effective values.
|
This reference describes what Promptkit does with the final effective values.
|
||||||
|
|
||||||
A configuration document may contain keyed top-level `backends`, `profiles`,
|
A configuration document may contain top-level `backends`, `profiles`, and
|
||||||
and `prompts` mappings, plus a `collections` mapping of namespaced resource
|
`prompts` lists, plus a `collections` mapping of namespaced resource groups.
|
||||||
groups. Resource keys are literal IDs, not filename-derived identities. Each
|
Each resource has an explicit literal `id`, not a filename-derived identity.
|
||||||
prompt ID maps directly to one definition; an optional `version` string is
|
IDs and references must be nonblank and remain exact: Promptkit does not trim or
|
||||||
descriptive metadata, not a selection key. Keys and references must be nonblank
|
re-key them. Unknown fields are errors. Promptkit installs a default-on bundled
|
||||||
and remain exact: Promptkit does not trim or re-key them. Old embedded `id` and
|
OpenRouter underlay; callers still explicitly select a profile.
|
||||||
`base_profile` fields are invalid. Unknown fields are errors. Promptkit installs
|
|
||||||
no provider or profile catalog; callers select the definitions they need.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
local:
|
- id: local
|
||||||
endpoint: http://localhost:8000/v1
|
endpoint: http://localhost:8000/v1
|
||||||
concurrency_limit: 2
|
concurrency_limit: 2
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -42,17 +39,17 @@ collections:
|
|||||||
support:
|
support:
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
prompts:
|
prompts:
|
||||||
answer:
|
- id: answer
|
||||||
default_profile: support/fast
|
default_profile: support/fast
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -66,31 +63,34 @@ collections:
|
|||||||
|
|
||||||
An input may omit a source header. When present, the source-root `version` is
|
An input may omit a source header. When present, the source-root `version` is
|
||||||
reserved for ConfigKit's specification header and accepts only the exact string
|
reserved for ConfigKit's specification header and accepts only the exact string
|
||||||
`"1"` or strict decimal integer `1`; null and other values are invalid. This
|
`"2"` or strict decimal integer `2`; null and other values are invalid. This
|
||||||
header is distinct from a prompt's nested `version` label and a collection's
|
header is distinct from a collection's `release_version` and ConfigKit's own
|
||||||
`release_version`. ConfigKit checks and consumes accepted headers on non-main
|
software version. ConfigKit checks and consumes accepted headers on non-main
|
||||||
inputs; a declaration on the main input remains visible in explicit
|
inputs; a declaration on the main input remains visible in explicit
|
||||||
configuration inspection. Headerless inputs still undergo ordinary final-value
|
configuration inspection. Headerless inputs still undergo ordinary final-value
|
||||||
validation. ConfigKit owns generic import and source-history behavior.
|
validation. ConfigKit owns generic import and source-history behavior.
|
||||||
|
For a standalone prompt item, a root `version` is only a source header, not
|
||||||
|
prompt metadata.
|
||||||
|
|
||||||
The root resource mappings contain unqualified IDs. Each `collections` entry
|
The root resource lists contain unqualified IDs. Each `collections` entry
|
||||||
has a namespace key and may contain `release_version` plus the same three
|
has a namespace key and may contain `release_version` plus the same three
|
||||||
resource mappings. Collection resource keys are local keys; their runtime IDs
|
resource lists. Collection resource IDs are local keys; their runtime IDs
|
||||||
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
||||||
namespace and local key must be nonblank and slash-free, and references must
|
namespace and local key must be nonblank and slash-free, and references must
|
||||||
name their intended exact ID; mounting a source does not rewrite references.
|
name their intended exact ID; mounting a source does not rewrite references.
|
||||||
For example, the `support/fast` profile above stores `support/offline` as its
|
For example, the `support/fast` profile above stores `support/offline` as its
|
||||||
backend ID. In `collections.support.profiles.fast`, ConfigKit `extends: base`
|
backend ID. `extends: {id: base}` refers to a sibling in the same resource
|
||||||
refers to a sibling profile in that same mapping. It is not a qualified or
|
list. It is not a qualified or cross-collection reference.
|
||||||
cross-collection reference.
|
|
||||||
|
|
||||||
`release_version` and prompt `version` are optional opaque labels. Absent or
|
`release_version` is an optional opaque collection label. Absent or null means
|
||||||
null means no label; an explicitly empty string is retained as a declared
|
no label; an explicitly empty string is retained. It does not select a resource
|
||||||
label. Neither label selects a resource or changes execution. Runtime lookups
|
or change execution. Runtime lookups
|
||||||
use the captured effective resource indexes. `InspectConfiguration` is an
|
use the captured effective resource indexes. `InspectConfiguration` is an
|
||||||
explicit operator view of effective settings and provenance; source history is
|
explicit operator view of resolved settings and provenance. Selected field
|
||||||
not a second runtime resolver, and the retained effective shape is not flattened
|
normalizations and the derived queue default are recorded in that tree, with
|
||||||
or rewritten into those indexes.
|
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
|
For examples of explicitly selecting native collection inputs and inspecting
|
||||||
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
||||||
@@ -116,12 +116,11 @@ see the consumer guide's [resource and collection listing](consumers/pkg-promptk
|
|||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
The `prompts` mapping selects a literal prompt ID whose value is the definition:
|
The `prompts` list contains definitions selected by their literal IDs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
description: Summarize a synthetic meeting note.
|
description: Summarize a synthetic meeting note.
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
@@ -237,7 +236,8 @@ It may contain `default_profile`, `reachability`, and `authentication`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
openrouter:
|
- id: openrouter
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
smoke_tests:
|
smoke_tests:
|
||||||
default_profile: application-profile
|
default_profile: application-profile
|
||||||
reachability:
|
reachability:
|
||||||
@@ -266,21 +266,22 @@ These values are configuration only: engine construction does not contact a
|
|||||||
backend.
|
backend.
|
||||||
|
|
||||||
These fields are resolved through the ordinary ConfigKit source layers before
|
These fields are resolved through the ordinary ConfigKit source layers before
|
||||||
Promptkit validates the final effective values. A higher-priority mapping can
|
Promptkit validates the final effective values. Sibling inheritance can overlay
|
||||||
override individual nested fields; an explicit null can clear the whole
|
individual nested fields; a later same-ID resource replaces the whole record.
|
||||||
`smoke_tests` block or one of its nested objects. An endpoint set to null uses
|
An explicit null can clear the whole `smoke_tests` block or one of its nested
|
||||||
|
objects. An endpoint set to null uses
|
||||||
its documented fallback, while an explicit blank endpoint is invalid. The
|
its documented fallback, while an explicit blank endpoint is invalid. The
|
||||||
metadata does not change ordinary generation credential requirements or
|
metadata does not change ordinary generation credential requirements or
|
||||||
provider settings.
|
provider settings.
|
||||||
|
|
||||||
## Profile Definitions
|
## Profile Definitions
|
||||||
|
|
||||||
Profiles are keyed by literal ID and may inherit a named parent with
|
Profiles have literal IDs and may inherit a sibling parent with
|
||||||
`extends`:
|
`extends`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
@@ -288,8 +289,8 @@ profiles:
|
|||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
extra_params:
|
extra_params:
|
||||||
provider_option: enabled
|
provider_option: enabled
|
||||||
local-summary-fast:
|
- id: local-summary-fast
|
||||||
extends: local-summary
|
extends: {id: local-summary}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
reasoning_effort: low
|
reasoning_effort: low
|
||||||
```
|
```
|
||||||
@@ -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
|
data, not a configuration inheritance instruction. An explicit zero timeout
|
||||||
does not disable the caller context or HTTP transport 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
|
### 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
|
optional main input. Later layers overlay earlier ones. Inputs within one
|
||||||
underlay group are strictly composed: conflicting contributions are errors,
|
underlay group are strictly composed: conflicting contributions are errors,
|
||||||
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
||||||
@@ -343,25 +393,27 @@ nothing. Imports are disabled by these helpers unless the caller enables them
|
|||||||
on an input. Single files can use ConfigKit inputs directly.
|
on an input. Single files can use ConfigKit inputs directly.
|
||||||
|
|
||||||
Root backends, profiles, and prompts, as well as collection declarations and
|
Root backends, profiles, and prompts, as well as collection declarations and
|
||||||
their resource mappings, follow these same source rules. `CollectionInput`
|
their resource lists, follow these same source rules. `CollectionInput`
|
||||||
mounts one explicitly selected source at a collection destination and enables
|
mounts one explicitly selected source at a collection destination and enables
|
||||||
ConfigKit imports for that input; it does not discover or select sources.
|
ConfigKit imports for that input; it does not discover or select sources.
|
||||||
A later profile or backend can change individual fields of a lower definition.
|
A later same-ID record replaces the lower definition in full. Use a distinct
|
||||||
|
inherited sibling when a variant should retain its parent's fields.
|
||||||
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
||||||
neither is selected, preparation fails. Exact profile inspection selects one
|
neither is selected, preparation fails. Exact profile inspection selects one
|
||||||
explicit ID and does not consult a prompt default.
|
explicit ID and does not consult a prompt default.
|
||||||
|
|
||||||
### Profile Inheritance
|
### Resource Inheritance
|
||||||
|
|
||||||
ConfigKit resolves `extends` on effective profile definitions. One profile
|
ConfigKit resolves `extends: {id: parent}` for backends, profiles, and prompts.
|
||||||
may inherit a sibling profile by local key in the same root or collection
|
Each child inherits a surviving sibling in the same root or collection list;
|
||||||
mapping; collection inheritance cannot cross namespaces. Presence, explicit
|
collection inheritance cannot cross namespaces. Parents are ordinary validated
|
||||||
|
resources, and profiles may remain partial until request completion. Presence, explicit
|
||||||
null, and recursively merged maps determine the final fields. Other resource
|
null, and recursively merged maps determine the final fields. Other resource
|
||||||
references are exact IDs and use their qualified form when they select a
|
references are exact IDs and use their qualified form when they select a
|
||||||
collection resource. There is no Promptkit-specific depth limit or second
|
collection resource. There is no Promptkit-specific depth limit or second
|
||||||
base-chain resolver. Missing references and cycles fail at construction. For
|
base-chain resolver. Missing references and cycles fail at construction. For
|
||||||
generic inheritance and layer mechanics, consult
|
generic inheritance and layer mechanics, consult
|
||||||
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit).
|
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ other content. Events are values with no mutable containers. Callback panics
|
|||||||
propagate to the caller.
|
propagate to the caller.
|
||||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||||
the invocation, including namespace qualification when supplied by an engine
|
the invocation, including namespace qualification when supplied by an engine
|
||||||
view. A prompt's optional version label is descriptive metadata, not a
|
view.
|
||||||
selection key.
|
|
||||||
Per-operation callbacks are ordered and synchronous; different operations may
|
Per-operation callbacks are ordered and synchronous; different operations may
|
||||||
invoke the same callback concurrently. The recorder marks each measured phase's
|
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||||
end before delivery, so callback work does not inflate provider duration.
|
end before delivery, so callback work does not inflate provider duration.
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ The internal engine applies each namespace view to slashless caller selections
|
|||||||
before they reach the runner. The runner therefore selects captured prompts and
|
before they reach the runner. The runner therefore selects captured prompts and
|
||||||
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
||||||
through, while configured defaults and stored references are used as authored
|
through, while configured defaults and stored references are used as authored
|
||||||
and are not requalified. Prompt version labels are returned as optional
|
and are not requalified.
|
||||||
descriptive metadata; they are not prompt selectors.
|
|
||||||
|
|
||||||
Ordinary preparation selects the full prompt definition and construction-time
|
Ordinary preparation selects the full prompt definition and construction-time
|
||||||
hash. It retains its early request-ID check before direct-session
|
hash. It retains its early request-ID check before direct-session
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ source semantics. Internal packages are not consumer APIs.
|
|||||||
`internal/configuration` is the single adapter from ConfigKit's final
|
`internal/configuration` is the single adapter from ConfigKit's final
|
||||||
effective configuration to execution-owned definitions. It performs one
|
effective configuration to execution-owned definitions. It performs one
|
||||||
layered load, captures effective presence (including explicit null), records
|
layered load, captures effective presence (including explicit null), records
|
||||||
normalization and defaults through resolution, and validates all effective
|
field-level normalization and derived defaults during resolution, and validates all effective
|
||||||
root and collection backend, profile, and prompt values. Replaced lower-layer
|
root and collection backend, profile, and prompt values. Replaced lower-layer
|
||||||
domain values are not separately validated. It indexes root resources under
|
domain values are not separately validated. It indexes root resources under
|
||||||
their unqualified IDs and collection resources under their full
|
their unqualified IDs and collection resources under their full
|
||||||
`namespace/local-key` IDs while retaining ConfigKit's effective tree in its
|
`namespace/local-key` IDs while retaining ConfigKit's resolved tree. It builds an immutable backend index and presence-aware profile
|
||||||
authored shape. It builds an immutable backend index and presence-aware profile
|
|
||||||
targets, applying final backend defaults only after profile inheritance. Runtime
|
targets, applying final backend defaults only after profile inheritance. Runtime
|
||||||
lookups use those final indexes and exact stored references; they do not rewrite
|
lookups use those final indexes and exact stored references; they do not rewrite
|
||||||
or resolve resources from ConfigKit source history. The runtime/domain packages
|
or resolve resources from ConfigKit source history. The runtime/domain packages
|
||||||
@@ -27,22 +26,22 @@ do not depend on ConfigKit.
|
|||||||
ConfigKit owns source headers, imports, composition, and source evidence.
|
ConfigKit owns source headers, imports, composition, and source evidence.
|
||||||
Promptkit does not preparse contributions: it validates the final effective
|
Promptkit does not preparse contributions: it validates the final effective
|
||||||
values once, captures the indexed identities and their original resource
|
values once, captures the indexed identities and their original resource
|
||||||
contexts, and retains ConfigKit's 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
|
Accepted non-main source headers are consumed during composition; the main
|
||||||
source declaration remains available in that inspection view. Prompt and
|
source declaration remains available in that inspection view. Collection
|
||||||
collection release labels are descriptive metadata, not selection inputs. The
|
release labels are descriptive metadata, not selection inputs. The
|
||||||
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
||||||
owns the exact source-header and collection contracts.
|
owns the exact source-header and collection contracts.
|
||||||
|
|
||||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||||
once to ConfigKit v0.8.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
|
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||||
both results; reports may still contain ordinary application data. See the
|
both results; reports may still contain ordinary application data. See the
|
||||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||||
and ConfigKit's [v0.8.0 comparison contract](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.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
|
After resource materialization and effective-profile snapshot construction, the
|
||||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
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
|
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
|
||||||
files under an explicit root, provide resource context, and leave imports
|
files under an explicit root, provide resource context, and leave imports
|
||||||
disabled. Each returned slice belongs to one strict-composition group; file
|
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
|
order, followed by the optional main input. The
|
||||||
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
||||||
real loader and final-value handoff.
|
real loader and final-value handoff.
|
||||||
@@ -85,10 +86,11 @@ returning and retains no source capability for public inspection.
|
|||||||
Prompt materialization shares a lazy metadata snapshot across root and collection
|
Prompt materialization shares a lazy metadata snapshot across root and collection
|
||||||
resources during construction, avoiding a full metadata copy per collection.
|
resources during construction, avoiding a full metadata copy per collection.
|
||||||
|
|
||||||
Role and cache-control normalization is applied to execution-owned message
|
Normalization and defaults are recorded at individual resolved field paths,
|
||||||
copies. ConfigKit v0.8.0 retains effective input message metadata and its
|
including message roles and cache controls. List indexes identify fields for
|
||||||
resource origins; recording a sequence-wide normalization in the retained
|
presence, validation, sensitivity, and resource context; full IDs select
|
||||||
configuration would replace origins of unchanged content-file fields.
|
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
|
The runner selects full definitions from this index for preparation and uses
|
||||||
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
||||||
@@ -142,7 +144,7 @@ change rendering behavior.
|
|||||||
## Profiles
|
## Profiles
|
||||||
|
|
||||||
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
||||||
`extends` names a sibling local key; it is not a cross-collection reference.
|
`extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference.
|
||||||
The configuration adapter validates final effective resource references and
|
The configuration adapter validates final effective resource references and
|
||||||
field values, then builds target
|
field values, then builds target
|
||||||
snapshots with backend defaults and presence/suppression state. A profile can
|
snapshots with backend defaults and presence/suppression state. A profile can
|
||||||
|
|||||||
@@ -26,17 +26,31 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/COPYRIGHT) |
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
[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
|
## jsonschema
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**Base Policy Version:** 1.8
|
||||||
|
|
||||||
**Last Updated**: 2026-09-27
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||||
|
|
||||||
@@ -221,7 +221,8 @@ Promptkit retains its LGPL-3.0-or-later library grant in
|
|||||||
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
||||||
scope identified there and in [attribution](../oss/attribution.md).
|
scope identified there and in [attribution](../oss/attribution.md).
|
||||||
|
|
||||||
Promptkit publishes source-only Go module tags on Gitea, with no binaries or
|
Promptkit publishes source-only Go module tags and Gitea release pages, with no
|
||||||
separate hosted download page. The [release procedure](../release.md) owns
|
binaries. Each new hosted page reuses the complete checked-in release note,
|
||||||
publication and concrete footer URLs. The hosted-page guidance applies if such
|
including its licensing footer. The [release procedure](../release.md) owns
|
||||||
a channel is deliberately introduced; it does not require creating one.
|
source publication, hosted delivery, and concrete footer URLs. Previously
|
||||||
|
published notes and tags retain their original records.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-10-08
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
@@ -241,9 +241,13 @@ navigation on 2026-09-26. Apply them prospectively; previously published notes
|
|||||||
and tags retain their original records.
|
and tags retain their original records.
|
||||||
|
|
||||||
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
||||||
annotated stable SemVer tags. It has no binaries, hosted download pages, or
|
annotated stable SemVer tags. The source commit is pushed through main before
|
||||||
asynchronous deliverables. The source commit is pushed through main before the
|
the tag. After source publication, maintainers use ReleaseKit locally to deliver
|
||||||
tag. The [release procedure](../release.md) owns commands and recovery.
|
a Gitea release page containing the complete checked-in note, including its
|
||||||
|
licensing footer. There are no binaries or other deliverables. Source publication
|
||||||
|
and hosted delivery have separate outcomes; delivery credentials and service
|
||||||
|
availability do not gate source-release completion. The
|
||||||
|
[release procedure](../release.md) owns commands and recovery.
|
||||||
|
|
||||||
Release preparation uses the latest Go installation available in the development
|
Release preparation uses the latest Go installation available in the development
|
||||||
environment to build the maintainer release executable and validate the
|
environment to build the maintainer release executable and validate the
|
||||||
@@ -267,9 +271,11 @@ The [publication command](../../scripts/release.sh) delegates to the released
|
|||||||
ReleaseKit tool selected in `go.mod`. The committed
|
ReleaseKit tool selected in `go.mod`. The committed
|
||||||
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
||||||
committed Go formatting, local Markdown links, workspace and filesystem
|
committed Go formatting, local Markdown links, workspace and filesystem
|
||||||
replacement inspection, and the offline semantic checks for the three
|
replacement inspection. The Go suite includes the offline semantic checks for
|
||||||
maintained consumer examples. These are the selected checks for the final
|
the three maintained consumer examples. It also rejects the configured local
|
||||||
candidate; a separate ordinary test run or standalone build is not an
|
delivery token path if committed; this narrowly scoped check is not a general credential
|
||||||
|
scan. These are the selected checks for the final candidate; a separate
|
||||||
|
ordinary test run or standalone build is not an
|
||||||
additional release gate. The upstream tool owns candidate, remote, and tag
|
additional release gate. The upstream tool owns candidate, remote, and tag
|
||||||
publication checks. It returns native status 0 for confirmed publication, 1
|
publication checks. It returns native status 0 for confirmed publication, 1
|
||||||
for a known validation or publication failure, 2 for invalid input or profile,
|
for a known validation or publication failure, 2 for invalid input or profile,
|
||||||
@@ -278,3 +284,9 @@ captured publication endpoint before retrying; the repository-specific
|
|||||||
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
||||||
instructions. Do not wait for CI or Go module availability. Licensing footer
|
instructions. Do not wait for CI or Go module availability. Licensing footer
|
||||||
URLs are preparation work, not a network gate.
|
URLs are preparation work, not a network gate.
|
||||||
|
|
||||||
|
Hosted delivery is a separate post-publication step for new releases. It reuses
|
||||||
|
the immutable tag and note without rerunning candidate validation or waiting
|
||||||
|
for CI. Retry failed delivery against that same version after resolving any
|
||||||
|
uncertain hosted write; preserve published tags and hosted content. Report
|
||||||
|
delivery status separately from the completed source release.
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ documented validation model.
|
|||||||
|
|
||||||
The selected ReleaseKit profile runs the race-enabled package suite and full
|
The selected ReleaseKit profile runs the race-enabled package suite and full
|
||||||
vet, checks committed Go formatting, local Markdown links, and module
|
vet, checks committed Go formatting, local Markdown links, and module
|
||||||
distribution hygiene, and executes semantic assertions for the three
|
distribution hygiene. The Go suite includes semantic assertions for the three
|
||||||
maintained offline consumer examples. ReleaseKit owns this shared validation
|
maintained offline consumer examples. ReleaseKit owns this shared validation
|
||||||
machinery and publication workflow. The test run builds the packages; the
|
machinery and publication workflow. The test run builds the packages and
|
||||||
examples also exercise ordinary compilation. Separate ordinary tests and a
|
exercises the examples. Separate ordinary tests and a
|
||||||
standalone build are not additional release gates. Ordinary tests remain useful
|
standalone build are not additional release gates. Ordinary tests remain useful
|
||||||
during development. The preparation example protects assembled preparation and
|
during development. The preparation example protects assembled preparation and
|
||||||
inspection behavior. The execution example separately protects assembled
|
inspection behavior. The execution example separately protects assembled
|
||||||
|
|||||||
192
docs/release.md
192
docs/release.md
@@ -1,26 +1,26 @@
|
|||||||
# Release Procedure
|
# Release Procedure
|
||||||
|
|
||||||
This runbook implements the [release policy](policy/release.md). Promptkit
|
This runbook implements the [release policy](policy/release.md). Promptkit
|
||||||
publishes source-only Go module tags from `main` to `origin`. A successful push
|
publishes source-only Go module tags from `main` to `origin`, followed by a
|
||||||
of the selected annotated tag, after its source commit is available on `main`,
|
page-only Gitea release. Successful publication of the intended tag, with its
|
||||||
completes the release. Do not wait for CI or Go module availability.
|
source commit available on `main`, completes the source release. Report hosted
|
||||||
|
delivery separately. Do not wait for CI or Go module availability.
|
||||||
|
|
||||||
## Prepare The Candidate
|
## Prepare The Candidate
|
||||||
|
|
||||||
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
Required tools are Git, Go with race-detector support, and POSIX shell. Work
|
||||||
3. Work from the repository root with push access to `origin`.
|
from the repository root with push access to `origin`.
|
||||||
|
|
||||||
Use the latest Go installation available locally for the release tool, the
|
Use the latest Go installation available locally for the release tool and its
|
||||||
candidate validation, and the maintained examples. Make it the active `go`
|
child commands. Put it first on `PATH` and keep it selected throughout:
|
||||||
command and record its version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
go version
|
go version
|
||||||
```
|
```
|
||||||
|
|
||||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
For a minor release, normally advance the consumer minimum to the initial
|
||||||
release of that Go series before committing the candidate:
|
stable patch of that Go series:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_go_version=$(go env GOVERSION)
|
release_go_version=$(go env GOVERSION)
|
||||||
@@ -29,105 +29,131 @@ release_go_series=${release_go_series%.*}
|
|||||||
go mod edit "-go=${release_go_series}.0"
|
go mod edit "-go=${release_go_series}.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, Go 1.26.8 normally results in `go 1.26.0`. Keep a higher patch
|
Retain a higher patch minimum when required by a dependency or identified fix.
|
||||||
minimum if a dependency or identified correctness fix requires it. For a patch
|
For a patch release, preserve the existing minimum. Explain minimum increases
|
||||||
release, leave the consumer minimum unchanged; using a newer validation
|
under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
||||||
toolchain does not itself raise that minimum. If the candidate needs a higher
|
Prepare dependency caches before committing the candidate:
|
||||||
minimum, prepare a minor release instead. See the
|
|
||||||
[consumer-minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
|
||||||
|
|
||||||
Prepare module caches before validation; this may update `go.sum` and belongs
|
|
||||||
before the candidate commit:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GOTOOLCHAIN=local go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
||||||
title containing `Promptkit` and that exact version, a short summary, and any
|
title containing `Promptkit` and that exact version, a short summary, and any
|
||||||
relevant compatibility impact or upgrade instructions. No fixed section
|
compatibility impact or upgrade instructions. End the note with:
|
||||||
headings are required. Describe a raised Go minimum and its compatibility
|
|
||||||
impact under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
|
||||||
End each new note with the licensing footer below, using the selected version:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the footer paths against the candidate; the future tag need not resolve
|
Substitute the selected version and check the paths against the candidate;
|
||||||
remotely during preparation. Follow the [OSS policy](policy/oss.md) for these
|
future tag URLs need not resolve during preparation. Follow the
|
||||||
links. Promptkit publishes no binaries, hosted download page, or other
|
[OSS policy](policy/oss.md). Historical notes and tags remain unchanged.
|
||||||
asynchronous deliverables.
|
|
||||||
|
|
||||||
Retire completed roadmaps while preserving deferred work and repairing incoming
|
Supply delivery credentials through `RELEASEKIT_GITEA_TOKEN` or the ignored
|
||||||
links. Review the intended changes through the ordinary development workflow,
|
local file `.releasekit/gitea-token`, with owner-only file permissions. A
|
||||||
commit the candidate on `main`, and push `main` to `origin` before publication.
|
nonblank environment value takes precedence. Source publication neither reads
|
||||||
Preparation may update module metadata; the release bootstrap and validation
|
nor requires this token. The [profile](../releasekit.json) selects the token
|
||||||
preserve the committed candidate.
|
path for committed-file protection; keep credentials out of commits and logs.
|
||||||
|
|
||||||
|
Retire completed roadmaps, preserve unfinished work, and repair affected links.
|
||||||
|
Review and commit the candidate on `main`, including any module updates, then
|
||||||
|
push `main` to `origin` before publishing its tag.
|
||||||
|
|
||||||
## Validate And Publish
|
## Validate And Publish
|
||||||
|
|
||||||
For an optional local diagnostic of a committed candidate, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/releasekit.sh check vMAJOR.MINOR.PATCH
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not a required step before publication. The upstream
|
|
||||||
[candidate-check contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/consumers/check.md)
|
|
||||||
describes its requirements.
|
|
||||||
|
|
||||||
Run the single publication command with the chosen version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release.sh vMAJOR.MINOR.PATCH
|
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The familiar wrapper delegates to the native ReleaseKit executable selected in
|
The launcher uses `go tool` to build or reuse the module-selected ReleaseKit
|
||||||
`go.mod`. The committed [profile](../releasekit.json) selects the race-enabled
|
executable in Go's cache, using the selected local toolchain. ReleaseKit runs
|
||||||
Go suite, full vet, committed Go formatting, local Markdown links, workspace
|
the [profile's checks](../releasekit.json) once, verifies publication state, and
|
||||||
and filesystem replacement inspection, and semantic checks for the three
|
publishes only the intended tag. Preserve its native exit status and diagnostic.
|
||||||
maintained offline consumer examples. ReleaseKit also verifies the committed
|
Successful publication needs no separate remote read-back or CI wait.
|
||||||
candidate and publication state, then publishes only the selected tag. Its
|
|
||||||
checks run once for this candidate; do not repeat them as a separate sign-off.
|
|
||||||
|
|
||||||
The native command returns status 0 for confirmed publication, 1 for a known
|
For optional local diagnosis, use `./scripts/releasekit.sh check VERSION`.
|
||||||
validation or publication failure, 2 for invalid input or profile, and 3 when
|
It is not a required step before publication. The upstream
|
||||||
the push outcome remains unknown. Successful publication needs no remote
|
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/check.md)
|
||||||
read-back, CI wait, or installation check. Provider probes and inference calls
|
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/cli.md)
|
||||||
are not part of validation.
|
own shared checks and exit-status definitions.
|
||||||
|
|
||||||
|
## Deliver The Hosted Release
|
||||||
|
|
||||||
|
After source publication, keep a clean checkout at the published tag's commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit.sh deliver vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile selects page-only Gitea delivery for this repository. The hosted
|
||||||
|
body is the complete checked-in note, including its licensing footer. There are
|
||||||
|
no binary assets, checksum manifests, candidate revalidation, or CI waits.
|
||||||
|
ReleaseKit checks delivery credentials during delivery, not as a publication
|
||||||
|
gate. Report the hosted URL on success; delivery failure leaves the source
|
||||||
|
release complete.
|
||||||
|
|
||||||
|
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
|
||||||
|
(
|
||||||
|
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.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.
|
||||||
|
|
||||||
## Failure Recovery
|
## Failure Recovery
|
||||||
|
|
||||||
For status 1, use the diagnostic to distinguish a validation failure from a
|
For source-publication failures, use ReleaseKit's status and diagnostic to
|
||||||
known publication conflict. Correct and commit candidate defects, push `main`,
|
distinguish a candidate defect from a publication conflict. Correct and commit
|
||||||
and rerun publication with an unused version when appropriate. Status 2 means
|
candidate defects, push `main`, and rerun publication as appropriate. A failure
|
||||||
the invocation or profile must be corrected.
|
after local tag creation may leave a ref even when its object ID is unavailable.
|
||||||
|
|
||||||
For status 3, do not retry or remove the local tag until the remote outcome is
|
Status 3 means the push outcome remains unknown. Do not retry or remove the local
|
||||||
resolved. ReleaseKit reports the attempted version, tag object, and recovery
|
tag until the authoritative outcome is resolved. ReleaseKit attempts to save
|
||||||
state, including the captured publication endpoint when available. Use that
|
private publication evidence and reports a record path or a separate recording
|
||||||
endpoint—not merely the current `origin` URL—to query the exact tag; follow the
|
warning. A recording failure does not change the publication outcome.
|
||||||
upstream
|
|
||||||
[failure-recovery procedure](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/release.md#resolve-failures):
|
Use the recovery command reported for that attempt, or discover local records
|
||||||
|
and explicitly select the matching attempt:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
./scripts/releasekit.sh publication-attempts
|
||||||
releasekit_version='vX.Y.Z'
|
./scripts/releasekit.sh reconcile --record /absolute/path/to/selected-record.json
|
||||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
|
||||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the returned object ID with the reported tag object. An exact match
|
Discovery is local; reconciliation observes the original destination without
|
||||||
confirms publication. A missing ref confirms absence only when the endpoint
|
rerunning validation, changing tags, or delivering hosted content. Neither is
|
||||||
identity check and query both succeed. If either check fails, the outcome
|
a routine gate after successful publication. Follow the upstream
|
||||||
remains unknown; correct the transport configuration and resolve it before
|
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/release.md#resolve-publication-failures)
|
||||||
retrying. Only after authoritative absence is confirmed may an unpublished
|
for outcomes, incomplete evidence, and older attempts without records.
|
||||||
local tag be removed before correcting the candidate or retrying. Never move,
|
|
||||||
overwrite, or delete a published tag.
|
|
||||||
|
|
||||||
## Policy Changes
|
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.9.0/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
for storage and retention details.
|
||||||
|
|
||||||
Update the policy and this procedure together when changing release governance.
|
Current absence does not prove a version was never published. Remove a retained
|
||||||
Consumers own their dependency-availability and release checks; their readiness
|
local tag only after establishing absence and confirming it was not already
|
||||||
does not delay completion of a Promptkit release.
|
published or used by another release channel. Never reuse a known published
|
||||||
|
version or replace a published tag or hosted page. Corrections require a new
|
||||||
|
appropriate version.
|
||||||
|
|||||||
68
docs/releases/v0.17.0.md
Normal file
68
docs/releases/v0.17.0.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Promptkit v0.17.0
|
||||||
|
|
||||||
|
This release adopts the vanity module path `go.maximumdirect.net/promptkit`
|
||||||
|
and ConfigKit v1.0.0, standardizes configuration resources as identified lists,
|
||||||
|
and removes descriptive prompt-version metadata. These are intentional breaking
|
||||||
|
changes before v1. The minimum supported Go version increases from 1.26.0 to
|
||||||
|
1.27.0 for the ConfigKit v1 migration.
|
||||||
|
|
||||||
|
## Breaking Changes And Upgrade
|
||||||
|
|
||||||
|
Consumers upgrading from v0.16.0 should follow the
|
||||||
|
[configuration and API migration guide](../consumers/configuration-migration.md):
|
||||||
|
|
||||||
|
- Change Promptkit imports from `gitea.maximumdirect.net/eric/promptkit` to
|
||||||
|
`go.maximumdirect.net/promptkit`. Update packages that exchange Promptkit
|
||||||
|
types together; old-path and vanity-path types are distinct.
|
||||||
|
- Change ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0 and
|
||||||
|
adopt its renamed public inspection types and fields where used.
|
||||||
|
- Convert root and collection `prompts`, `profiles`, and `backends` from keyed
|
||||||
|
mappings to lists whose records contain an explicit `id`. Collections remain
|
||||||
|
keyed by namespace. Sibling inheritance now uses `extends: {id: parent}`;
|
||||||
|
later same-ID records replace lower records in full.
|
||||||
|
- Change `ProfileSource` and `BackendSource` calls to pass slices of
|
||||||
|
`ProfileConfig` and `BackendConfig`, setting each record's `ID`.
|
||||||
|
- Change source-root `configkit:` controls to `config:`. Imports are optional
|
||||||
|
unless marked `required: true`. Optional Promptkit specification headers now
|
||||||
|
use `version: 2` or `version: "2"`; headerless sources remain valid.
|
||||||
|
- Remove descriptive prompt `version` values and code consuming `PromptVersion`,
|
||||||
|
`PromptIdentity.Version`, or corresponding `prompt_version` output members.
|
||||||
|
Collection `release_version` labels remain available. Prompt hashes may change
|
||||||
|
across this migration.
|
||||||
|
|
||||||
|
After adapting Go imports, configuration, and API calls, use Go 1.27.0 or newer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.17.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Earlier tags retain their original module declarations. No permanent local
|
||||||
|
replacement is needed for this cutover. See the
|
||||||
|
[framework format reference](../formats.md) for current configuration semantics
|
||||||
|
and the [maintained preparation example](../../examples/go-library/prepare/main.go)
|
||||||
|
for explicit composition, source-relative imports, and scoped discovery.
|
||||||
|
|
||||||
|
## Configuration Inspection And Execution
|
||||||
|
|
||||||
|
Configuration inspection and diffs expose ConfigKit's composed values and
|
||||||
|
provenance, including list-index paths. Promptkit applies domain normalization
|
||||||
|
and execution defaults to owned interpretations without replacing the source
|
||||||
|
origins needed for relative resources. Profile inspection and preparation expose
|
||||||
|
their interpreted or effective settings. See the
|
||||||
|
[migration guide](../consumers/configuration-migration.md#select-and-compose-the-configuration-you-need)
|
||||||
|
for the distinction and inspection-path updates.
|
||||||
|
|
||||||
|
## Maintainer Tooling
|
||||||
|
|
||||||
|
Release tooling now uses the module-selected ReleaseKit v0.8.0 through `go tool`,
|
||||||
|
with page-only Gitea delivery and saved-publication-attempt recovery. The
|
||||||
|
[release procedure](../release.md) describes publication and recovery.
|
||||||
|
|
||||||
|
The three maintained offline examples now have ordinary Go workflow tests,
|
||||||
|
included in the standard suite. Their separate Python runner and custom release
|
||||||
|
hook have been removed; Python is no longer a normal release prerequisite.
|
||||||
|
The optional release-wiring diagnostic remains Python. See the
|
||||||
|
[development guide](../development.md#maintainer-validation).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/docs/oss/attribution.md)
|
||||||
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)
|
||||||
@@ -126,7 +126,7 @@ cycle-safe, and resolved afresh for ordinary operations. Prepared execution
|
|||||||
freezes the fully resolved target. The selected leaf ID remains public while
|
freezes the fully resolved target. The selected leaf ID remains public while
|
||||||
effective execution settings reflect the resolved chain.
|
effective execution settings reflect the resolved chain.
|
||||||
|
|
||||||
See the [profile inheritance format reference](../formats.md#profile-inheritance),
|
See the [profile inheritance format reference](../formats.md#resource-inheritance),
|
||||||
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
||||||
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
||||||
behavior.
|
behavior.
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
No ideas are currently awaiting selection. Active feature work belongs in its
|
No unselected ideas are currently recorded here. Deliberately postponed ideas
|
||||||
focused roadmap rather than this catalog.
|
remain in the [deferred catalog](deferred.md).
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
30
engine.go
30
engine.go
@@ -8,11 +8,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
internalengine "go.maximumdirect.net/promptkit/internal/engine"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||||
@@ -101,8 +101,9 @@ type Engine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
||||||
// highest-priority main input. Each underlay's inputs strictly compose within
|
// highest-priority main input. By default a bundled OpenRouter underlay precedes
|
||||||
// their group; later underlays overlay earlier ones. A nil Main uses
|
// 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
|
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
|
||||||
// may fail construction if invalid.
|
// may fail construction if invalid.
|
||||||
type ConfigurationSources struct {
|
type ConfigurationSources struct {
|
||||||
@@ -154,6 +155,15 @@ type engineOptions struct {
|
|||||||
internal internalengine.Options
|
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
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
// [Engine.RunPrepared].
|
// [Engine.RunPrepared].
|
||||||
//
|
//
|
||||||
@@ -213,7 +223,9 @@ func WithSchemaFile(path string) Option {
|
|||||||
// NewEngine constructs an Engine from configuration and options.
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
//
|
//
|
||||||
// ConfigKit composes caller-selected underlays in their given order, followed
|
// 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
|
// backend, profile, and prompt definitions and captures referenced prompt
|
||||||
// content. A missing prompt collection is allowed. Schema documents and
|
// content. A missing prompt collection is allowed. Schema documents and
|
||||||
// request artifacts remain runtime resources.
|
// request artifacts remain runtime resources.
|
||||||
@@ -251,7 +263,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
// InspectPrompt requires a nonblank promptID and performs an exact,
|
// InspectPrompt requires a nonblank promptID and performs an exact,
|
||||||
// case-sensitive lookup in the captured configuration snapshot. A
|
// case-sensitive lookup in the captured configuration snapshot. A
|
||||||
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
||||||
// qualified IDs pass through. Prompt version labels do not select definitions.
|
// qualified IDs pass through.
|
||||||
//
|
//
|
||||||
// A successful result proves that the selected definition and any referenced
|
// A successful result proves that the selected definition and any referenced
|
||||||
// message content files were structurally loaded. Inputs are returned in
|
// message content files were structurally loaded. Inputs are returned in
|
||||||
|
|||||||
12
errors.go
12
errors.go
@@ -5,12 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
"go.maximumdirect.net/promptkit/internal/generation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"go.maximumdirect.net/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapPublicError(err error) error {
|
func mapPublicError(err error) error {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed prompts/*.yaml schemas/*.json
|
//go:embed prompts/*.yaml schemas/*.json
|
||||||
@@ -84,19 +84,21 @@ func run() (summary, error) {
|
|||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
||||||
|
backend := promptkit.LocalBackend("https://example.invalid/v1", 1)
|
||||||
|
backend.ID = backendID
|
||||||
inputs = append(inputs,
|
inputs = append(inputs,
|
||||||
configkit.Input{Source: promptkit.BackendSource("example backend", map[string]promptkit.BackendConfig{
|
configkit.Input{Source: promptkit.BackendSource("example backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("https://example.invalid/v1", 1),
|
backend,
|
||||||
})},
|
})},
|
||||||
configkit.Input{Source: promptkit.ProfileSource("example profiles", map[string]promptkit.ProfileConfig{
|
configkit.Input{Source: promptkit.ProfileSource("example profiles", []promptkit.ProfileConfig{
|
||||||
"proposal-profile": {Backend: &backendID, Model: &proposalModel},
|
{ID: "proposal-profile", Backend: &backendID, Model: &proposalModel},
|
||||||
"assessment-profile": {Backend: &backendID, Model: &assessmentModel},
|
{ID: "assessment-profile", Backend: &backendID, Model: &assessmentModel},
|
||||||
})},
|
})},
|
||||||
)
|
)
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
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 {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
@@ -156,13 +158,16 @@ func run() (summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
value, err := run()
|
if err := execute(os.Stdout); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execute(out io.Writer) error {
|
||||||
|
value, err := run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(out).Encode(value)
|
||||||
|
}
|
||||||
|
|||||||
30
examples/go-library/observe/main_test.go
Normal file
30
examples/go-library/observe/main_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestObserveExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := execute(&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
ProposalAttempts *int `json:"proposal_attempts"`
|
||||||
|
ProposalRetries *int `json:"proposal_transient_retries"`
|
||||||
|
UsageComplete *bool `json:"proposal_usage_complete"`
|
||||||
|
AssessmentAttempts *int `json:"assessment_attempts"`
|
||||||
|
AssessmentRetries *int `json:"assessment_transient_retries"`
|
||||||
|
Outcome string `json:"assessment_outcome"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
|
||||||
|
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
|
||||||
|
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
|
||||||
|
t.Fatalf("unexpected observation summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.assessment:
|
- id: example.assessment
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: assessment-profile
|
default_profile: assessment-profile
|
||||||
description: Assess a structured proposal independently of schema validity.
|
description: Assess a structured proposal independently of schema validity.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.proposal:
|
- id: example.proposal
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: proposal-profile
|
default_profile: proposal-profile
|
||||||
description: Propose a bounded improvement from a synthetic idea.
|
description: Propose a bounded improvement from a synthetic idea.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://review.example.invalid/v1
|
endpoint: https://review.example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
audit:
|
- id: audit
|
||||||
backend: review/offline
|
backend: review/offline
|
||||||
model: review-model
|
model: review-model
|
||||||
prompts:
|
prompts:
|
||||||
check:
|
- id: check
|
||||||
default_profile: review/audit
|
default_profile: review/audit
|
||||||
description: Inspect a review item without contacting a model provider.
|
description: Inspect a review item without contacting a model provider.
|
||||||
messages:
|
messages:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version: "1"
|
version: "2"
|
||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: fragments/resources.yaml
|
- path: fragments/resources.yaml
|
||||||
|
required: true
|
||||||
|
- path: fragments/prepare.yaml
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
required: true
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
id: prepare
|
||||||
|
default_profile: support/fast
|
||||||
|
description: Prepare a prompt without contacting a model provider.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content_file: ../messages/note.md
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
@@ -1,28 +1,11 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 60
|
timeout_seconds: 60
|
||||||
prompts:
|
|
||||||
prepare:
|
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: support/fast
|
|
||||||
description: Prepare a prompt without contacting a model provider.
|
|
||||||
inputs:
|
|
||||||
- name: note
|
|
||||||
required: true
|
|
||||||
content_type: text/plain
|
|
||||||
messages:
|
|
||||||
- role: system
|
|
||||||
content: Summarize the note in one sentence.
|
|
||||||
- role: user
|
|
||||||
content_file: ../messages/note.md
|
|
||||||
output:
|
|
||||||
format: text
|
|
||||||
validation_mode: basic
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# A later input intentionally replaces only the inherited profile timeout.
|
# A later input replaces the profile with an inherited variant.
|
||||||
profiles:
|
profiles:
|
||||||
fast:
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type summary struct {
|
type summary struct {
|
||||||
PromptID string `json:"prompt_id"`
|
PromptID string `json:"prompt_id"`
|
||||||
PromptVersion *string `json:"prompt_version,omitempty"`
|
|
||||||
SelectedProfile string `json:"selected_profile"`
|
SelectedProfile string `json:"selected_profile"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
@@ -29,62 +30,66 @@ type collectionSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/prepare", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
support, err := promptkit.CollectionInput(
|
support, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
review, err := promptkit.CollectionInput(
|
review, err := promptkit.CollectionInput(
|
||||||
"review",
|
"review",
|
||||||
configkit.File("examples/go-library/prepare/collections/review/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "review", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
override, err := promptkit.CollectionInput(
|
override, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/operator-overrides.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "operator-overrides.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{support, review}},
|
{Inputs: []configkit.Input{support, review}},
|
||||||
{Inputs: []configkit.Input{override}},
|
{Inputs: []configkit.Input{override}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false)) // Keep this example's inventory caller-owned.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
|
||||||
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
||||||
Prompt: "support", Profile: "support", Backend: "support",
|
Prompt: "support", Profile: "support", Backend: "support",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys(
|
if _, found := view.Explain(configkit.Keys("collections", "support", "profiles").Index(1).Key("timeout_seconds")); !found {
|
||||||
"collections", "support", "profiles", "fast", "timeout_seconds",
|
return fmt.Errorf("collection profile override was not found")
|
||||||
)); !found {
|
|
||||||
exit(fmt.Errorf("collection profile override was not found"))
|
|
||||||
}
|
}
|
||||||
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||||
PromptID: "prepare",
|
PromptID: "prepare",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
||||||
requirements.Inputs[0].Name != "note" ||
|
requirements.Inputs[0].Name != "note" ||
|
||||||
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
||||||
exit(fmt.Errorf("required note input was not discovered before preparation"))
|
return fmt.Errorf("required note input was not discovered before preparation")
|
||||||
}
|
}
|
||||||
|
|
||||||
request := promptkit.RunRequest{
|
request := promptkit.RunRequest{
|
||||||
@@ -95,39 +100,39 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
prepared, err := scoped.Prepare(ctx, request)
|
prepared, err := scoped.Prepare(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
profile, err := scoped.InspectProfile(ctx, "fast")
|
profile, err := scoped.InspectProfile(ctx, "fast")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
||||||
exit(fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"later profile override was not applied: timeout is %d seconds",
|
"later profile override was not applied: timeout is %d seconds",
|
||||||
profile.EffectiveModelParams.TimeoutSeconds,
|
profile.EffectiveModelParams.TimeoutSeconds,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
namespaces, err := scoped.ListNamespaces(ctx)
|
namespaces, err := scoped.ListNamespaces(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
collections, err := scoped.ListCollections(ctx)
|
collections, err := scoped.ListCollections(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPrompts, err := scoped.ListPrompts(ctx)
|
scopedPrompts, err := scoped.ListPrompts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
||||||
Mode: promptkit.NamespaceNamed, Name: "review",
|
Mode: promptkit.NamespaceNamed, Name: "review",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPromptIDs := make([]string, len(scopedPrompts))
|
scopedPromptIDs := make([]string, len(scopedPrompts))
|
||||||
for index := range scopedPrompts {
|
for index := range scopedPrompts {
|
||||||
@@ -144,11 +149,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: prepared.PromptVersion,
|
|
||||||
SelectedProfile: prepared.SelectedProfileID,
|
SelectedProfile: prepared.SelectedProfileID,
|
||||||
Model: prepared.EffectiveModelParams.Model,
|
Model: prepared.EffectiveModelParams.Model,
|
||||||
MessageCount: len(prepared.Messages),
|
MessageCount: len(prepared.Messages),
|
||||||
@@ -157,12 +161,5 @@ func main() {
|
|||||||
Collections: collectionSummaries,
|
Collections: collectionSummaries,
|
||||||
ScopedPrompts: scopedPromptIDs,
|
ScopedPrompts: scopedPromptIDs,
|
||||||
ReviewPrompts: reviewPromptIDs,
|
ReviewPrompts: reviewPromptIDs,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
examples/go-library/prepare/main_test.go
Normal file
45
examples/go-library/prepare/main_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
Profile string `json:"selected_profile"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
Timeout int `json:"profile_timeout_seconds"`
|
||||||
|
Namespaces []string `json:"namespaces"`
|
||||||
|
Collections []map[string]json.RawMessage `json:"collections"`
|
||||||
|
ScopedPrompts []string `json:"scoped_prompts"`
|
||||||
|
ReviewPrompts []string `json:"review_prompts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PromptID != "support/prepare" || got.Profile != "support/fast" || got.Model != "offline-model" ||
|
||||||
|
got.MessageCount <= 0 || got.Timeout != 30 || !reflect.DeepEqual(got.Namespaces, []string{"review", "support"}) ||
|
||||||
|
!reflect.DeepEqual(got.ScopedPrompts, []string{"support/prepare"}) || !reflect.DeepEqual(got.ReviewPrompts, []string{"review/check"}) || len(got.Collections) != 2 {
|
||||||
|
t.Fatalf("unexpected preparation summary: %+v", got)
|
||||||
|
}
|
||||||
|
for index, want := range []struct{ namespace, release string }{{"review", ""}, {"support", "1.4.0"}} {
|
||||||
|
var namespace string
|
||||||
|
if err := json.Unmarshal(got.Collections[index]["namespace"], &namespace); err != nil || namespace != want.namespace {
|
||||||
|
t.Fatalf("collection namespace = %q, %v", namespace, err)
|
||||||
|
}
|
||||||
|
value, present := got.Collections[index]["release_version"]
|
||||||
|
var release *string
|
||||||
|
if !present || json.Unmarshal(value, &release) != nil || (index == 0 && release != nil) || (index == 1 && (release == nil || *release != want.release)) {
|
||||||
|
t.Fatalf("collection release = %s, present %t; want %q", value, present, want.release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deterministicClient struct{}
|
type deterministicClient struct{}
|
||||||
@@ -39,41 +41,41 @@ type summary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/run", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", []promptkit.ProfileConfig{
|
||||||
"offline-example": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline-example", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.File("examples/go-library/run/prompt.yaml")},
|
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 {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
PromptID: "example.run",
|
PromptID: "example.run",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"note": promptkit.Inline("Ada finished the migration review."),
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
Output: result.RawOutput,
|
Output: result.RawOutput,
|
||||||
ValidationStatus: result.Validation.Status,
|
ValidationStatus: result.Validation.Status,
|
||||||
IsValid: result.Validation.IsValid,
|
IsValid: result.Validation.IsValid,
|
||||||
Model: result.ModelName,
|
Model: result.ModelName,
|
||||||
TotalTokens: result.Usage.TotalTokens,
|
TotalTokens: result.Usage.TotalTokens,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
examples/go-library/run/main_test.go
Normal file
28
examples/go-library/run/main_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Valid *bool `json:"is_valid"`
|
||||||
|
Status string `json:"validation_status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Tokens *int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Output != "Ada finished the migration review." || got.Valid == nil || !*got.Valid ||
|
||||||
|
got.Status != "passed" || got.Model != "offline-model" || got.Tokens == nil || *got.Tokens != 18 {
|
||||||
|
t.Fatalf("unexpected execution summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.run:
|
- id: example.run
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: offline-example
|
default_profile: offline-example
|
||||||
description: Run a prompt with a deterministic injected model client.
|
description: Run a prompt with a deterministic injected model client.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const capturedPromptDocument = `
|
const capturedPromptDocument = `
|
||||||
prompts:
|
prompts:
|
||||||
captured:
|
- id: captured
|
||||||
version: "1"
|
|
||||||
default_profile: offline
|
default_profile: offline
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
messages:
|
messages:
|
||||||
@@ -40,8 +39,8 @@ func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: inputs},
|
{Inputs: inputs},
|
||||||
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -145,12 +144,11 @@ func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *tes
|
|||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
schemas := fstest.MapFS{}
|
schemas := fstest.MapFS{}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||||
"prompts": map[string]any{"structured": map[string]any{
|
"prompts": []any{map[string]any{"id": "structured",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuiltInGenerationError(t *testing.T) {
|
func TestBuiltInGenerationError(t *testing.T) {
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -1,18 +1,18 @@
|
|||||||
module gitea.maximumdirect.net/eric/promptkit
|
module go.maximumdirect.net/promptkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,13 +1,13 @@
|
|||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
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 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
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 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
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=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var artifactFileReadBenchmarkSink *domain.Artifact
|
var artifactFileReadBenchmarkSink *domain.Artifact
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package capacity
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type generateResult struct {
|
type generateResult struct {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const benchmarkDefinitionCount = 128
|
const benchmarkDefinitionCount = 128
|
||||||
@@ -31,13 +31,26 @@ type configurationShape struct {
|
|||||||
|
|
||||||
func benchmarkPrompt(profileID string) map[string]any {
|
func benchmarkPrompt(profileID string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "1",
|
|
||||||
"default_profile": profileID,
|
"default_profile": profileID,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func benchmarkRecords(values map[string]any) []any {
|
||||||
|
records := make([]any, 0, len(values))
|
||||||
|
for _, id := range sortedKeys(values) {
|
||||||
|
value := values[id].(map[string]any)
|
||||||
|
record := make(map[string]any, len(value)+1)
|
||||||
|
for key, field := range value {
|
||||||
|
record[key] = field
|
||||||
|
}
|
||||||
|
record["id"] = id
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
func independentDefinitions(count int) configurationShape {
|
func independentDefinitions(count int) configurationShape {
|
||||||
profiles := make(map[string]any, count)
|
profiles := make(map[string]any, count)
|
||||||
prompts := make(map[string]any, count)
|
prompts := make(map[string]any, count)
|
||||||
@@ -48,7 +61,7 @@ func independentDefinitions(count int) configurationShape {
|
|||||||
prompts[promptID] = benchmarkPrompt(profileID)
|
prompts[promptID] = benchmarkPrompt(profileID)
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": profiles, "prompts": prompts})},
|
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)})},
|
||||||
inputEntries: count * 2,
|
inputEntries: count * 2,
|
||||||
effectiveEntries: count * 2,
|
effectiveEntries: count * 2,
|
||||||
tiers: 1,
|
tiers: 1,
|
||||||
@@ -71,7 +84,7 @@ func fileBackedDefinitions(count int) configurationShape {
|
|||||||
})
|
})
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{
|
main: configkit.Input{
|
||||||
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": profiles, "prompts": prompts}),
|
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)}),
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
||||||
},
|
},
|
||||||
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
||||||
@@ -85,12 +98,12 @@ func sharedBaseDefinitions(count int) configurationShape {
|
|||||||
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
||||||
}
|
}
|
||||||
for index := range count {
|
for index := range count {
|
||||||
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": "base", "temperature": float64(index%10) / 10}
|
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": map[string]any{"id": "base"}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count + 2,
|
inputEntries: count + 2,
|
||||||
effectiveEntries: count + 2,
|
effectiveEntries: count + 2,
|
||||||
@@ -106,19 +119,19 @@ func layeredDefinitions(count int) configurationShape {
|
|||||||
for index := range count {
|
for index := range count {
|
||||||
id := fmt.Sprintf("profile-%03d", index)
|
id := fmt.Sprintf("profile-%03d", index)
|
||||||
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
||||||
operator[id] = map[string]any{"model": "operator-model"}
|
operator[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model"}
|
||||||
programmatic[id] = map[string]any{"extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
programmatic[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
||||||
final[id] = map[string]any{"temperature": float64(index%10) / 10}
|
final[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
underlays: []configkit.Underlay{
|
underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": base})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": benchmarkRecords(base)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": operator})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": benchmarkRecords(operator)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": programmatic})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": benchmarkRecords(programmatic)})}}},
|
||||||
},
|
},
|
||||||
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||||
"profiles": final,
|
"profiles": benchmarkRecords(final),
|
||||||
"prompts": map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count*4 + 1,
|
inputEntries: count*4 + 1,
|
||||||
effectiveEntries: count + 1,
|
effectiveEntries: count + 1,
|
||||||
@@ -132,8 +145,8 @@ func BenchmarkCollectionFileBackedConstruction(b *testing.B) {
|
|||||||
collections := make(map[string]any, count)
|
collections := make(map[string]any, count)
|
||||||
for index := range count {
|
for index := range count {
|
||||||
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"example": map[string]any{
|
map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
},
|
},
|
||||||
@@ -240,8 +253,8 @@ func inspectionDefinitions(extraParams map[string]any) configurationShape {
|
|||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": prompts,
|
"prompts": benchmarkRecords(prompts),
|
||||||
})},
|
})},
|
||||||
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
||||||
}
|
}
|
||||||
@@ -262,7 +275,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
||||||
if err != nil || definition.ID != "inspection" || definition.Version == nil || *definition.Version != "1" || hash == "" ||
|
if err != nil || definition.ID != "inspection" || hash == "" ||
|
||||||
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
||||||
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +285,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
||||||
}
|
}
|
||||||
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
||||||
if err != nil || inspection.PromptID != "inspection" || inspection.PromptVersion == nil || *inspection.PromptVersion != "1" ||
|
if err != nil || inspection.PromptID != "inspection" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
||||||
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
||||||
}
|
}
|
||||||
|
|||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inspection is the read-only subset of the retained ConfigKit result used by
|
// Inspection is the read-only subset of the retained ConfigKit result used by
|
||||||
@@ -66,11 +67,16 @@ func secretProviderPath(path configkit.Path) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func providerExtraParamsPath(parts []string) bool {
|
func providerExtraParamsPath(parts []string) bool {
|
||||||
if len(parts) >= 4 && parts[1] != "" &&
|
if len(parts) >= 4 && sequenceIndex(parts[1]) &&
|
||||||
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
||||||
(parts[2] == "backends" || parts[2] == "profiles") &&
|
(parts[2] == "backends" || parts[2] == "profiles") &&
|
||||||
parts[3] != "" && parts[4] == "extra_params"
|
sequenceIndex(parts[3]) && parts[4] == "extra_params"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceIndex(segment string) bool {
|
||||||
|
index, err := strconv.Atoi(segment)
|
||||||
|
return err == nil && index >= 0 && strconv.Itoa(index) == segment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package configuration
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||||
@@ -12,15 +12,15 @@ func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
|||||||
path configkit.Path
|
path configkit.Path
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true},
|
{name: "backend nested secret", path: configkit.Keys("backends").Index(0).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true},
|
{name: "profile nested secret", path: configkit.Keys("profiles").Index(1).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true},
|
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends").Index(2).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), want: true},
|
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles").Index(0).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
||||||
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
||||||
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
||||||
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
||||||
{name: "ordinary provider value", path: configkit.Keys("profiles", "assistant", "extra_params", "provider", "mode"), want: false},
|
{name: "ordinary provider value", path: configkit.Keys("profiles").Index(0).Key("extra_params").Key("provider").Key("mode"), want: false},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
152
internal/configuration/interpretation_test.go
Normal file
152
internal/configuration/interpretation_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
"smoke_tests": map[string]any{
|
||||||
|
"reachability": map[string]any{"endpoint": " https://example.invalid/health "},
|
||||||
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": " https://example.invalid/key "},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local", "service_tier": " standard ", "api_key_env": " TOKEN "}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := state.GetBackend("local")
|
||||||
|
if err != nil || backend.Endpoint != "https://example.invalid/v1" || backend.QueueCapacity != 1024 ||
|
||||||
|
backend.SmokeTests == nil || *backend.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
|
||||||
|
*backend.SmokeTests.Authentication.Kind != "none" || *backend.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" {
|
||||||
|
t.Fatalf("interpreted backend = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "partial")
|
||||||
|
if err != nil || profile.ResolvedTarget.ServiceTier != "standard" || profile.ResolvedTarget.APIKeyEnv != "TOKEN" {
|
||||||
|
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.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 || 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 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"}},
|
||||||
|
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": " schema.json "},
|
||||||
|
}
|
||||||
|
state, err := load(mapping("prompt metadata", map[string]any{"prompts": []any{definition}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, inspection, err := state.prompt("example")
|
||||||
|
if err != nil || selected.Validation.SchemaPath != "schema.json" || inspection.OutputContract.SchemaPath != "schema.json" || selected.Inputs[0].Name != "document" {
|
||||||
|
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("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)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/inputs/1") || !strings.Contains(err.Error(), "duplicated") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package configuration
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectionIdentity is the captured metadata for one final collection entry.
|
// CollectionIdentity is the captured metadata for one final collection entry.
|
||||||
@@ -77,9 +77,6 @@ func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
prompts := append([]domain.PromptIdentity(nil), state.promptIDs...)
|
prompts := append([]domain.PromptIdentity(nil), state.promptIDs...)
|
||||||
for index := range prompts {
|
|
||||||
prompts[index].Version = stringPointerValue(prompts[index].Version)
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,18 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}},
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1"},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{
|
"collections": map[string]any{
|
||||||
"z": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "z-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"z": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "z-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
"partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local"}}},
|
"partial": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local"}}},
|
||||||
"a": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "a-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"a": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "a-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
" z": map[string]any{"profiles": map[string]any{"profile ": map[string]any{"backend": "local", "model": "spaced-model"}}, "prompts": map[string]any{"prompt ": listingPromptDefinition()}},
|
" z": map[string]any{"profiles": []any{map[string]any{"id": "profile ", "backend": "local", "model": "spaced-model"}}, "prompts": []any{listingPromptDefinition("prompt ")}},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,9 +46,9 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{
|
wantPrompts := []domain.PromptIdentity{
|
||||||
{ID: " z/prompt ", Version: stringPointer("v1")},
|
{ID: " z/prompt "},
|
||||||
{ID: "a/prompt", Version: stringPointer("v1")},
|
{ID: "a/prompt"},
|
||||||
{ID: "z/prompt", Version: stringPointer("v1")},
|
{ID: "z/prompt"},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
||||||
@@ -59,9 +57,9 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsAreIndependent(t *testing.T) {
|
func TestLoadedListsAreIndependent(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{"backend": map[string]any{"endpoint": "https://example.test/v1"}},
|
"backends": []any{map[string]any{"id": "backend", "endpoint": "https://example.test/v1"}},
|
||||||
"profiles": map[string]any{"profile": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "profile"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -84,7 +82,6 @@ func TestLoadedListsAreIndependent(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prompts[0].ID = "changed prompt"
|
prompts[0].ID = "changed prompt"
|
||||||
*prompts[0].Version = "changed version"
|
|
||||||
|
|
||||||
profiles, err = state.ListProfiles(context.Background())
|
profiles, err = state.ListProfiles(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,7 +98,7 @@ func TestLoadedListsAreIndependent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{{ID: "example", Version: stringPointer("v1")}}
|
wantPrompts := []domain.PromptIdentity{{ID: "example"}}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
||||||
}
|
}
|
||||||
@@ -128,8 +125,8 @@ func TestLoadedListsEmptyInventory(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -151,9 +148,9 @@ func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listingPromptDefinition() map[string]any {
|
func listingPromptDefinition(id string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "v1",
|
"id": id,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type document struct {
|
type document struct {
|
||||||
Version *string `yaml:"version"`
|
Version *string `yaml:"version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
Collections map[string]collectionConfig `yaml:"collections"`
|
Collections map[string]collectionConfig `yaml:"collections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type collectionConfig struct {
|
type collectionConfig struct {
|
||||||
ReleaseVersion *string `yaml:"release_version"`
|
ReleaseVersion *string `yaml:"release_version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type backendConfig struct {
|
type backendConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
APIKeyEnv *string `yaml:"api_key_env"`
|
APIKeyEnv *string `yaml:"api_key_env"`
|
||||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||||
@@ -56,6 +56,7 @@ type authenticationProbeConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type profileConfig struct {
|
type profileConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Backend *string `yaml:"backend"`
|
Backend *string `yaml:"backend"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
Model *string `yaml:"model"`
|
Model *string `yaml:"model"`
|
||||||
@@ -71,7 +72,7 @@ type profileConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type promptConfig struct {
|
type promptConfig struct {
|
||||||
Version *string `yaml:"version"`
|
ID string `yaml:"id"`
|
||||||
DefaultProfile *string `yaml:"default_profile"`
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
Description *string `yaml:"description"`
|
Description *string `yaml:"description"`
|
||||||
SessionID *string `yaml:"session_id"`
|
SessionID *string `yaml:"session_id"`
|
||||||
@@ -141,9 +142,9 @@ type resourceSet struct {
|
|||||||
isCollection bool
|
isCollection bool
|
||||||
namespace string
|
namespace string
|
||||||
bases resourceBases
|
bases resourceBases
|
||||||
backends map[string]backendConfig
|
backends []backendConfig
|
||||||
profiles map[string]profileConfig
|
profiles []profileConfig
|
||||||
prompts map[string]promptConfig
|
prompts []promptConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootResourceBases() resourceBases {
|
func rootResourceBases() resourceBases {
|
||||||
@@ -192,8 +193,8 @@ func indexedResourceID(namespace, localKey string) string {
|
|||||||
func indexedBackends(config *document) map[string]backendConfig {
|
func indexedBackends(config *document) map[string]backendConfig {
|
||||||
backends := make(map[string]backendConfig)
|
backends := make(map[string]backendConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.backends {
|
for _, definition := range resources.backends {
|
||||||
backends[indexedResourceID(resources.namespace, localKey)] = definition
|
backends[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return backends
|
return backends
|
||||||
@@ -202,8 +203,8 @@ func indexedBackends(config *document) map[string]backendConfig {
|
|||||||
func indexedProfiles(config *document) map[string]profileConfig {
|
func indexedProfiles(config *document) map[string]profileConfig {
|
||||||
profiles := make(map[string]profileConfig)
|
profiles := make(map[string]profileConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.profiles {
|
for _, definition := range resources.profiles {
|
||||||
profiles[indexedResourceID(resources.namespace, localKey)] = definition
|
profiles[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return profiles
|
return profiles
|
||||||
@@ -219,6 +220,40 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
return load(main, underlays)
|
return load(main, underlays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configurationDefinition(state *loaded) configkit.Definition[document] {
|
||||||
|
return configkit.Definition[document]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("backends"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("profiles"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("backends")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("profiles")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts")}, "id"),
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: checkConfigurationVersion,
|
||||||
|
},
|
||||||
|
Sensitive: secretProviderPath,
|
||||||
|
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
for _, resources := range resourceSets(config) {
|
||||||
|
captureFields(resources.backends, func(v backendConfig) string { return v.ID }, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends)
|
||||||
|
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) {
|
||||||
|
validateEffective(config, validation)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// load performs one ConfigKit lifecycle over the selected source tiers.
|
// load performs one ConfigKit lifecycle over the selected source tiers.
|
||||||
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||||
state := &loaded{
|
state := &loaded{
|
||||||
@@ -229,27 +264,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
||||||
Underlays: underlays,
|
Underlays: underlays,
|
||||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
Definition: configkit.Definition[document]{
|
Definition: configurationDefinition(state),
|
||||||
Version: &configkit.VersionRule{
|
|
||||||
Path: configkit.Keys("version"),
|
|
||||||
Check: checkConfigurationVersion,
|
|
||||||
},
|
|
||||||
Secret: secretProviderPath,
|
|
||||||
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
|
||||||
for _, resources := range resourceSets(config) {
|
|
||||||
captureFields(resources.backends, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends)
|
|
||||||
captureFields(resources.profiles, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
|
|
||||||
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
|
|
||||||
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) {
|
|
||||||
validateEffective(config, validation)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
@@ -261,10 +276,10 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
if err := state.buildExecutionProfiles(); err != nil {
|
if err := state.buildExecutionProfiles(); err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
state.backendIDs = sortedKeys(indexedBackends(&result.Config))
|
state.backendIDs = sortedKeys(indexedBackends(&state.result.Config))
|
||||||
state.profileIDs = sortedKeys(state.indexedProfiles)
|
state.profileIDs = sortedKeys(state.indexedProfiles)
|
||||||
for _, id := range sortedKeys(state.prompts) {
|
for _, id := range sortedKeys(state.prompts) {
|
||||||
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: stringPointerValue(state.prompts[id].definition.Version)})
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
|
||||||
}
|
}
|
||||||
state.namespaces = map[string][]string{
|
state.namespaces = map[string][]string{
|
||||||
"backend": resourceNamespaces(state.backendIDs),
|
"backend": resourceNamespaces(state.backendIDs),
|
||||||
@@ -302,7 +317,7 @@ func checkConfigurationVersion(version configkit.Version, validation *configkit.
|
|||||||
if !version.Present {
|
if !version.Present {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if version.Null || version.Text != "1" {
|
if version.Null || version.Text != "2" {
|
||||||
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -313,11 +328,11 @@ type promptInputPresence struct {
|
|||||||
description fieldState
|
description fieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||||
for _, id := range sortedKeys(prompts) {
|
for promptIndex, prompt := range prompts {
|
||||||
inputs := prompts[id].Inputs
|
inputs := prompt.Inputs
|
||||||
states := make([]promptInputPresence, len(inputs))
|
states := make([]promptInputPresence, len(inputs))
|
||||||
base := basePath.Key(id).Key("inputs")
|
base := basePath.Index(promptIndex).Key("inputs")
|
||||||
for index := range inputs {
|
for index := range inputs {
|
||||||
path := base.Index(index)
|
path := base.Index(index)
|
||||||
states[index] = promptInputPresence{
|
states[index] = promptInputPresence{
|
||||||
@@ -325,12 +340,12 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P
|
|||||||
description: captureFieldState(path.Key("description"), resolution),
|
description: captureFieldState(path.Key("description"), resolution),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
destination[indexedResourceID(namespace, prompt.ID)] = states
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
||||||
info, exists := resolution.InputInfo(path)
|
info, exists := resolution.EntryInfo(path)
|
||||||
switch {
|
switch {
|
||||||
case !exists || !info.Explicit:
|
case !exists || !info.Explicit:
|
||||||
return fieldAbsent
|
return fieldAbsent
|
||||||
@@ -341,11 +356,11 @@ func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFields[T any](entries map[string]T, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
func captureFields[T any](entries []T, identity func(T) string, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
||||||
for id := range entries {
|
for index, entry := range entries {
|
||||||
states := make(map[string]fieldState, len(fields))
|
states := make(map[string]fieldState, len(fields))
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
info, exists := resolution.EntryInfo(basePath.Index(index).Key(field))
|
||||||
switch {
|
switch {
|
||||||
case !exists || !info.Explicit:
|
case !exists || !info.Explicit:
|
||||||
states[field] = fieldAbsent
|
states[field] = fieldAbsent
|
||||||
@@ -355,7 +370,30 @@ func captureFields[T any](entries map[string]T, basePath configkit.Path, namespa
|
|||||||
states[field] = fieldValue
|
states[field] = fieldValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
destination[indexedResourceID(namespace, identity(entry))] = states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,99 +406,6 @@ func sortedKeys[T any](entries map[string]T) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
|
|
||||||
if value != nil && *value != nil && **value != normalized {
|
|
||||||
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(backends) {
|
|
||||||
backend := backends[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if backend.Endpoint != nil {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.APIKeyEnv != nil {
|
|
||||||
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
|
||||||
}
|
|
||||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
|
||||||
smokePath := base.Key("smoke_tests")
|
|
||||||
if smokeTests.Reachability != nil {
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
if smokeTests.Authentication != nil {
|
|
||||||
if smokeTests.Authentication.Kind != nil {
|
|
||||||
normalizeString(&smokeTests.Authentication.Kind,
|
|
||||||
smokePath.Key("authentication").Key("kind"), resolution,
|
|
||||||
strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind)))
|
|
||||||
}
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
|
||||||
smokePath.Key("authentication").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
|
|
||||||
capacity := 1024
|
|
||||||
if resolution.IsExplicit(base.Key("queue_capacity")) {
|
|
||||||
configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
} else {
|
|
||||||
configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
backends[id] = backend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
if value == nil || *value == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalized := strings.TrimSpace(**value)
|
|
||||||
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
|
||||||
if normalized == "" || err != nil || request.URL == nil ||
|
|
||||||
(strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") ||
|
|
||||||
request.URL.Hostname() == "" {
|
|
||||||
resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalizeString(value, path, resolution, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(profiles) {
|
|
||||||
profile := profiles[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"service_tier", &profile.ServiceTier},
|
|
||||||
{"reasoning_effort", &profile.ReasoningEffort},
|
|
||||||
{"api_key_env", &profile.APIKeyEnv},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profiles[id] = profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateEffective(config *document, validation *configkit.Validation) {
|
func validateEffective(config *document, validation *configkit.Validation) {
|
||||||
sets := resourceSets(config)
|
sets := resourceSets(config)
|
||||||
backendIDs := make(map[string]struct{})
|
backendIDs := make(map[string]struct{})
|
||||||
@@ -468,16 +413,16 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
|||||||
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
||||||
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
||||||
}
|
}
|
||||||
for localKey := range resources.backends {
|
for _, backend := range resources.backends {
|
||||||
backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{}
|
backendIDs[indexedResourceID(resources.namespace, backend.ID)] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, resources := range sets {
|
for _, resources := range sets {
|
||||||
for _, id := range sortedKeys(resources.backends) {
|
for index, backend := range resources.backends {
|
||||||
backend := resources.backends[id]
|
base := resources.bases.backends.Index(index)
|
||||||
base := resources.bases.backends.Key(id)
|
validateBackendEndpoints(backend, base, validation)
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
if !domain.IsValidLocalResourceKey(backend.ID) {
|
||||||
validation.Error(base, "backend key must be a nonblank local key without a slash")
|
validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash")
|
||||||
}
|
}
|
||||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||||
@@ -518,11 +463,15 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, id := range sortedKeys(resources.profiles) {
|
for index, profile := range resources.profiles {
|
||||||
profile := resources.profiles[id]
|
base := resources.bases.profiles.Index(index)
|
||||||
base := resources.bases.profiles.Key(id)
|
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint); err != nil {
|
||||||
validation.Error(base, "profile key must be a nonblank local key without a slash")
|
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")
|
||||||
}
|
}
|
||||||
if profile.Backend != nil && *profile.Backend != "" {
|
if profile.Backend != nil && *profile.Backend != "" {
|
||||||
if !domain.IsValidResourceID(*profile.Backend) {
|
if !domain.IsValidResourceID(*profile.Backend) {
|
||||||
@@ -556,11 +505,10 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
|||||||
}
|
}
|
||||||
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||||
}
|
}
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
for index, prompt := range resources.prompts {
|
||||||
prompt := resources.prompts[id]
|
base := resources.bases.prompts.Index(index)
|
||||||
base := resources.bases.prompts.Key(id)
|
if !domain.IsValidLocalResourceKey(prompt.ID) {
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
validation.Error(base.Key("id"), "prompt ID must be a nonblank local key without a slash")
|
||||||
validation.Error(base, "prompt key must be a nonblank local key without a slash")
|
|
||||||
}
|
}
|
||||||
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
||||||
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
||||||
|
|||||||
@@ -1,17 +1,342 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapping(label string, values map[string]any) configkit.Input {
|
func mapping(label string, values map[string]any) configkit.Input {
|
||||||
return configkit.Input{Source: configkit.Mapping(label, values)}
|
return configkit.Input{Source: configkit.Mapping(label, values)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadIdentifiedResourceLists(t *testing.T) {
|
||||||
|
for _, header := range []bool{false, true} {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
"collections": map[string]any{"team": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if header {
|
||||||
|
values["version"] = "2"
|
||||||
|
}
|
||||||
|
loaded, err := load(mapping("identified resources", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load identified resources with header %t: %v", header, err)
|
||||||
|
}
|
||||||
|
if loaded.result.Config.Backends[0].ID != "local" || loaded.result.Config.Collections["team"].Backends[0].ID != "local" {
|
||||||
|
t.Fatal("identified root and collection resources were not retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesComposeAcrossNamespacesAndKinds(t *testing.T) {
|
||||||
|
resources := func() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"backends": []any{
|
||||||
|
map[string]any{"id": "shared", "endpoint": "https://example.invalid/v1"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "model"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"prompts": []any{
|
||||||
|
map[string]any{"id": "shared", "messages": []any{map[string]any{"role": "user", "content": "inherited"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := resources()
|
||||||
|
values["collections"] = map[string]any{"*": resources(), "123": resources()}
|
||||||
|
state, err := load(mapping("identified namespaces", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"", "*/", "123/"} {
|
||||||
|
backend, err := state.registry.GetBackend(prefix + "child")
|
||||||
|
if err != nil || backend.ID != prefix+"child" || backend.Endpoint != "https://example.invalid/v1" {
|
||||||
|
t.Fatalf("inherited backend %q = %+v, %v", prefix, backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfileIdentity(context.Background(), prefix+"child")
|
||||||
|
if err != nil || profile != prefix+"child" {
|
||||||
|
t.Fatalf("inherited profile %q = %q, %v", prefix, profile, err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt(prefix + "child")
|
||||||
|
if err != nil || definition.ID != prefix+"child" || len(definition.Templates) != 1 || definition.Templates[0].Content != "inherited" {
|
||||||
|
t.Fatalf("inherited prompt %q = %+v, %v", prefix, definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidInheritance(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for name, records := range map[string][]any{
|
||||||
|
"missing": {map[string]any{"id": "child", "extends": map[string]any{"id": "missing"}}},
|
||||||
|
"self": {map[string]any{"id": "child", "extends": map[string]any{"id": "child"}}},
|
||||||
|
"cycle": {
|
||||||
|
map[string]any{"id": "first", "extends": map[string]any{"id": "second"}},
|
||||||
|
map[string]any{"id": "second", "extends": map[string]any{"id": "first"}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid inheritance", map[string]any{kind: records}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream inheritance diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeInheritanceMissing || diagnostic.Code == configkit.CodeInheritanceCycle {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing inheritance diagnostic: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidLocalIDs(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
record map[string]any
|
||||||
|
}{
|
||||||
|
{name: "missing", record: map[string]any{}},
|
||||||
|
{name: "null", record: map[string]any{"id": nil}},
|
||||||
|
{name: "non-string", record: map[string]any{"id": 42}},
|
||||||
|
{name: "blank", record: map[string]any{"id": " "}},
|
||||||
|
{name: "slash", record: map[string]any{"id": "not/local"}},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid resource ID", map[string]any{kind: []any{test.record}}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want a path-aware error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectDuplicateStrictPeers(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, namespace := range []string{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
values := func() map[string]any {
|
||||||
|
records := []any{map[string]any{"id": "duplicate"}}
|
||||||
|
if namespace == "" {
|
||||||
|
return map[string]any{kind: records}
|
||||||
|
}
|
||||||
|
return map[string]any{"collections": map[string]any{namespace: map[string]any{kind: records}}}
|
||||||
|
}
|
||||||
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
mapping("first", values()), mapping("second", values()),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream duplicate diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing duplicate conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
definition := configurationDefinition(&loaded{})
|
||||||
|
definition.Resolve = func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
path := configkit.Keys(kind)
|
||||||
|
if namespace == "" {
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
config.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
config.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
config.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collection := collectionConfig{}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
collection.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
collection.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
collection.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
config.Collections = map[string]collectionConfig{namespace: collection}
|
||||||
|
path = configkit.Keys("collections", namespace, kind)
|
||||||
|
}
|
||||||
|
resolution.RecordNormalization(path, "supply resolved records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.NoSource()}
|
||||||
|
if namespace != "" {
|
||||||
|
input = mapping("existing collection", map[string]any{"collections": map[string]any{
|
||||||
|
namespace: map[string]any{kind: []any{}},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(input, configkit.LayeredOptions[document]{Definition: definition})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want a final identity diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeSequenceConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing final identity conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayerOperations(t *testing.T) {
|
||||||
|
base := mapping("base profiles", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "first", "model": "original", "service_tier": "standard"},
|
||||||
|
map[string]any{"id": "second", "model": "retained"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
records []any
|
||||||
|
models map[string]string
|
||||||
|
}{
|
||||||
|
{name: "whole record upsert", records: []any{map[string]any{"id": "first", "model": "updated"}}, models: map[string]string{"first": "updated", "second": "retained"}},
|
||||||
|
{name: "empty upsert", records: []any{}, models: map[string]string{"first": "original", "second": "retained"}},
|
||||||
|
{name: "append", mode: "append", records: []any{map[string]any{"id": "third", "model": "added"}}, models: map[string]string{"first": "original", "second": "retained", "third": "added"}},
|
||||||
|
{name: "replace", mode: "replace", records: []any{map[string]any{"id": "third", "model": "replacement"}}, models: map[string]string{"third": "replacement"}},
|
||||||
|
{name: "clear", mode: "replace", records: []any{}, models: map[string]string{}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
values := map[string]any{"profiles": test.records}
|
||||||
|
if test.mode != "" {
|
||||||
|
values["config"] = map[string]any{"sequences": []any{map[string]any{
|
||||||
|
"destination": []any{"profiles"}, "mode": test.mode,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("overlay profiles", values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles := indexedProfiles(&state.result.Config)
|
||||||
|
if len(profiles) != len(test.models) {
|
||||||
|
t.Fatalf("profiles = %+v, want IDs and models %+v", profiles, test.models)
|
||||||
|
}
|
||||||
|
for id, model := range test.models {
|
||||||
|
profile, ok := profiles[id]
|
||||||
|
if !ok || profile.Model == nil || *profile.Model != model {
|
||||||
|
t.Fatalf("profile %q = %+v, want model %q", id, profile, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "whole record upsert" && profiles["first"].ServiceTier != nil {
|
||||||
|
t.Fatalf("upsert retained a displaced field: %+v", profiles["first"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayersRejectDisplacedParentAndDuplicateAppend(t *testing.T) {
|
||||||
|
base := mapping("base", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "original"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
values map[string]any
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "self extension cannot recover displaced record",
|
||||||
|
values: map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "extends": map[string]any{"id": "shared"}},
|
||||||
|
}},
|
||||||
|
code: configkit.CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "append cannot duplicate an identity",
|
||||||
|
values: map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": "append"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "shared", "model": "other"}},
|
||||||
|
},
|
||||||
|
code: configkit.CodeSequenceConflict,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("overlay", test.values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want structured diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == test.code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing %s diagnostic: %+v", test.code, loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -19,11 +344,11 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
include bool
|
include bool
|
||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{name: "string one", version: "1", include: true, valid: true},
|
{name: "string two", version: "2", include: true, valid: true},
|
||||||
{name: "integer one", version: int64(1), include: true, valid: true},
|
{name: "integer two", version: int64(2), include: true, valid: true},
|
||||||
{name: "omitted", valid: true},
|
{name: "omitted", valid: true},
|
||||||
{name: "explicit null", include: true},
|
{name: "explicit null", include: true},
|
||||||
{name: "unsupported string", version: "2", include: true},
|
{name: "unsupported string", version: "1", include: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
@@ -35,8 +360,8 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load accepted version: %v", err)
|
t.Fatalf("load accepted version: %v", err)
|
||||||
}
|
}
|
||||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") {
|
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2") {
|
||||||
t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version)
|
t.Fatalf("typed version = %v, want 2", loaded.result.Config.Version)
|
||||||
}
|
}
|
||||||
if !test.include && loaded.result.Config.Version != nil {
|
if !test.include && loaded.result.Config.Version != nil {
|
||||||
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
||||||
@@ -47,9 +372,6 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err == nil || !errors.As(err, &loadErr) {
|
if err == nil || !errors.As(err, &loadErr) {
|
||||||
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "2") {
|
|
||||||
t.Fatalf("load error disclosed the unsupported value: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
found := false
|
||||||
for _, diagnostic := range loadErr.Diagnostics() {
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
||||||
@@ -63,9 +385,44 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMalformedHeadersAndLegacyResourceMappings(t *testing.T) {
|
||||||
|
for name, values := range map[string]map[string]any{
|
||||||
|
"boolean header": {"version": true},
|
||||||
|
"fractional header": {"version": 2.5},
|
||||||
|
"sequence header": {"version": []any{2}},
|
||||||
|
"backend mapping": {"backends": map[string]any{"local": map[string]any{"endpoint": "https://example.invalid/v1"}}},
|
||||||
|
"profile mapping": {"profiles": map[string]any{"local": map[string]any{}}},
|
||||||
|
"prompt mapping": {"prompts": map[string]any{"local": map[string]any{}}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping(name, values), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want structured rejection", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedConfigRemainsApplicationData(t *testing.T) {
|
||||||
|
state, err := load(mapping("nested config", map[string]any{
|
||||||
|
"config": map[string]any{},
|
||||||
|
"profiles": []any{map[string]any{"id": "example", "extra_params": map[string]any{
|
||||||
|
"config": map[string]any{"imports": "provider data"},
|
||||||
|
}}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "example")
|
||||||
|
if err != nil || profile.ResolvedTarget.ExtraParams["config"].(map[string]any)["imports"] != "provider data" {
|
||||||
|
t.Fatalf("nested application data = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
||||||
underlay := mapping("declared underlay", map[string]any{
|
underlay := mapping("declared underlay", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +433,7 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
||||||
"prompts": map[string]any{"incomplete": map[string]any{"description": "still validated"}},
|
"prompts": []any{map[string]any{"id": "incomplete", "description": "still validated"}},
|
||||||
})
|
})
|
||||||
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
||||||
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
||||||
@@ -85,30 +442,25 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
|
|
||||||
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{"base": map[string]any{
|
"collections": map[string]any{"base": map[string]any{
|
||||||
"release_version": "",
|
"release_version": "",
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 3}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3},
|
"profiles": []any{
|
||||||
},
|
map[string]any{"id": "one", "backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||||
"profiles": map[string]any{
|
|
||||||
"one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||||
"child.v1": map[string]any{"extends": "one"},
|
map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"}},
|
||||||
"namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"},
|
map[string]any{"id": "namespaced", "backend": "base/local.v1", "model": "collection-model"},
|
||||||
},
|
},
|
||||||
}, "invoice": map[string]any{
|
}, "invoice": map[string]any{
|
||||||
"release_version": "r7",
|
"release_version": "r7",
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"summary": map[string]any{
|
map[string]any{"id": "summary",
|
||||||
"version": "v1.0",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
"version": map[string]any{
|
map[string]any{"id": "version",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
@@ -116,19 +468,23 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}},
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2, "queue_capacity": 0}},
|
||||||
"collections": map[string]any{"base": map[string]any{"profiles": map[string]any{"child.v1": map[string]any{
|
"collections": map[string]any{"base": map[string]any{"profiles": []any{map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"},
|
||||||
"temperature": 0, "top_p": nil, "api_key_required": false,
|
"temperature": 0, "top_p": nil, "api_key_required": false,
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
||||||
}}}},
|
}}}},
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("main", map[string]any{"version": "1"}), []configkit.Underlay{
|
loaded, err := load(mapping("main", map[string]any{"version": "2"}), []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load: %v; diagnostics: %+v", err, loadErr.Diagnostics())
|
||||||
|
}
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profile := loaded.result.Config.Collections["base"].Profiles["child.v1"]
|
profile := loaded.result.Config.Collections["base"].Profiles[1]
|
||||||
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("effective values were not retained: %+v", profile)
|
t.Fatalf("effective values were not retained: %+v", profile)
|
||||||
}
|
}
|
||||||
@@ -139,7 +495,7 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
||||||
t.Fatalf("recursive overlay failed: %+v", provider)
|
t.Fatalf("recursive overlay failed: %+v", provider)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local.v1"]
|
backend := loaded.result.Config.Backends[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue {
|
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue {
|
||||||
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
||||||
}
|
}
|
||||||
@@ -150,10 +506,10 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
||||||
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Collections["invoice"].Prompts["summary"].Messages[0].Content == nil || loaded.result.Config.Collections["invoice"].Prompts["summary"].Version == nil || *loaded.result.Config.Collections["invoice"].Prompts["summary"].Version != "v1.0" {
|
if loaded.result.Config.Collections["invoice"].Prompts[0].Messages[0].Content == nil {
|
||||||
t.Fatal("literal prompt ID and version were not decoded")
|
t.Fatal("literal prompt content was not decoded")
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1" ||
|
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2" ||
|
||||||
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
||||||
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
||||||
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
||||||
@@ -176,20 +532,20 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "root resource with separator",
|
name: "root resource with separator",
|
||||||
values: map[string]any{"backends": map[string]any{
|
values: map[string]any{"backends": []any{
|
||||||
"bad/name": map[string]any{"endpoint": "https://example.test/v1"},
|
map[string]any{"id": "bad/name", "endpoint": "https://example.test/v1"},
|
||||||
}},
|
}},
|
||||||
message: "backend key must be a nonblank local key without a slash",
|
message: "backend ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collection resource with separator",
|
name: "collection resource with separator",
|
||||||
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
||||||
"prompts": map[string]any{"bad/name": map[string]any{
|
"prompts": []any{map[string]any{"id": "bad/name",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}},
|
}},
|
||||||
}}},
|
}}},
|
||||||
message: "prompt key must be a nonblank local key without a slash",
|
message: "prompt ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -208,23 +564,39 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
map[string]any{"id": "local", "endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
||||||
"concurrency_limit": 2, "queue_capacity": nil},
|
"concurrency_limit": 2, "queue_capacity": nil},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
backend, err := loaded.GetBackend("local")
|
||||||
if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" ||
|
if err != nil || backend.Endpoint != "http://localhost:8080/v1" ||
|
||||||
backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" ||
|
backend.APIKeyEnv != "TOKEN_NAME" || backend.QueueCapacity != 1024 {
|
||||||
backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 {
|
|
||||||
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
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 || *composed.QueueCapacity != 1024 {
|
||||||
|
t.Fatalf("resolved backend was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
if loaded.backends["local"]["queue_capacity"] != fieldNull {
|
if loaded.backends["local"]["queue_capacity"] != fieldNull {
|
||||||
t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"])
|
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 {
|
||||||
|
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) {
|
func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||||
@@ -238,17 +610,17 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
{name: "null", smokeTests: nil, include: true, inherited: true},
|
{name: "null", smokeTests: nil, include: true, inherited: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
backend := map[string]any{"endpoint": "https://backend.example/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://backend.example/v1"}
|
||||||
if test.include {
|
if test.include {
|
||||||
backend["smoke_tests"] = test.smokeTests
|
backend["smoke_tests"] = test.smokeTests
|
||||||
}
|
}
|
||||||
main := mapping("backend", map[string]any{
|
main := mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
})
|
})
|
||||||
var underlays []configkit.Underlay
|
var underlays []configkit.Underlay
|
||||||
if test.inherited {
|
if test.inherited {
|
||||||
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
||||||
}},
|
}},
|
||||||
@@ -258,7 +630,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := loaded.result.Config.Backends["local"].SmokeTests; got != nil {
|
if got := loaded.result.Config.Backends[0].SmokeTests; got != nil {
|
||||||
t.Fatalf("smoke_tests = %+v, want nil", got)
|
t.Fatalf("smoke_tests = %+v, want nil", got)
|
||||||
}
|
}
|
||||||
selected, err := loaded.registry.GetBackend("local")
|
selected, err := loaded.registry.GetBackend("local")
|
||||||
@@ -274,7 +646,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "catalog",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "catalog-profile",
|
"default_profile": "catalog-profile",
|
||||||
@@ -287,7 +659,7 @@ func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "extends": map[string]any{"id": "catalog"},
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "not-yet-listed",
|
"default_profile": "not-yet-listed",
|
||||||
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
||||||
@@ -331,37 +703,37 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "blank default profile",
|
name: "blank default profile",
|
||||||
data: map[string]any{"default_profile": " \t "},
|
data: map[string]any{"default_profile": " \t "},
|
||||||
path: "/backends/local/smoke_tests/default_profile",
|
path: "/backends/0/smoke_tests/default_profile",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "authentication kind required",
|
name: "authentication kind required",
|
||||||
data: map[string]any{"authentication": map[string]any{}},
|
data: map[string]any{"authentication": map[string]any{}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown authentication kind",
|
name: "unknown authentication kind",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-http reachability URL",
|
name: "non-http reachability URL",
|
||||||
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
||||||
path: "/backends/local/smoke_tests/reachability/endpoint",
|
path: "/backends/0/smoke_tests/reachability/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank authentication URL",
|
name: "blank authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "relative authentication URL",
|
name: "relative authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, err := load(mapping("backend", map[string]any{
|
_, err := load(mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": test.data,
|
"smoke_tests": test.data,
|
||||||
}},
|
}},
|
||||||
@@ -381,22 +753,42 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
underlay := mapping("defaults", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "api_key_env": "OLD_KEY"},
|
||||||
|
}})
|
||||||
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
||||||
|
}}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := loaded.GetBackend("local")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCapturesProfilePresence(t *testing.T) {
|
||||||
|
loaded, err := load(mapping("profile", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "local", "temperature": 0, "top_p": nil, "api_key_required": false},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
profile := loaded.result.Config.Profiles[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
t.Fatalf("profile values were not preserved: %+v", profile)
|
||||||
|
}
|
||||||
|
fields := loaded.profiles["local"]
|
||||||
|
if fields["temperature"] != fieldValue || fields["top_p"] != fieldNull || fields["api_key_required"] != fieldValue || fields["model"] != fieldAbsent {
|
||||||
|
t.Fatalf("profile presence was not preserved: %+v", fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
||||||
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("first", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("first", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
mapping("second", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("second", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
@@ -406,33 +798,33 @@ func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": "not a URL"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": "not a URL"}},
|
||||||
"profiles": map[string]any{"partial": map[string]any{"backend": "local"}},
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080/v1"}}})
|
main := mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}}})
|
||||||
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Profiles["partial"].Model != nil {
|
if loaded.result.Config.Profiles[0].Model != nil {
|
||||||
t.Fatal("unused partial profile unexpectedly gained a model")
|
t.Fatal("unused partial profile unexpectedly gained a model")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("invalid", map[string]any{"backends": map[string]any{"unused": map[string]any{"endpoint": "not a URL"}}}),
|
mapping("invalid", map[string]any{"backends": []any{map[string]any{"id": "unused", "endpoint": "not a URL"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
||||||
}
|
}
|
||||||
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/unused/endpoint" {
|
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/0/endpoint" {
|
||||||
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": " ",
|
"default_profile": " ",
|
||||||
@@ -442,7 +834,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{
|
main := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "dangling-profile",
|
"default_profile": "dangling-profile",
|
||||||
"reachability": map[string]any{"endpoint": nil},
|
"reachability": map[string]any{"endpoint": nil},
|
||||||
@@ -465,7 +857,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
||||||
_, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}),
|
_, err := load(mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080"}}}),
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,59 +28,6 @@ type promptEntry struct {
|
|||||||
messages []*template.Template
|
messages []*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(prompts) {
|
|
||||||
definition := prompts[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"description", &definition.Description},
|
|
||||||
{"session_id", &definition.SessionID},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inputsChanged := false
|
|
||||||
for index := range definition.Inputs {
|
|
||||||
input := &definition.Inputs[index]
|
|
||||||
inputPath := base.Key("inputs").Index(index)
|
|
||||||
normalizedName := strings.TrimSpace(input.Name)
|
|
||||||
if normalizedName != input.Name {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"content_type", &input.ContentType},
|
|
||||||
{"description", &input.Description},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalized := strings.TrimSpace(**field.value)
|
|
||||||
if normalized != **field.value {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inputsChanged {
|
|
||||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
|
||||||
}
|
|
||||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
|
||||||
// records sequence mutations as whole-value replacements, which would
|
|
||||||
// discard the effective source contexts of unchanged content_file fields.
|
|
||||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
|
||||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
|
||||||
}
|
|
||||||
prompts[id] = definition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *loaded) materializePrompts() error {
|
func (state *loaded) materializePrompts() error {
|
||||||
state.prompts = make(map[string]*promptEntry)
|
state.prompts = make(map[string]*promptEntry)
|
||||||
var metadata *configkit.Metadata
|
var metadata *configkit.Metadata
|
||||||
@@ -92,14 +39,13 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return metadata.Lookup(path)
|
return metadata.Lookup(path)
|
||||||
}
|
}
|
||||||
for _, resources := range resourceSets(&state.result.Config) {
|
for _, resources := range resourceSets(&state.result.Config) {
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
for index, raw := range resources.prompts {
|
||||||
promptBase := resources.bases.prompts.Key(id)
|
promptBase := resources.bases.prompts.Index(index)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||||
}
|
}
|
||||||
fullID := indexedResourceID(resources.namespace, id)
|
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||||
raw := resources.prompts[id]
|
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,11 +55,11 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
func (state *loaded) materializePrompt(id string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||||
if len(raw.Messages) == 0 {
|
if len(raw.Messages) == 0 {
|
||||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
||||||
}
|
}
|
||||||
definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)}
|
definition := domain.PromptDefinition{ID: id}
|
||||||
if raw.DefaultProfile != nil {
|
if raw.DefaultProfile != nil {
|
||||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||||
@@ -168,8 +114,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
if message.Role == nil {
|
if message.Role == nil {
|
||||||
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
||||||
}
|
}
|
||||||
role, err := domain.NormalizeMessageRole(*message.Role)
|
if _, err := domain.NormalizeMessageRole(*message.Role); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
return nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
||||||
}
|
}
|
||||||
if (message.Content != nil) == (message.ContentFile != nil) {
|
if (message.Content != nil) == (message.ContentFile != nil) {
|
||||||
@@ -194,9 +139,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
if message.CacheControl.TTL != nil {
|
if message.CacheControl.TTL != nil {
|
||||||
control.TTL = *message.CacheControl.TTL
|
control.TTL = *message.CacheControl.TTL
|
||||||
}
|
}
|
||||||
var err error
|
if _, err := domain.NormalizeCacheControl(control); err != nil {
|
||||||
control, err = domain.NormalizeCacheControl(control)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +149,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
}
|
}
|
||||||
entry.messages = append(entry.messages, compiled)
|
entry.messages = append(entry.messages, compiled)
|
||||||
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
|
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
|
||||||
Role: role, Content: content, CacheControl: control,
|
Role: *message.Role, Content: content, CacheControl: control,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if raw.Output == nil {
|
if raw.Output == nil {
|
||||||
@@ -242,7 +185,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
entry.definition = definition
|
entry.definition = definition
|
||||||
entry.hash = hash
|
entry.hash = hash
|
||||||
entry.inspection = domain.PromptInspection{
|
entry.inspection = domain.PromptInspection{
|
||||||
PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash,
|
PromptID: id, PromptHash: hash,
|
||||||
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||||
OutputContract: contract,
|
OutputContract: contract,
|
||||||
}
|
}
|
||||||
@@ -305,7 +248,6 @@ func (state *loaded) lookupPromptEntry(id string) (*promptEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
||||||
definition.Version = stringPointerValue(definition.Version)
|
|
||||||
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
||||||
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
||||||
for index := range definition.Templates {
|
for index := range definition.Templates {
|
||||||
@@ -318,7 +260,6 @@ func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
||||||
inspection.PromptVersion = stringPointerValue(inspection.PromptVersion)
|
|
||||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||||
return &inspection
|
return &inspection
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\n - path: ../other/definition.yaml\ncollections:\n docs:\n prompts:\n child:\n extends: base\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")},
|
||||||
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\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")},
|
"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}}")},
|
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
||||||
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n child:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
"other/definition.yaml": {Data: []byte("collections:\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")},
|
||||||
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||||
}
|
}
|
||||||
namespace := configkit.NewFilesystem("embedded", files)
|
namespace := configkit.NewFilesystem("embedded", files)
|
||||||
state, err := load(configkit.Input{
|
state, err := load(configkit.Input{
|
||||||
Source: namespace.File("app/main.yaml"), Imports: true,
|
Source: namespace.File("app/main.yaml"), Imports: true,
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
||||||
}, nil)
|
}, []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("other/definition.yaml"), Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) {
|
if errors.As(err, &loadError) {
|
||||||
@@ -41,11 +42,29 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
||||||
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
||||||
}
|
}
|
||||||
|
if definition.Description != "description" || definition.SessionID != "session" || len(definition.Inputs) != 1 ||
|
||||||
|
definition.Inputs[0].Name != "document" || definition.Inputs[0].ContentType != "text/plain" || definition.Inputs[0].Description != "document source" {
|
||||||
|
t.Fatalf("inherited metadata was not interpreted: %+v", definition)
|
||||||
|
}
|
||||||
message := definition.Templates[0]
|
message := definition.Templates[0]
|
||||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||||
}
|
}
|
||||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file"))
|
childIndex := -1
|
||||||
|
for index, item := range state.result.Config.Collections["docs"].Prompts {
|
||||||
|
if item.ID == "child" {
|
||||||
|
childIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if childIndex < 0 {
|
||||||
|
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("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 {
|
if !ok || len(field.Origin.Locations) != 1 {
|
||||||
t.Fatalf("content origin was lost during normalization: %+v", field)
|
t.Fatalf("content origin was lost during normalization: %+v", field)
|
||||||
}
|
}
|
||||||
@@ -59,10 +78,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||||
}
|
}
|
||||||
other, otherInspection, err := state.prompt("other/child")
|
other, otherInspection, err := state.prompt("other/child")
|
||||||
if err != nil || other.Templates[0].Content != "Other collection {{.name}}" {
|
if err != nil || other.Templates[0].Content != "Replacement {{.name}}" {
|
||||||
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||||
}
|
}
|
||||||
files["catalog/body.md"].Data = []byte("Changed")
|
files["catalog/body.md"].Data = []byte("Changed")
|
||||||
|
files["app/body.md"].Data = []byte("Changed replacement")
|
||||||
files["other/body.md"].Data = []byte("Changed other collection")
|
files["other/body.md"].Data = []byte("Changed other collection")
|
||||||
again, againInspection, err := state.prompt("docs/child")
|
again, againInspection, err := state.prompt("docs/child")
|
||||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
||||||
@@ -74,11 +94,28 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||||
}
|
}
|
||||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
||||||
if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
if err != nil || otherAgain.Templates[0].Content != "Replacement {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||||
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadHeaderlessImportedItem(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yaml": {Data: []byte("version: 2\nconfig:\n imports:\n - path: item.yaml\n destination: [prompts]\n placement: sequence-item\n required: true\n")},
|
||||||
|
"item.yaml": {Data: []byte("id: example\nmessages:\n - role: user\n content: hello\noutput:\n format: text\n validation_mode: basic\n")},
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("items", files)
|
||||||
|
state, err := load(configkit.Input{Source: namespace.File("main.yaml"), Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt("example")
|
||||||
|
if err != nil || definition.Templates[0].Content != "hello" {
|
||||||
|
t.Fatalf("headerless item = %+v, %v", definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||||
filesystem := &countingFS{FS: fstest.MapFS{
|
filesystem := &countingFS{FS: fstest.MapFS{
|
||||||
"assets/body.md": {Data: []byte("From mapping")},
|
"assets/body.md": {Data: []byte("From mapping")},
|
||||||
@@ -97,7 +134,7 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
|||||||
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") {
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/messages/0/content_file") {
|
||||||
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +143,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
definitionPath := filepath.Join(root, "definition.yaml")
|
definitionPath := filepath.Join(root, "definition.yaml")
|
||||||
contentPath := filepath.Join(root, "body.md")
|
contentPath := filepath.Join(root, "body.md")
|
||||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
if err := os.WriteFile(definitionPath, []byte("prompts:\n - id: example\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
||||||
@@ -125,127 +162,14 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsMalformedStaticSyntaxAndCapturesOptionalVersion(t *testing.T) {
|
func TestLoadRejectsMalformedStaticSyntax(t *testing.T) {
|
||||||
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err := load(mapping("bad-template", map[string]any{"prompts": []any{map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}), nil)
|
}}}), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
||||||
t.Fatalf("expected construction-time syntax error, got %v", err)
|
t.Fatalf("expected construction-time syntax error, got %v", err)
|
||||||
}
|
}
|
||||||
state, err := load(mapping("version metadata", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
|
||||||
"version": "release label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "one"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}}}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version == nil || *definition.Version != "release label" ||
|
|
||||||
inspection.PromptVersion == nil || *inspection.PromptVersion != "release label" {
|
|
||||||
t.Fatalf("optional version metadata was not retained: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionLabelPresenceAndOwnership(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
version any
|
|
||||||
include bool
|
|
||||||
wantPresent bool
|
|
||||||
wantValue string
|
|
||||||
}{
|
|
||||||
{name: "absent"},
|
|
||||||
{name: "null", include: true},
|
|
||||||
{name: "empty", version: "", include: true, wantPresent: true},
|
|
||||||
{name: "exact text", version: " Release/v1 ", include: true, wantPresent: true, wantValue: " Release/v1 "},
|
|
||||||
}
|
|
||||||
|
|
||||||
hashes := make(map[string]string, len(tests))
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
definition := map[string]any{
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
if test.include {
|
|
||||||
definition["version"] = test.version
|
|
||||||
}
|
|
||||||
state, err := load(mapping("prompt version label", map[string]any{
|
|
||||||
"prompts": map[string]any{"example": definition},
|
|
||||||
}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, inspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
identities, err := state.ListPrompts(context.Background())
|
|
||||||
if err != nil || len(identities) != 1 {
|
|
||||||
t.Fatalf("list prompt labels: %#v, %v", identities, err)
|
|
||||||
}
|
|
||||||
check := func(name string, value *string) {
|
|
||||||
t.Helper()
|
|
||||||
if !test.wantPresent {
|
|
||||||
if value != nil {
|
|
||||||
t.Errorf("%s label = %q, want absent", name, *value)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if value == nil || *value != test.wantValue {
|
|
||||||
t.Errorf("%s label = %v, want %q", name, value, test.wantValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check("definition", captured.Version)
|
|
||||||
check("inspection", inspection.PromptVersion)
|
|
||||||
check("listing", identities[0].Version)
|
|
||||||
if captured.Version != nil && (captured.Version == inspection.PromptVersion || captured.Version == identities[0].Version || inspection.PromptVersion == identities[0].Version) {
|
|
||||||
t.Fatal("returned prompt labels share mutable pointers")
|
|
||||||
}
|
|
||||||
hashes[test.name] = inspection.PromptHash
|
|
||||||
|
|
||||||
if captured.Version != nil {
|
|
||||||
*captured.Version = "mutated definition"
|
|
||||||
}
|
|
||||||
if inspection.PromptVersion != nil {
|
|
||||||
*inspection.PromptVersion = "mutated inspection"
|
|
||||||
}
|
|
||||||
if identities[0].Version != nil {
|
|
||||||
*identities[0].Version = "mutated listing"
|
|
||||||
}
|
|
||||||
again, againInspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
check("captured definition after mutation", again.Version)
|
|
||||||
check("captured inspection after mutation", againInspection.PromptVersion)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if hashes["absent"] == hashes["empty"] {
|
|
||||||
t.Fatal("absent and declared-empty labels produced the same definition hash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionNullClearsInheritedLabel(t *testing.T) {
|
|
||||||
prompt := map[string]any{
|
|
||||||
"version": "base-label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
state, err := load(
|
|
||||||
mapping("clear inherited label", map[string]any{"prompts": map[string]any{"example": map[string]any{"version": nil}}}),
|
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{mapping("base prompt", map[string]any{"prompts": map[string]any{"example": prompt}})}}},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version != nil || inspection.PromptVersion != nil {
|
|
||||||
t.Fatalf("null overlay retained inherited label: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
||||||
@@ -261,12 +185,10 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedPrompt := makePrompt("selected")
|
selectedPrompt := makePrompt("selected")
|
||||||
selectedPrompt["version"] = "v2"
|
selectedPrompt["id"] = "selected"
|
||||||
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{
|
"prompts": []any{selectedPrompt},
|
||||||
"selected": selectedPrompt,
|
|
||||||
},
|
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -281,8 +203,7 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("snapshot prompt ID: %v", err)
|
t.Fatalf("snapshot prompt ID: %v", err)
|
||||||
}
|
}
|
||||||
if inspection.PromptID != definition.ID || inspection.PromptVersion == nil || definition.Version == nil ||
|
if inspection.PromptID != definition.ID ||
|
||||||
*inspection.PromptVersion != *definition.Version ||
|
|
||||||
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
||||||
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
||||||
@@ -322,9 +243,8 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
||||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{
|
state, err := load(mapping("empty-content", map[string]any{"prompts": []any{map[string]any{"id": " exact.id ",
|
||||||
"version": "v1",
|
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
|
||||||
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
||||||
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -344,7 +264,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
||||||
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err = load(mapping("obsolete-field", map[string]any{"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -353,7 +273,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("null-prompt", map[string]any{"prompts": map[string]any{"example": nil}}), nil)
|
_, err = load(mapping("null-prompt", map[string]any{"prompts": []any{nil}}), nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("null prompt collection was accepted")
|
t.Fatal("null prompt collection was accepted")
|
||||||
}
|
}
|
||||||
@@ -361,8 +281,8 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
|
|
||||||
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
||||||
state, err := load(mapping("requirement-metadata", map[string]any{
|
state, err := load(mapping("requirement-metadata", map[string]any{
|
||||||
"prompts": map[string]any{"example": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "example",
|
||||||
"session_id": "{{.session}}",
|
"session_id": "{{.session}}",
|
||||||
"inputs": []any{
|
"inputs": []any{
|
||||||
map[string]any{"name": "absent"},
|
map[string]any{"name": "absent"},
|
||||||
@@ -435,8 +355,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
input[name] = value
|
input[name] = value
|
||||||
}
|
}
|
||||||
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
||||||
"prompts": map[string]any{"same": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "same",
|
||||||
"inputs": []any{input},
|
"inputs": []any{input},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -466,7 +386,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func promptWithFile(name string) map[string]any {
|
func promptWithFile(name string) map[string]any {
|
||||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{
|
return map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}
|
}}}
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (state *loaded) buildExecutionProfiles() error {
|
func (state *loaded) buildExecutionProfiles() error {
|
||||||
|
|||||||
@@ -9,36 +9,88 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestLoadIndexesIdentifiedTargets(t *testing.T) {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://root.example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
|
"collections": map[string]any{},
|
||||||
|
}
|
||||||
|
collections := values["collections"].(map[string]any)
|
||||||
|
for _, namespace := range []string{"north", "south"} {
|
||||||
|
collections[namespace] = map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://" + namespace + ".example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": namespace + "/local"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("identified targets", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for id, backendID := range map[string]string{"partial": "local", "north/partial": "north/local", "south/partial": "south/local"} {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil || profile.ID != id || profile.ResolvedTarget == nil || profile.ResolvedTarget.Model != "" || profile.ResolvedTarget.BackendID != backendID {
|
||||||
|
t.Fatalf("partial profile %q = %+v, %v", id, profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCompletesLoadedPartialProfile(t *testing.T) {
|
||||||
|
state, err := load(mapping("partial profile", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
|
"prompts": []any{map[string]any{
|
||||||
|
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
|
}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := usecase.NewRunner(state, state, nil, prompt.NewGoRenderer(), nil, nil, nil)
|
||||||
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "example", ProfileID: "partial",
|
||||||
|
Execution: &domain.ExecutionTargetOverride{Endpoint: "https://example.invalid/v1", Model: "request-model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prepared.SelectedProfileID != "partial" || prepared.EffectiveModelParams.Endpoint != "https://example.invalid/v1" ||
|
||||||
|
prepared.EffectiveModelParams.Model != "request-model" {
|
||||||
|
t.Fatalf("request did not complete the loaded profile: %+v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
||||||
state, err := load(mapping("effective-targets", map[string]any{
|
state, err := load(mapping("effective-targets", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{
|
||||||
"first": map[string]any{"endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
map[string]any{"id": "first", "endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
||||||
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
||||||
"second": map[string]any{"endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
map[string]any{"id": "second", "endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
||||||
"concurrency_limit": 2, "queue_capacity": 0,
|
"concurrency_limit": 2, "queue_capacity": 0,
|
||||||
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
||||||
},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base": map[string]any{"backend": "first", "model": "model", "temperature": 0.8,
|
map[string]any{"id": "base", "backend": "first", "model": "model", "temperature": 0.8,
|
||||||
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
||||||
"child": map[string]any{"extends": "base", "backend": "second", "temperature": 0.0,
|
map[string]any{"id": "child", "extends": map[string]any{"id": "base"}, "backend": "second", "temperature": 0.0,
|
||||||
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
||||||
"api_key_required": false,
|
"api_key_required": false,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
||||||
"direct-key": map[string]any{"backend": "second", "api_key_required": true},
|
map[string]any{"id": "direct-key", "backend": "second", "api_key_required": true},
|
||||||
"named-key": map[string]any{"backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
map[string]any{"id": "named-key", "backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
||||||
"no-provider-map": map[string]any{"backend": "second", "extra_params": nil},
|
map[string]any{"id": "no-provider-map", "backend": "second", "extra_params": nil},
|
||||||
"no-timeout": map[string]any{"backend": "second", "timeout_seconds": 0},
|
map[string]any{"id": "no-timeout", "backend": "second", "timeout_seconds": 0},
|
||||||
"partial": map[string]any{},
|
map[string]any{"id": "partial"},
|
||||||
"endpoint-only": map[string]any{"endpoint": "https://standalone.example/v1"},
|
map[string]any{"id": "endpoint-only", "endpoint": "https://standalone.example/v1"},
|
||||||
"cleared-endpoint": map[string]any{"extends": "base", "endpoint": nil},
|
map[string]any{"id": "cleared-endpoint", "extends": map[string]any{"id": "base"}, "endpoint": nil},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,9 +185,9 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
state, err := load(mapping("wire-controls", map[string]any{
|
state, err := load(mapping("wire-controls", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": server.URL + "/v1"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": server.URL + "/v1"}},
|
||||||
"profiles": map[string]any{"direct": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
"id": "direct", "backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
||||||
}},
|
}},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package defaults
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ type RunResult struct {
|
|||||||
RawOutput string
|
RawOutput string
|
||||||
Validation ValidationResult
|
Validation ValidationResult
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SessionID string
|
SessionID string
|
||||||
RenderedPromptHash string
|
RenderedPromptHash string
|
||||||
@@ -100,7 +99,6 @@ type RunResult struct {
|
|||||||
// It must never include resolved API key values, model output, or validation data.
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
type PreparedRun struct {
|
type PreparedRun struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SelectedProfileID string
|
SelectedProfileID string
|
||||||
SelectedBackendID string
|
SelectedBackendID string
|
||||||
@@ -134,16 +132,14 @@ type Artifact struct {
|
|||||||
Hash string
|
Hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptIdentity identifies one configured prompt and its descriptive version label.
|
// PromptIdentity identifies one configured prompt.
|
||||||
type PromptIdentity struct {
|
type PromptIdentity struct {
|
||||||
ID string
|
ID string
|
||||||
Version *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptDefinition represents a configured prompt execution definition.
|
// PromptDefinition represents a configured prompt execution definition.
|
||||||
type PromptDefinition struct {
|
type PromptDefinition struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Version *string `yaml:"version"`
|
|
||||||
DefaultProfile string `yaml:"default_profile"`
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
@@ -156,7 +152,6 @@ type PromptDefinition struct {
|
|||||||
// PromptInspection is the resolved result of exact prompt inspection.
|
// PromptInspection is the resolved result of exact prompt inspection.
|
||||||
type PromptInspection struct {
|
type PromptInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
DefaultProfileID string
|
DefaultProfileID string
|
||||||
Inputs []PromptInput
|
Inputs []PromptInput
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ type ExecutionEvent struct {
|
|||||||
RunID string
|
RunID string
|
||||||
Time time.Time
|
Time time.Time
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
ProfileID string
|
ProfileID string
|
||||||
BackendID string
|
BackendID string
|
||||||
Model string
|
Model string
|
||||||
|
|||||||
@@ -48,11 +48,10 @@ type RequirementIssue struct {
|
|||||||
|
|
||||||
// RequirementInspection contains the inventories discovered for one prompt.
|
// RequirementInspection contains the inventories discovered for one prompt.
|
||||||
type RequirementInspection struct {
|
type RequirementInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
ProfileID string
|
||||||
ProfileID string
|
Inputs []InputRequirement
|
||||||
Inputs []InputRequirement
|
Variables []VariableRequirement
|
||||||
Variables []VariableRequirement
|
Complete bool
|
||||||
Complete bool
|
Issues []RequirementIssue
|
||||||
Issues []RequirementIssue
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package domain
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
||||||
@@ -65,7 +65,6 @@ type SmokeTestTarget struct {
|
|||||||
BackendID string
|
BackendID string
|
||||||
ProfileID string
|
ProfileID string
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
DefaultSourceBackendID string
|
DefaultSourceBackendID string
|
||||||
SelectionSource string
|
SelectionSource string
|
||||||
Endpoint string
|
Endpoint string
|
||||||
@@ -180,7 +179,6 @@ func CloneSmokeTestRequest(request SmokeTestRequest) (SmokeTestRequest, error) {
|
|||||||
func CloneSmokeTestReport(report SmokeTestReport) (SmokeTestReport, error) {
|
func CloneSmokeTestReport(report SmokeTestReport) (SmokeTestReport, error) {
|
||||||
cloned := report
|
cloned := report
|
||||||
cloned.Applicable = cloneBool(report.Applicable)
|
cloned.Applicable = cloneBool(report.Applicable)
|
||||||
cloned.Target.PromptVersion = cloneString(report.Target.PromptVersion)
|
|
||||||
cloned.Facts = cloneSmokeTestFacts(report.Facts)
|
cloned.Facts = cloneSmokeTestFacts(report.Facts)
|
||||||
cloned.Timing = cloneSmokeTestTiming(report.Timing)
|
cloned.Timing = cloneSmokeTestTiming(report.Timing)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -77,11 +77,9 @@ func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) {
|
|||||||
reachable := true
|
reachable := true
|
||||||
status := 200
|
status := 200
|
||||||
wait := 3 * time.Millisecond
|
wait := 3 * time.Millisecond
|
||||||
promptVersion := "v1"
|
|
||||||
report := SmokeTestReport{
|
report := SmokeTestReport{
|
||||||
Applicable: &applicable,
|
Applicable: &applicable,
|
||||||
Target: SmokeTestTarget{
|
Target: SmokeTestTarget{
|
||||||
PromptVersion: &promptVersion,
|
|
||||||
EffectiveSettings: ExecutionTarget{
|
EffectiveSettings: ExecutionTarget{
|
||||||
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
||||||
},
|
},
|
||||||
@@ -103,10 +101,9 @@ func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) {
|
|||||||
*cloned.Attempts[0].HTTPStatus = 503
|
*cloned.Attempts[0].HTTPStatus = 503
|
||||||
*cloned.Attempts[0].Facts.EndpointReachable = false
|
*cloned.Attempts[0].Facts.EndpointReachable = false
|
||||||
*cloned.Attempts[0].Timing.CapacityWait = time.Second
|
*cloned.Attempts[0].Timing.CapacityWait = time.Second
|
||||||
*cloned.Target.PromptVersion = "changed"
|
|
||||||
cloned.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
cloned.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||||
|
|
||||||
if !applicable || !reachable || status != 200 || wait != 3*time.Millisecond || promptVersion != "v1" {
|
if !applicable || !reachable || status != 200 || wait != 3*time.Millisecond {
|
||||||
t.Fatal("cloned report shared optional scalar pointers")
|
t.Fatal("cloned report shared optional scalar pointers")
|
||||||
}
|
}
|
||||||
if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
"go.maximumdirect.net/promptkit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
"go.maximumdirect.net/promptkit/internal/generation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
"go.maximumdirect.net/promptkit/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config carries construction settings selected by the public facade.
|
// Config carries construction settings selected by the public facade.
|
||||||
@@ -41,13 +41,17 @@ type ConfigurationSources struct {
|
|||||||
|
|
||||||
// Options collects construction choices from the public facade.
|
// Options collects construction choices from the public facade.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
llmClient llm.Client
|
llmClient llm.Client
|
||||||
artifactReader artifact.Reader
|
artifactReader artifact.Reader
|
||||||
validator validate.Validator
|
validator validate.Validator
|
||||||
validatorSource bool
|
validatorSource bool
|
||||||
artifactSource 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.
|
// SetLLMClient selects the client collaborator for one engine.
|
||||||
func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client }
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
underlays := cfg.Sources.Underlays
|
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()}
|
main := configkit.Input{Source: configkit.NoSource()}
|
||||||
if cfg.Sources.Main != nil {
|
if cfg.Sources.Main != nil {
|
||||||
main = *cfg.Sources.Main
|
main = *cfg.Sources.Main
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func engineSmokeBackendInput() configkit.Input {
|
func engineSmokeBackendInput() configkit.Input {
|
||||||
return configkit.Input{Source: configkit.Mapping("smoke-test backend", map[string]any{
|
return configkit.Input{Source: configkit.Mapping("smoke-test backend", map[string]any{
|
||||||
"backends": map[string]any{"test-backend": map[string]any{
|
"backends": []any{map[string]any{"id": "test-backend",
|
||||||
"endpoint": "https://test-backend.example/v1",
|
"endpoint": "https://test-backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "test-profile",
|
"default_profile": "test-profile",
|
||||||
"authentication": map[string]any{"kind": "none"},
|
"authentication": map[string]any{"kind": "none"},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
"profiles": map[string]any{"test-profile": map[string]any{
|
"profiles": []any{map[string]any{"id": "test-profile",
|
||||||
"backend": "test-backend",
|
"backend": "test-backend",
|
||||||
"model": "synthetic-model",
|
"model": "synthetic-model",
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type transportFunc func(*http.Request) (*http.Response, error)
|
type transportFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package generation
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerationEvidence contains invocation-local request evidence without raw
|
// GenerationEvidence contains invocation-local request evidence without raw
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateWithEvidenceRetainsAttemptsAndUsage(t *testing.T) {
|
func TestGenerateWithEvidenceRetainsAttemptsAndUsage(t *testing.T) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func recordEvents(t *testing.T, ctx context.Context, events *[]domain.ExecutionEvent) context.Context {
|
func recordEvents(t *testing.T, ctx context.Context, events *[]domain.ExecutionEvent) context.Context {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package llm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client executes a rendered prompt against an LLM endpoint.
|
// Client executes a rendered prompt against an LLM endpoint.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPExecutionEvidence contains only facts observable at the configured
|
// HTTPExecutionEvidence contains only facts observable at the configured
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user