Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.sum
|
||||
|
||||
# Local ReleaseKit executable
|
||||
# Local ReleaseKit delivery credentials
|
||||
.releasekit/
|
||||
|
||||
# env file
|
||||
|
||||
13
README.md
13
README.md
@@ -4,7 +4,7 @@ Promptkit is a reusable Go library for preparing and executing prompt-defined
|
||||
LLM workflows. Its module path is:
|
||||
|
||||
```text
|
||||
gitea.maximumdirect.net/eric/promptkit
|
||||
go.maximumdirect.net/promptkit
|
||||
```
|
||||
|
||||
The root `promptkit` package provides the supported public engine. Consumers
|
||||
@@ -43,10 +43,17 @@ boundary and constraints that framework work must preserve.
|
||||
|
||||
## Release Guidance
|
||||
|
||||
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
|
||||
diagnostics, configuration collections, scoped engine views, and discovery.
|
||||
Upgrading consumers should review the breaking changes to bundled configuration
|
||||
and prompt version selection and follow the linked migration guide.
|
||||
Upgrading consumers should also review the current identified-resource and
|
||||
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
|
||||
validation of concrete requests with `Engine.ValidateRequest`.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
artifactadapter "go.maximumdirect.net/promptkit/internal/artifact"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -71,7 +71,7 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
||||
message := "Hello {{.name}}"
|
||||
response := "ok"
|
||||
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})}
|
||||
var expectedMessage string
|
||||
|
||||
@@ -99,9 +99,8 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
||||
}
|
||||
|
||||
config := map[string]any{
|
||||
"profiles": map[string]any{"profile": profile},
|
||||
"prompts": map[string]any{"workflow": map[string]any{
|
||||
"version": "v1",
|
||||
"profiles": []any{profile},
|
||||
"prompts": []any{map[string]any{"id": "workflow",
|
||||
"default_profile": "profile",
|
||||
"messages": []any{map[string]any{"role": "user", "content": message}},
|
||||
"output": output,
|
||||
@@ -128,7 +127,7 @@ func verifyAssembledWorkflowFixture(b *testing.B, fixture assembledWorkflowFixtu
|
||||
if err != nil {
|
||||
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 {
|
||||
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
||||
}
|
||||
|
||||
21
backends.go
21
backends.go
@@ -1,8 +1,8 @@
|
||||
package promptkit
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/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.
|
||||
const BackendLocal = "local"
|
||||
|
||||
// BackendConfig is a presence-aware convenience value for a keyed backend
|
||||
// BackendConfig is a presence-aware convenience value for an identified backend
|
||||
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
||||
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
||||
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
||||
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
||||
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
||||
type BackendConfig struct {
|
||||
// ID is the local backend identity within its resource list.
|
||||
ID string
|
||||
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
||||
Endpoint *string
|
||||
// APIKeyEnv optionally names an environment variable, not a credential.
|
||||
@@ -71,15 +73,16 @@ type AuthenticationProbeConfig struct {
|
||||
Endpoint *string
|
||||
}
|
||||
|
||||
// BackendSource creates a labeled ConfigKit mapping from keyed backend values.
|
||||
// BackendSource creates a labeled ConfigKit mapping containing backend records
|
||||
// in caller order. IDs are explicit and duplicates are left for loading to reject.
|
||||
// It encodes fields without validating them; NewEngine validates the final
|
||||
// effective configuration after all selected source layers are combined.
|
||||
// Mapping captures a copy of supported input values at creation; unsupported
|
||||
// values fail when the source is loaded.
|
||||
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source {
|
||||
definitions := make(map[string]any, len(backends))
|
||||
for id, config := range backends {
|
||||
fields := make(map[string]any)
|
||||
func BackendSource(label string, backends []BackendConfig) configkit.Source {
|
||||
definitions := make([]any, 0, len(backends))
|
||||
for _, config := range backends {
|
||||
fields := map[string]any{"id": config.ID}
|
||||
if config.Endpoint != nil {
|
||||
fields["endpoint"] = *config.Endpoint
|
||||
}
|
||||
@@ -119,7 +122,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
||||
}
|
||||
fields["smoke_tests"] = smokeTests
|
||||
}
|
||||
definitions[id] = fields
|
||||
definitions = append(definitions, fields)
|
||||
}
|
||||
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
||||
@@ -247,11 +247,12 @@ func newBackendCapacityEngine(
|
||||
promptID, content = "input-prompt", `{{input "input"}}`
|
||||
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 {
|
||||
backend["queue_capacity"] = *queueCapacity
|
||||
}
|
||||
definition := map[string]any{
|
||||
"id": promptID,
|
||||
"default_profile": "profile",
|
||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||
"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{
|
||||
Source: configkit.Mapping("capacity fixture", map[string]any{
|
||||
"backends": map[string]any{"limited": backend},
|
||||
"profiles": map[string]any{"profile": map[string]any{"backend": "limited", "model": "model"}},
|
||||
"prompts": map[string]any{promptID: promptDefinitionWithVersion("1", definition)},
|
||||
"backends": []any{backend},
|
||||
"profiles": []any{map[string]any{"id": "profile", "backend": "limited", "model": "model"}},
|
||||
"prompts": []any{definition},
|
||||
}),
|
||||
}}}, options...)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
||||
@@ -94,18 +94,14 @@ func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("compare presence and sequence values: %v", err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
||||
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||
t.Fatalf("atomic backend list difference = %+v", differences)
|
||||
}
|
||||
byPath := differencesByPath(differences)
|
||||
nullable, ok := byPath["/backends/local/extra_params/nullable"]
|
||||
if !ok || nullable.Left.Present || !nullable.Right.Present || strings.TrimSpace(nullable.Right.YAML) != "null" {
|
||||
t.Fatalf("absent-to-null difference = (%+v, %t)", nullable, ok)
|
||||
}
|
||||
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)
|
||||
difference := differences[0]
|
||||
if !difference.Left.Present || !difference.Right.Present ||
|
||||
strings.Contains(difference.Left.YAML, "nullable:") || !strings.Contains(difference.Right.YAML, "nullable: null") ||
|
||||
!strings.Contains(difference.Left.YAML, "earlier") || !strings.Contains(difference.Right.YAML, "later") {
|
||||
t.Fatalf("atomic sequence snapshots = %+v", difference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +119,19 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
||||
if err != nil {
|
||||
t.Fatalf("compare sensitive values: %v", err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
||||
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||
t.Fatalf("sensitive backend list difference = %+v", differences)
|
||||
}
|
||||
byPath := differencesByPath(differences)
|
||||
secret, ok := byPath["/backends/local/extra_params/token"]
|
||||
if !ok || !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||
t.Fatalf("sensitive difference = (%+v, %t)", secret, ok)
|
||||
secret := differences[0]
|
||||
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||
t.Fatalf("sensitive difference = %+v", secret)
|
||||
}
|
||||
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") {
|
||||
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
||||
}
|
||||
visible, ok := byPath["/backends/local/extra_params/visible"]
|
||||
if !ok || visible.Left.Redacted || visible.Right.Redacted || visible.HiddenChanged ||
|
||||
!strings.Contains(visible.Left.YAML, "before-value") || !strings.Contains(visible.Right.YAML, "after-value") {
|
||||
t.Fatalf("public sibling difference = (%+v, %t)", visible, ok)
|
||||
if !strings.Contains(secret.Left.YAML, "before-value") || !strings.Contains(secret.Right.YAML, "after-value") {
|
||||
t.Fatalf("public sibling missing from snapshots: %+v", secret)
|
||||
}
|
||||
|
||||
differences[0].Path = configkit.Keys("caller", "mutation")
|
||||
@@ -148,18 +141,15 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
||||
if err != nil {
|
||||
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
||||
}
|
||||
if len(again) != 2 {
|
||||
t.Fatalf("repeat difference count = %d, want 2: %+v", len(again), again)
|
||||
if len(again) != 1 {
|
||||
t.Fatalf("repeat difference count = %d, want 1: %+v", len(again), again)
|
||||
}
|
||||
againByPath := differencesByPath(again)
|
||||
if _, exists := againByPath["/caller/mutation"]; exists {
|
||||
t.Fatal("mutating returned path reached retained inspection")
|
||||
}
|
||||
if _, exists := againByPath["/backends/local/extra_params/token"]; !exists {
|
||||
t.Fatalf("original sensitive 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)
|
||||
if _, exists := againByPath["/backends"]; !exists {
|
||||
t.Fatalf("original backend path was lost after mutation: %+v", again)
|
||||
}
|
||||
for _, difference := range again {
|
||||
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
||||
@@ -183,7 +173,7 @@ func TestConfigurationInspectionDiffMasksSensitiveShapeReplacement(t *testing.T)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
difference := differences[0]
|
||||
@@ -202,16 +192,16 @@ func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationIns
|
||||
|
||||
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
||||
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 {
|
||||
backend["extra_params"] = extraParams
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
||||
"backends": map[string]any{"local": backend},
|
||||
"profiles": map[string]any{"offline": map[string]any{
|
||||
"backend": "local",
|
||||
"model": model,
|
||||
"backends": []any{backend},
|
||||
"profiles": []any{map[string]any{
|
||||
"id": "offline", "backend": "local",
|
||||
"model": model,
|
||||
}},
|
||||
})}}}},
|
||||
}})
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||
)
|
||||
|
||||
// ConfigurationInspection is a read-only view of one Engine's captured
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,31 +12,34 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
|
||||
"backends": []any{map[string]any{
|
||||
"id": "local", "endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
|
||||
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
||||
}},
|
||||
"collections": map[string]any{"literal": map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://example.invalid/v1",
|
||||
"extra_params": map[string]any{"token": "namespaced-private-sentinel"},
|
||||
}},
|
||||
"profiles": map[string]any{
|
||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
||||
"id": map[string]any{"extends": "base.v1"},
|
||||
"backends": []any{
|
||||
map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "namespaced-private-sentinel"}},
|
||||
map[string]any{"id": "child", "extends": map[string]any{"id": "local"}},
|
||||
},
|
||||
"profiles": []any{
|
||||
map[string]any{"id": "base.v1", "backend": "local", "model": "default-model"},
|
||||
map[string]any{"id": "id", "extends": map[string]any{"id": "base.v1"}},
|
||||
},
|
||||
}},
|
||||
})}}},
|
||||
{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"}},
|
||||
}},
|
||||
})}}},
|
||||
},
|
||||
}})
|
||||
@@ -46,29 +50,38 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model")
|
||||
inheritedPath := configkit.Keys("collections", "literal", "profiles").Index(1).Key("model")
|
||||
inherited, found := view.Explain(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)
|
||||
}
|
||||
if inheritedPath.String() != "/collections/literal/profiles/id/model" {
|
||||
if inheritedPath.String() != "/collections/literal/profiles/1/model" {
|
||||
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")
|
||||
secret, found := view.Explain(secretPath)
|
||||
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
||||
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
if err := view.WriteEffective(&effective); err != nil {
|
||||
t.Fatalf("write effective configuration: %v", err)
|
||||
@@ -104,9 +117,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) {
|
||||
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 {
|
||||
t.Fatalf("construct engine with accepted declaration: %v", err)
|
||||
@@ -117,7 +147,7 @@ func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T
|
||||
}
|
||||
path := configkit.Keys("version")
|
||||
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)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
@@ -150,9 +180,8 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
||||
t.Fatalf("zero explanation writer = %v", err)
|
||||
}
|
||||
|
||||
endpoint, model := "https://configured.example/v1", "configured-model"
|
||||
profile := configkit.Input{Source: promptkit.ProfileSource("inspection test profile", map[string]promptkit.ProfileConfig{
|
||||
"inspection-profile": {Endpoint: &endpoint, Model: &model},
|
||||
profile := configkit.Input{Source: configkit.Mapping("inspection test profile", map[string]any{
|
||||
"profiles": []any{map[string]any{"id": "inspection-profile", "endpoint": "https://configured.example/v1", "model": "configured-model"}},
|
||||
})}
|
||||
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
||||
@@ -169,14 +198,15 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
||||
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||
}}
|
||||
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
||||
@@ -184,7 +214,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
t.Fatalf("select inputs: %v", err)
|
||||
}
|
||||
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")},
|
||||
}}
|
||||
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
||||
@@ -195,16 +225,16 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
client := countingLLMClient{calls: &modelCalls}
|
||||
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
||||
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{
|
||||
"offline": {Endpoint: &endpoint, Model: &model},
|
||||
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("offline profile", map[string]any{
|
||||
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": model}},
|
||||
})}}}},
|
||||
}}, promptkit.WithLLMClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
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{
|
||||
"offline": {Endpoint: &endpoint, Model: &otherModel},
|
||||
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("comparison profile", map[string]any{
|
||||
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": otherModel}},
|
||||
})}}}},
|
||||
}}, promptkit.WithLLMClient(client))
|
||||
if err != nil {
|
||||
@@ -226,7 +256,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
if err != nil || len(differences) != 1 {
|
||||
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].Right.YAML, otherModel) {
|
||||
t.Fatalf("captured difference = %+v", differences[0])
|
||||
@@ -238,7 +268,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
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")
|
||||
}
|
||||
var report bytes.Buffer
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||
)
|
||||
|
||||
// CollectionInput mounts source at the selected collection's configuration
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
type sourceDocument struct {
|
||||
@@ -37,10 +37,10 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "collection.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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{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"),
|
||||
},
|
||||
}})
|
||||
@@ -58,15 +58,15 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
||||
t.Fatalf("construct engine with native collection import: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\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")},
|
||||
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\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)
|
||||
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{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{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"),
|
||||
},
|
||||
}})
|
||||
@@ -85,7 +85,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||
t.Fatalf("construct engine with embedded collection import: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||
@@ -38,18 +38,19 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||
}
|
||||
cfg := simpleConfig("hello")
|
||||
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
||||
endpoint, model := "http://example.test/v1", "model"
|
||||
lowerName := name
|
||||
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
||||
Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{
|
||||
"profile": {APIKeyEnv: &lowerName},
|
||||
Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{
|
||||
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &lowerName},
|
||||
}),
|
||||
}}})
|
||||
env := " " + name + " "
|
||||
if test.clear {
|
||||
env = ""
|
||||
}
|
||||
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{
|
||||
"profile": {APIKeyEnv: &env},
|
||||
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{
|
||||
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &env},
|
||||
})}
|
||||
var calls int
|
||||
var auth string
|
||||
@@ -73,21 +74,23 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
||||
backendID, model := "workstation", "offline-model"
|
||||
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
||||
"prompts": map[string]any{"hello": map[string]any{
|
||||
"version": "1",
|
||||
"prompts": []any{map[string]any{
|
||||
"id": "hello",
|
||||
"default_profile": "offline",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
}},
|
||||
})}
|
||||
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
||||
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||
localBackend.ID = backendID
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||
{Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||
localBackend,
|
||||
})},
|
||||
{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
||||
"offline": {Backend: &backendID, Model: &model},
|
||||
{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||
{ID: "offline", Backend: &backendID, Model: &model},
|
||||
})},
|
||||
}}},
|
||||
Main: &prompt,
|
||||
@@ -113,13 +116,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
|
||||
model := "configured-model"
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{
|
||||
openRouterID: {Endpoint: &openRouterEndpoint},
|
||||
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
|
||||
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
|
||||
{ID: openRouterID, Endpoint: &openRouterEndpoint},
|
||||
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
|
||||
})},
|
||||
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
|
||||
"openrouter-profile": {Backend: &openRouterID, Model: &model},
|
||||
"rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model},
|
||||
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
|
||||
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
|
||||
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
|
||||
})},
|
||||
}}},
|
||||
}})
|
||||
@@ -141,12 +144,11 @@ func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *test
|
||||
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
||||
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
||||
profiles:
|
||||
application:
|
||||
- id: application
|
||||
endpoint: https://example.invalid/v1
|
||||
model: default-model
|
||||
prompts:
|
||||
hello:
|
||||
version: "1"
|
||||
- id: hello
|
||||
default_profile: application
|
||||
messages:
|
||||
- role: user
|
||||
@@ -160,16 +162,16 @@ prompts:
|
||||
t.Fatalf("select embedded defaults: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||
if err != nil {
|
||||
t.Fatalf("select operator inputs: %v", err)
|
||||
}
|
||||
programmaticModel := "programmatic-model"
|
||||
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{
|
||||
"application": {Model: &programmaticModel},
|
||||
programmaticModel, programmaticEndpoint := "programmatic-model", "https://example.invalid/v1"
|
||||
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{
|
||||
{ID: "application", Endpoint: &programmaticEndpoint, Model: &programmaticModel},
|
||||
})}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -205,7 +207,7 @@ prompts:
|
||||
|
||||
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
||||
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
|
||||
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"
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
||||
backendID: {Endpoint: &endpoint},
|
||||
{Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
|
||||
{ID: backendID, Endpoint: &endpoint},
|
||||
})},
|
||||
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
|
||||
"application-profile": {Backend: &backendID, Model: &model},
|
||||
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
|
||||
{ID: "application-profile", Backend: &backendID, Model: &model},
|
||||
})},
|
||||
}}},
|
||||
}})
|
||||
@@ -251,8 +253,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
||||
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
||||
authenticationKind := " OPENROUTER_KEY "
|
||||
authenticationEndpoint := "https://probe.example/key?scope=current"
|
||||
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
|
||||
"local": {
|
||||
source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
|
||||
{
|
||||
ID: "local",
|
||||
Endpoint: &backendEndpoint,
|
||||
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
||||
DefaultProfile: &defaultProfile,
|
||||
@@ -288,7 +291,7 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
||||
"https://backend.example/v1",
|
||||
"not-yet-listed",
|
||||
"https://probe.example/ready?check=1",
|
||||
"openrouter_key",
|
||||
" OPENROUTER_KEY ",
|
||||
"https://probe.example/key?scope=current",
|
||||
} {
|
||||
if !strings.Contains(contents, want) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
type fakeLLMClient struct {
|
||||
@@ -60,11 +60,10 @@ func newSimpleEngine(t *testing.T, content string, options ...promptkit.Option)
|
||||
func simpleConfig(content string) promptkit.Config {
|
||||
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
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",
|
||||
}},
|
||||
"prompts": map[string]any{"prompt": map[string]any{
|
||||
"version": "1",
|
||||
"prompts": []any{map[string]any{"id": "prompt",
|
||||
"default_profile": "profile",
|
||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"reflect"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||
@@ -72,7 +72,6 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
||||
}
|
||||
return &PreparedRun{
|
||||
PromptID: prepared.PromptID,
|
||||
PromptVersion: copyStringPtr(prepared.PromptVersion),
|
||||
PromptHash: prepared.PromptHash,
|
||||
SelectedProfileID: prepared.SelectedProfileID,
|
||||
SelectedBackendID: prepared.SelectedBackendID,
|
||||
@@ -99,7 +98,6 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
||||
RawOutput: result.RawOutput,
|
||||
Validation: fromDomainValidationResult(result.Validation),
|
||||
PromptID: result.PromptID,
|
||||
PromptVersion: copyStringPtr(result.PromptVersion),
|
||||
PromptHash: result.PromptHash,
|
||||
SessionID: result.SessionID,
|
||||
RenderedPromptHash: result.RenderedPromptHash,
|
||||
@@ -257,7 +255,6 @@ func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInsp
|
||||
}
|
||||
return &PromptInspection{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: copyStringPtr(inspection.PromptVersion),
|
||||
PromptHash: inspection.PromptHash,
|
||||
DefaultProfileID: inspection.DefaultProfileID,
|
||||
Inputs: inputs,
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestToDomainAppendedMessages(t *testing.T) {
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
# Configuration And API Migration
|
||||
|
||||
Promptkit no longer installs a provider catalog or selects prompt definitions
|
||||
by descriptive version labels. Applications now choose configuration inputs
|
||||
explicitly, and execution selects one prompt by its exact ID. This guide
|
||||
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
|
||||
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
|
||||
and removes prompt-version labels.
|
||||
Promptkit does not install a provider catalog; applications choose configuration
|
||||
inputs explicitly, and execution selects one prompt by its exact ID. This guide
|
||||
explains the shape changes and decisions an application must make; it is not an
|
||||
automatic migration tool. The [framework format reference](../formats.md) owns
|
||||
the current configuration contract, and the root package GoDoc owns exact API
|
||||
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
|
||||
|
||||
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
|
||||
qualified. For example, `collections.openrouter.profiles.gemini-flash-latest`
|
||||
is selected as `openrouter/gemini-flash-latest`. Stored backend, prompt-default,
|
||||
and other resource references must use their exact intended IDs; Promptkit does
|
||||
not rewrite references when a source is mounted. Profile `extends` remains a
|
||||
sibling local key in the same collection, not a cross-collection reference.
|
||||
Collection resource IDs are local IDs, while their runtime identities are
|
||||
qualified. For example, a profile with `id: gemini-flash-latest` in collection
|
||||
`openrouter` is selected as `openrouter/gemini-flash-latest`. Stored backend,
|
||||
prompt-default, and other resource references must use their exact intended IDs;
|
||||
Promptkit does not rewrite references when a source is mounted. `extends`
|
||||
selects a sibling ID in the same list, not a cross-collection reference.
|
||||
Review collisions and all references when moving resources into collections.
|
||||
|
||||
The root `version` is an optional ConfigKit source header. A collection's
|
||||
`release_version` and a prompt's `version` are optional descriptive labels;
|
||||
neither selects a resource or changes execution. Preserve a label only when it
|
||||
is useful to your application. A formerly version-keyed prompt mapping must be
|
||||
reduced to one deliberately chosen definition per prompt ID, with its old label
|
||||
retained as prompt metadata if desired. Promptkit cannot choose a winner from a
|
||||
consumer's version history.
|
||||
The optional source-root `version` header now accepts `2` or `"2"`; headerless
|
||||
sources remain valid. A collection's `release_version` remains an optional
|
||||
label. Prompt-version labels have been removed: choose one definition per ID
|
||||
and store any application-specific release label outside Promptkit. In a
|
||||
standalone imported prompt item, root `version` is a source header, not a label.
|
||||
|
||||
For example, change a version-keyed definition:
|
||||
|
||||
@@ -60,12 +84,11 @@ prompts:
|
||||
validation_mode: none
|
||||
```
|
||||
|
||||
to one direct definition. Its optional `version` is only a label:
|
||||
to one identified definition:
|
||||
|
||||
```yaml
|
||||
prompts:
|
||||
report:
|
||||
version: "2.0"
|
||||
- id: report
|
||||
messages:
|
||||
- role: user
|
||||
content: Review this report.
|
||||
@@ -74,6 +97,51 @@ prompts:
|
||||
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 composed values and provenance, while `InspectProfile` and
|
||||
other interpreted views expose Promptkit normalization and defaults; 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
|
||||
|
||||
The reviewed catalog identities have slash-free profile keys. Their documented
|
||||
@@ -100,8 +168,8 @@ Gemini profile in the same `openrouter` collection:
|
||||
collections:
|
||||
openrouter:
|
||||
profiles:
|
||||
standard:
|
||||
extends: gemini-flash-latest
|
||||
- id: standard
|
||||
extends: {id: gemini-flash-latest}
|
||||
```
|
||||
|
||||
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:
|
||||
openrouter:
|
||||
profiles:
|
||||
auto:
|
||||
- id: auto
|
||||
backend: openrouter/default
|
||||
model: openrouter/auto
|
||||
```
|
||||
@@ -142,10 +210,8 @@ if err != nil {
|
||||
```
|
||||
|
||||
If the former format contained multiple versions under one ID, choose the one
|
||||
definition the application should install, and optionally keep its label in
|
||||
the prompt's `version` field. That label remains descriptive metadata and is
|
||||
reported by inspection or results where applicable; it never chooses the
|
||||
definition.
|
||||
definition the application should install. Promptkit no longer retains or
|
||||
returns its former descriptive label.
|
||||
|
||||
The public inventory APIs can help an operator inspect a captured effective
|
||||
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
||||
|
||||
@@ -12,9 +12,15 @@ file contracts.
|
||||
Import the package as:
|
||||
|
||||
```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.0.0. Its types are
|
||||
not interchangeable with the old ConfigKit module path.
|
||||
|
||||
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
||||
demonstrates explicit collection composition and inventory. Applications
|
||||
adapting older provider or versioned-prompt configuration can use the
|
||||
@@ -28,7 +34,8 @@ programs.
|
||||
## Go Compatibility
|
||||
|
||||
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
|
||||
does not simply track that patch version.
|
||||
|
||||
@@ -108,8 +115,8 @@ defaults, err := promptkit.FilesystemInputs("embedded defaults", applicationFS,
|
||||
if err != nil { return err }
|
||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||
if err != nil { return err }
|
||||
profile := promptkit.ProfileSource("application choices", map[string]promptkit.ProfileConfig{
|
||||
"summary": {Model: &selectedModel},
|
||||
profile := promptkit.ProfileSource("application choices", []promptkit.ProfileConfig{
|
||||
{ID: "summary", Endpoint: &selectedEndpoint, Model: &selectedModel},
|
||||
})
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{
|
||||
@@ -120,7 +127,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
|
||||
`.yaml` and `.yml` files recursively and disables imports by default. The
|
||||
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
||||
@@ -131,8 +140,8 @@ labeled mapping as an input. A mapping owns a snapshot of supported values:
|
||||
|
||||
```go
|
||||
document := configkit.Mapping("application configuration", map[string]any{
|
||||
"profiles": map[string]any{
|
||||
"local-summary": map[string]any{
|
||||
"profiles": []any{
|
||||
map[string]any{"id": "local-summary",
|
||||
"backend": "local", "model": "example-model",
|
||||
"temperature": nil, // Suppress an inherited provider control.
|
||||
},
|
||||
@@ -202,14 +211,15 @@ sensitive application data.
|
||||
## Inspect The Captured Configuration
|
||||
|
||||
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
||||
operator-selected report of effective configuration, source descriptions,
|
||||
diagnostics, and path history. For a literal ID containing a slash, construct
|
||||
the semantic path with `configkit.Keys`:
|
||||
operator-selected report of composed configuration, source descriptions,
|
||||
diagnostics, and path history. Configuration inspection uses list indexes in
|
||||
the collection's nested path rather than qualified runtime IDs. For a profile
|
||||
at index `i` in collection `team`, construct the path with `configkit.Keys`:
|
||||
|
||||
```go
|
||||
view, err := engine.InspectConfiguration()
|
||||
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)
|
||||
if found && explanation.Effective.Redacted {
|
||||
// Apply the application's disclosure policy before sharing the report.
|
||||
@@ -220,8 +230,9 @@ if found && explanation.Effective.Redacted {
|
||||
the application decides whether those reports may be disclosed. Credential-
|
||||
shaped provider keys are redacted, but arbitrary prompt and provider content
|
||||
is not necessarily safe to share. Inspection uses the engine's captured result
|
||||
and does not reopen sources or resolve credentials. `InspectProfile` instead
|
||||
shows backend-completed execution settings for one profile, and `Prepare` or
|
||||
and does not reopen sources or resolve credentials. It retains composed values
|
||||
and provenance rather than Promptkit's normalized/defaulted interpretation.
|
||||
`InspectProfile` instead shows backend-completed execution settings for one profile, and `Prepare` or
|
||||
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
||||
and validation resources. None of those views is a substitute for the others.
|
||||
|
||||
@@ -252,7 +263,7 @@ for _, difference := range differences {
|
||||
```
|
||||
|
||||
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.0.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md#effective-configuration-diffs)
|
||||
for the exact comparison contract. The operation compares only frozen effective
|
||||
configuration values: it does not reread external files or compare their
|
||||
contents, resolved credential contents, later execution preparation, or
|
||||
@@ -620,7 +631,7 @@ copy, and credential behavior. The
|
||||
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
||||
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
|
||||
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
||||
|
||||
@@ -633,13 +644,13 @@ and `WithBackend` options have no compatibility wrappers. Use
|
||||
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
||||
ConfigKit file inputs for individual files, and `ProfileSource`,
|
||||
`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
|
||||
highest-priority input.
|
||||
|
||||
Move embedded IDs into keyed `profiles` and `prompts` maps; each prompt ID now
|
||||
contains one installed definition, with any descriptive `version` label inside
|
||||
that definition. Convert `base_profile` to ConfigKit `extends`. For the current
|
||||
Move resource IDs into identified `backends`, `profiles`, and `prompts` lists;
|
||||
each prompt ID identifies one installed definition without a version label.
|
||||
Convert `base_profile` to ConfigKit `extends: {id: parent}`. For the current
|
||||
collection identity mapping, ID-only prompt selection, and required
|
||||
caller-owned choices, see the
|
||||
[configuration migration guide](configuration-migration.md). Exact current
|
||||
@@ -655,7 +666,7 @@ application selects. PromptKit does not install provider catalogs or built-in
|
||||
profile inventories; conventional backend ID constants are names only. Define
|
||||
the model, endpoint, credentials lookup, and settings that your application
|
||||
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
|
||||
[format reference](../formats.md) describes the definition fields and
|
||||
composition rules.
|
||||
@@ -728,8 +739,8 @@ it and shared backend identity or capacity policy is unnecessary:
|
||||
|
||||
```go
|
||||
endpoint, model := "http://localhost:8000/v1", "example-model"
|
||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
||||
"local-summary": {Endpoint: &endpoint, Model: &model},
|
||||
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||
{ID: "local-summary", Endpoint: &endpoint, Model: &model},
|
||||
})
|
||||
// Add configkit.Input{Source: profile} to a caller underlay.
|
||||
```
|
||||
@@ -744,11 +755,13 @@ identity, endpoint, and concurrency limit:
|
||||
|
||||
```go
|
||||
backendID, model := promptkit.BackendLocal, "example-model"
|
||||
backend := promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||
localBackend := 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{
|
||||
"local-summary": {Backend: &backendID, Model: &model},
|
||||
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||
{ID: "local-summary", Backend: &backendID, Model: &model},
|
||||
})
|
||||
// Add both sources as inputs in a caller underlay.
|
||||
```
|
||||
@@ -759,13 +772,14 @@ capacity of 1024.
|
||||
|
||||
#### 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:
|
||||
|
||||
```go
|
||||
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
||||
backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendConfig{
|
||||
"local-gpu": {
|
||||
backend := promptkit.BackendSource("GPU backend", []promptkit.BackendConfig{
|
||||
{
|
||||
ID: "local-gpu",
|
||||
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
||||
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
||||
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||
@@ -774,7 +788,7 @@ backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendCo
|
||||
```
|
||||
|
||||
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
|
||||
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
||||
defines exact omission and construction behavior.
|
||||
@@ -804,7 +818,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.
|
||||
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
||||
profiles and custom backends without a configured limit remain unrestricted.
|
||||
|
||||
@@ -67,7 +67,6 @@ the maintained examples when they are affected:
|
||||
go test ./...
|
||||
go vet ./...
|
||||
gofmt -w path/to/changed.go
|
||||
python3 scripts/check-examples.py
|
||||
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
|
||||
VERSION`; this is not a required pre-publication step.
|
||||
|
||||
The standalone `python3 scripts/check-examples.py` command checks semantic
|
||||
expectations for the three offline consumer examples. The focused
|
||||
`python3 scripts/test-release.py` command exercises the release tooling against
|
||||
disposable local repositories; use it when changing that tooling, not as a
|
||||
recurring release gate. Tests and examples require no provider credentials or
|
||||
live inference calls.
|
||||
The example workflows are covered by the Go suite; use
|
||||
`go test ./examples/go-library/...` to run them separately during development.
|
||||
The focused `python3 scripts/test-release.py` command exercises the release
|
||||
tooling against disposable local repositories; use it when changing that
|
||||
tooling, not as a recurring release gate. Tests and examples require no provider
|
||||
credentials or live inference calls.
|
||||
|
||||
Ordinary validation can run on intended uncommitted work. Release validation
|
||||
uses committed candidate contents and the selected local Go installation.
|
||||
|
||||
102
docs/formats.md
102
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
|
||||
how to select sources and use an engine; the
|
||||
[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.0.0/docs/config.md)
|
||||
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||
This reference describes what Promptkit does with the final effective values.
|
||||
|
||||
A configuration document may contain keyed top-level `backends`, `profiles`,
|
||||
and `prompts` mappings, plus a `collections` mapping of namespaced resource
|
||||
groups. Resource keys are literal IDs, not filename-derived identities. Each
|
||||
prompt ID maps directly to one definition; an optional `version` string is
|
||||
descriptive metadata, not a selection key. Keys and references must be nonblank
|
||||
and remain exact: Promptkit does not trim or re-key them. Old embedded `id` and
|
||||
`base_profile` fields are invalid. Unknown fields are errors. Promptkit installs
|
||||
no provider or profile catalog; callers select the definitions they need.
|
||||
A configuration document may contain top-level `backends`, `profiles`, and
|
||||
`prompts` lists, plus a `collections` mapping of namespaced resource groups.
|
||||
Each resource has an explicit literal `id`, not a filename-derived identity.
|
||||
IDs and references must be nonblank and remain exact: Promptkit does not trim or
|
||||
re-key them. Unknown fields are errors. Promptkit installs no provider or
|
||||
profile catalog; callers select the definitions they need.
|
||||
|
||||
```yaml
|
||||
backends:
|
||||
local:
|
||||
- id: local
|
||||
endpoint: http://localhost:8000/v1
|
||||
concurrency_limit: 2
|
||||
profiles:
|
||||
local-summary:
|
||||
- id: local-summary
|
||||
backend: local
|
||||
model: example-model
|
||||
prompts:
|
||||
meeting.summary:
|
||||
version: "1.0.0"
|
||||
- id: meeting.summary
|
||||
default_profile: local-summary
|
||||
messages:
|
||||
- role: user
|
||||
@@ -42,17 +39,17 @@ collections:
|
||||
support:
|
||||
release_version: "1.4.0"
|
||||
backends:
|
||||
offline:
|
||||
- id: offline
|
||||
endpoint: https://example.invalid/v1
|
||||
profiles:
|
||||
base:
|
||||
- id: base
|
||||
backend: support/offline
|
||||
model: offline-model
|
||||
fast:
|
||||
extends: base
|
||||
- id: fast
|
||||
extends: {id: base}
|
||||
timeout_seconds: 30
|
||||
prompts:
|
||||
answer:
|
||||
- id: answer
|
||||
default_profile: support/fast
|
||||
messages:
|
||||
- role: user
|
||||
@@ -66,31 +63,33 @@ collections:
|
||||
|
||||
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
|
||||
`"1"` or strict decimal integer `1`; null and other values are invalid. This
|
||||
header is distinct from a prompt's nested `version` label and a collection's
|
||||
`release_version`. ConfigKit checks and consumes accepted headers on non-main
|
||||
`"2"` or strict decimal integer `2`; null and other values are invalid. This
|
||||
header is distinct from a collection's `release_version` and ConfigKit's own
|
||||
software version. ConfigKit checks and consumes accepted headers on non-main
|
||||
inputs; a declaration on the main input remains visible in explicit
|
||||
configuration inspection. Headerless inputs still undergo ordinary final-value
|
||||
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
|
||||
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
|
||||
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.
|
||||
For example, the `support/fast` profile above stores `support/offline` as its
|
||||
backend ID. In `collections.support.profiles.fast`, ConfigKit `extends: base`
|
||||
refers to a sibling profile in that same mapping. It is not a qualified or
|
||||
cross-collection reference.
|
||||
backend ID. `extends: {id: base}` refers to a sibling in the same resource
|
||||
list. It is not a qualified or cross-collection reference.
|
||||
|
||||
`release_version` and prompt `version` are optional opaque labels. Absent or
|
||||
null means no label; an explicitly empty string is retained as a declared
|
||||
label. Neither label selects a resource or changes execution. Runtime lookups
|
||||
`release_version` is an optional opaque collection label. Absent or null means
|
||||
no label; an explicitly empty string is retained. It does not select a resource
|
||||
or change execution. Runtime lookups
|
||||
use the captured effective resource indexes. `InspectConfiguration` is an
|
||||
explicit operator view of effective settings and provenance; source history is
|
||||
not a second runtime resolver, and the retained effective shape is not flattened
|
||||
or rewritten into those indexes.
|
||||
explicit operator view of composed settings and provenance. Normalization and
|
||||
defaults appear in Promptkit's interpreted execution views, not as changes to
|
||||
that composed tree. Source history is not a second runtime resolver, and the
|
||||
retained effective shape is not flattened or rewritten into those indexes.
|
||||
|
||||
For examples of explicitly selecting native collection inputs and inspecting
|
||||
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
||||
@@ -116,12 +115,11 @@ see the consumer guide's [resource and collection listing](consumers/pkg-promptk
|
||||
|
||||
## 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
|
||||
prompts:
|
||||
meeting.summary:
|
||||
version: "1.0.0"
|
||||
- id: meeting.summary
|
||||
default_profile: local-summary
|
||||
description: Summarize a synthetic meeting note.
|
||||
session_id: '{{.session}}'
|
||||
@@ -237,7 +235,8 @@ It may contain `default_profile`, `reachability`, and `authentication`:
|
||||
|
||||
```yaml
|
||||
backends:
|
||||
openrouter:
|
||||
- id: openrouter
|
||||
endpoint: https://openrouter.ai/api/v1
|
||||
smoke_tests:
|
||||
default_profile: application-profile
|
||||
reachability:
|
||||
@@ -266,21 +265,22 @@ These values are configuration only: engine construction does not contact a
|
||||
backend.
|
||||
|
||||
These fields are resolved through the ordinary ConfigKit source layers before
|
||||
Promptkit validates the final effective values. A higher-priority mapping can
|
||||
override individual nested fields; an explicit null can clear the whole
|
||||
`smoke_tests` block or one of its nested objects. An endpoint set to null uses
|
||||
Promptkit validates the final effective values. Sibling inheritance can overlay
|
||||
individual nested fields; a later same-ID resource replaces the whole record.
|
||||
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
|
||||
metadata does not change ordinary generation credential requirements or
|
||||
provider settings.
|
||||
|
||||
## 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`:
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
local-summary:
|
||||
- id: local-summary
|
||||
backend: local
|
||||
model: example-model
|
||||
temperature: 0.2
|
||||
@@ -288,8 +288,8 @@ profiles:
|
||||
timeout_seconds: 90
|
||||
extra_params:
|
||||
provider_option: enabled
|
||||
local-summary-fast:
|
||||
extends: local-summary
|
||||
- id: local-summary-fast
|
||||
extends: {id: local-summary}
|
||||
timeout_seconds: 30
|
||||
reasoning_effort: low
|
||||
```
|
||||
@@ -343,25 +343,27 @@ nothing. Imports are disabled by these helpers unless the caller enables them
|
||||
on an input. Single files can use ConfigKit inputs directly.
|
||||
|
||||
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
|
||||
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
|
||||
neither is selected, preparation fails. Exact profile inspection selects one
|
||||
explicit ID and does not consult a prompt default.
|
||||
|
||||
### Profile Inheritance
|
||||
### Resource Inheritance
|
||||
|
||||
ConfigKit resolves `extends` on effective profile definitions. One profile
|
||||
may inherit a sibling profile by local key in the same root or collection
|
||||
mapping; collection inheritance cannot cross namespaces. Presence, explicit
|
||||
ConfigKit resolves `extends: {id: parent}` for backends, profiles, and prompts.
|
||||
Each child inherits a surviving sibling in the same root or collection list;
|
||||
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
|
||||
references are exact IDs and use their qualified form when they select a
|
||||
collection resource. There is no Promptkit-specific depth limit or second
|
||||
base-chain resolver. Missing references and cycles fail at construction. For
|
||||
generic inheritance and layer mechanics, consult
|
||||
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit).
|
||||
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
|
||||
|
||||
## Schemas
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ other content. Events are values with no mutable containers. Callback panics
|
||||
propagate to the caller.
|
||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||
the invocation, including namespace qualification when supplied by an engine
|
||||
view. A prompt's optional version label is descriptive metadata, not a
|
||||
selection key.
|
||||
view.
|
||||
Per-operation callbacks are ordered and synchronous; different operations may
|
||||
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||
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
|
||||
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
||||
through, while configured defaults and stored references are used as authored
|
||||
and are not requalified. Prompt version labels are returned as optional
|
||||
descriptive metadata; they are not prompt selectors.
|
||||
and are not requalified.
|
||||
|
||||
Ordinary preparation selects the full prompt definition and construction-time
|
||||
hash. It retains its early request-ID check before direct-session
|
||||
|
||||
@@ -12,8 +12,8 @@ source semantics. Internal packages are not consumer APIs.
|
||||
|
||||
`internal/configuration` is the single adapter from ConfigKit's final
|
||||
effective configuration to execution-owned definitions. It performs one
|
||||
layered load, captures effective presence (including explicit null), records
|
||||
normalization and defaults through resolution, and validates all effective
|
||||
layered load, captures effective presence (including explicit null), builds
|
||||
detached normalized interpretations during validation, and validates all effective
|
||||
root and collection backend, profile, and prompt values. Replaced lower-layer
|
||||
domain values are not separately validated. It indexes root resources under
|
||||
their unqualified IDs and collection resources under their full
|
||||
@@ -27,22 +27,22 @@ do not depend on ConfigKit.
|
||||
ConfigKit owns source headers, imports, composition, and source evidence.
|
||||
Promptkit does not preparse contributions: it validates the final effective
|
||||
values once, captures the indexed identities and their original resource
|
||||
contexts, and retains ConfigKit's result for explicit operator inspection.
|
||||
contexts, and retains ConfigKit's composed result for explicit operator inspection.
|
||||
Accepted non-main source headers are consumed during composition; the main
|
||||
source declaration remains available in that inspection view. Prompt and
|
||||
collection release labels are descriptive metadata, not selection inputs. The
|
||||
source declaration remains available in that inspection view. Collection
|
||||
release labels are descriptive metadata, not selection inputs. The
|
||||
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
||||
owns the exact source-header and collection contracts.
|
||||
|
||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||
once to ConfigKit v0.8.0 and returns its public difference records. Comparison
|
||||
once to ConfigKit v1.0.0 and returns its public difference records. Comparison
|
||||
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||
both results; reports may still contain ordinary application data. See the
|
||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||
and ConfigKit's [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.0.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md#effective-configuration-diffs).
|
||||
|
||||
After resource materialization and effective-profile snapshot construction, the
|
||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
||||
@@ -85,10 +85,12 @@ returning and retains no source capability for public inspection.
|
||||
Prompt materialization shares a lazy metadata snapshot across root and collection
|
||||
resources during construction, avoiding a full metadata copy per collection.
|
||||
|
||||
Role and cache-control normalization is applied to execution-owned message
|
||||
copies. ConfigKit v0.8.0 retains effective input message metadata and its
|
||||
resource origins; recording a sequence-wide normalization in the retained
|
||||
configuration would replace origins of unchanged content-file fields.
|
||||
Normalization and defaults are applied to execution-owned interpretations,
|
||||
including message roles and cache controls. ConfigKit retains the composed
|
||||
values and origins, rather than synthetic Promptkit normalization events. List
|
||||
indexes identify fields for presence, validation, sensitivity, and resource
|
||||
context; full IDs select captured runtime indexes. Recording a sequence-wide
|
||||
normalization would replace origins of unchanged content-file fields.
|
||||
|
||||
The runner selects full definitions from this index for preparation and uses
|
||||
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
||||
@@ -142,7 +144,7 @@ change rendering behavior.
|
||||
## Profiles
|
||||
|
||||
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
|
||||
field values, then builds target
|
||||
snapshots with backend defaults and presence/suppression state. A profile can
|
||||
|
||||
@@ -26,17 +26,17 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
|
||||
## ConfigKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
||||
Selected module: `go.maximumdirect.net/configkit` at `v1.0.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
||||
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53/COPYRIGHT) |
|
||||
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
||||
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/c88aac990b23f48a31f9046b830d4d0954f57f53).
|
||||
|
||||
## jsonschema
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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)
|
||||
|
||||
@@ -221,7 +221,8 @@ Promptkit retains its LGPL-3.0-or-later library grant in
|
||||
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
||||
scope identified there and in [attribution](../oss/attribution.md).
|
||||
|
||||
Promptkit publishes source-only Go module tags on Gitea, with no binaries or
|
||||
separate hosted download page. The [release procedure](../release.md) owns
|
||||
publication and concrete footer URLs. The hosted-page guidance applies if such
|
||||
a channel is deliberately introduced; it does not require creating one.
|
||||
Promptkit publishes source-only Go module tags and Gitea release pages, with no
|
||||
binaries. Each new hosted page reuses the complete checked-in release note,
|
||||
including its licensing footer. The [release procedure](../release.md) owns
|
||||
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
|
||||
|
||||
**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)
|
||||
|
||||
@@ -241,9 +241,13 @@ navigation on 2026-09-26. Apply them prospectively; previously published notes
|
||||
and tags retain their original records.
|
||||
|
||||
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
|
||||
asynchronous deliverables. The source commit is pushed through main before the
|
||||
tag. The [release procedure](../release.md) owns commands and recovery.
|
||||
annotated stable SemVer tags. The source commit is pushed through main before
|
||||
the tag. After source publication, maintainers use ReleaseKit locally to deliver
|
||||
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
|
||||
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
|
||||
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
||||
committed Go formatting, local Markdown links, workspace and filesystem
|
||||
replacement inspection, and the offline semantic checks for the three
|
||||
maintained consumer examples. These are the selected checks for the final
|
||||
candidate; a separate ordinary test run or standalone build is not an
|
||||
replacement inspection. The Go suite includes the offline semantic checks for
|
||||
the three maintained consumer examples. It also rejects the configured local
|
||||
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
|
||||
publication checks. It returns native status 0 for confirmed publication, 1
|
||||
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
|
||||
instructions. Do not wait for CI or Go module availability. Licensing footer
|
||||
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
|
||||
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
|
||||
machinery and publication workflow. The test run builds the packages; the
|
||||
examples also exercise ordinary compilation. Separate ordinary tests and a
|
||||
machinery and publication workflow. The test run builds the packages and
|
||||
exercises the examples. Separate ordinary tests and a
|
||||
standalone build are not additional release gates. Ordinary tests remain useful
|
||||
during development. The preparation example protects assembled preparation and
|
||||
inspection behavior. The execution example separately protects assembled
|
||||
|
||||
180
docs/release.md
180
docs/release.md
@@ -1,26 +1,26 @@
|
||||
# Release Procedure
|
||||
|
||||
This runbook implements the [release policy](policy/release.md). Promptkit
|
||||
publishes source-only Go module tags from `main` to `origin`. A successful push
|
||||
of the selected annotated tag, after its source commit is available on `main`,
|
||||
completes the release. Do not wait for CI or Go module availability.
|
||||
publishes source-only Go module tags from `main` to `origin`, followed by a
|
||||
page-only Gitea release. Successful publication of the intended tag, with its
|
||||
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
|
||||
|
||||
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
||||
3. Work from the repository root with push access to `origin`.
|
||||
Required tools are Git, Go with race-detector support, and POSIX shell. Work
|
||||
from the repository root with push access to `origin`.
|
||||
|
||||
Use the latest Go installation available locally for the release tool, the
|
||||
candidate validation, and the maintained examples. Make it the active `go`
|
||||
command and record its version:
|
||||
Use the latest Go installation available locally for the release tool and its
|
||||
child commands. Put it first on `PATH` and keep it selected throughout:
|
||||
|
||||
```sh
|
||||
export GOTOOLCHAIN=local
|
||||
go version
|
||||
```
|
||||
|
||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
||||
release of that Go series before committing the candidate:
|
||||
For a minor release, normally advance the consumer minimum to the initial
|
||||
stable patch of that Go series:
|
||||
|
||||
```sh
|
||||
release_go_version=$(go env GOVERSION)
|
||||
@@ -29,105 +29,119 @@ release_go_series=${release_go_series%.*}
|
||||
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
|
||||
minimum if a dependency or identified correctness fix requires it. For a patch
|
||||
release, leave the consumer minimum unchanged; using a newer validation
|
||||
toolchain does not itself raise that minimum. If the candidate needs a higher
|
||||
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:
|
||||
Retain a higher patch minimum when required by a dependency or identified fix.
|
||||
For a patch release, preserve the existing minimum. Explain minimum increases
|
||||
under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
||||
Prepare dependency caches before committing the candidate:
|
||||
|
||||
```sh
|
||||
GOTOOLCHAIN=local go mod download
|
||||
go mod download
|
||||
```
|
||||
|
||||
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
|
||||
relevant compatibility impact or upgrade instructions. No fixed section
|
||||
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:
|
||||
compatibility impact or upgrade instructions. End the note with:
|
||||
|
||||
```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
|
||||
remotely during preparation. Follow the [OSS policy](policy/oss.md) for these
|
||||
links. Promptkit publishes no binaries, hosted download page, or other
|
||||
asynchronous deliverables.
|
||||
Substitute the selected version and check the paths against the candidate;
|
||||
future tag URLs need not resolve during preparation. Follow the
|
||||
[OSS policy](policy/oss.md). Historical notes and tags remain unchanged.
|
||||
|
||||
Retire completed roadmaps while preserving deferred work and repairing incoming
|
||||
links. Review the intended changes through the ordinary development workflow,
|
||||
commit the candidate on `main`, and push `main` to `origin` before publication.
|
||||
Preparation may update module metadata; the release bootstrap and validation
|
||||
preserve the committed candidate.
|
||||
Supply delivery credentials through `RELEASEKIT_GITEA_TOKEN` or the ignored
|
||||
local file `.releasekit/gitea-token`, with owner-only file permissions. A
|
||||
nonblank environment value takes precedence. Source publication neither reads
|
||||
nor requires this token. The [profile](../releasekit.json) selects the token
|
||||
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
|
||||
|
||||
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
|
||||
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
The familiar wrapper delegates to the native ReleaseKit executable selected in
|
||||
`go.mod`. The committed [profile](../releasekit.json) selects the race-enabled
|
||||
Go suite, full vet, committed Go formatting, local Markdown links, workspace
|
||||
and filesystem replacement inspection, and semantic checks for the three
|
||||
maintained offline consumer examples. ReleaseKit also verifies the committed
|
||||
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 launcher uses `go tool` to build or reuse the module-selected ReleaseKit
|
||||
executable in Go's cache, using the selected local toolchain. ReleaseKit runs
|
||||
the [profile's checks](../releasekit.json) once, verifies publication state, and
|
||||
publishes only the intended tag. Preserve its native exit status and diagnostic.
|
||||
Successful publication needs no separate remote read-back or CI wait.
|
||||
|
||||
The native command returns status 0 for confirmed publication, 1 for a known
|
||||
validation or publication failure, 2 for invalid input or profile, and 3 when
|
||||
the push outcome remains unknown. Successful publication needs no remote
|
||||
read-back, CI wait, or installation check. Provider probes and inference calls
|
||||
are not part of validation.
|
||||
For optional local diagnosis, use `./scripts/releasekit.sh check VERSION`.
|
||||
It is not a required step before publication. The upstream
|
||||
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/check.md)
|
||||
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md)
|
||||
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
|
||||
git switch --detach vMAJOR.MINOR.PATCH
|
||||
./scripts/releasekit.sh deliver vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
Resolve uncertain hosted writes before retrying the same immutable version.
|
||||
Matching content is accepted; conflicts are not overwritten. Do not rerun
|
||||
`publish` or move tags to repair delivery. Follow the upstream
|
||||
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/delivery.md)
|
||||
for result interpretation and recovery. These commands apply to tags containing
|
||||
this delivery configuration; older releases retain their original workflows.
|
||||
|
||||
## Failure Recovery
|
||||
|
||||
For status 1, use the diagnostic to distinguish a validation failure from a
|
||||
known publication conflict. Correct and commit candidate defects, push `main`,
|
||||
and rerun publication with an unused version when appropriate. Status 2 means
|
||||
the invocation or profile must be corrected.
|
||||
For source-publication failures, use ReleaseKit's status and diagnostic to
|
||||
distinguish a candidate defect from a publication conflict. Correct and commit
|
||||
candidate defects, push `main`, and rerun publication as appropriate. A failure
|
||||
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
|
||||
resolved. ReleaseKit reports the attempted version, tag object, and recovery
|
||||
state, including the captured publication endpoint when available. Use that
|
||||
endpoint—not merely the current `origin` URL—to query the exact tag; follow the
|
||||
upstream
|
||||
[failure-recovery procedure](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/release.md#resolve-failures):
|
||||
Status 3 means the push outcome remains unknown. Do not retry or remove the local
|
||||
tag until the authoritative outcome is resolved. ReleaseKit attempts to save
|
||||
private publication evidence and reports a record path or a separate recording
|
||||
warning. A recording failure does not change the publication outcome.
|
||||
|
||||
Use the recovery command reported for that attempt, or discover local records
|
||||
and explicitly select the matching attempt:
|
||||
|
||||
```sh
|
||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
||||
releasekit_version='vX.Y.Z'
|
||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
||||
./scripts/releasekit.sh publication-attempts
|
||||
./scripts/releasekit.sh reconcile --record /absolute/path/to/selected-record.json
|
||||
```
|
||||
|
||||
Compare the returned object ID with the reported tag object. An exact match
|
||||
confirms publication. A missing ref confirms absence only when the endpoint
|
||||
identity check and query both succeed. If either check fails, the outcome
|
||||
remains unknown; correct the transport configuration and resolve it before
|
||||
retrying. Only after authoritative absence is confirmed may an unpublished
|
||||
local tag be removed before correcting the candidate or retrying. Never move,
|
||||
overwrite, or delete a published tag.
|
||||
Discovery is local; reconciliation observes the original destination without
|
||||
rerunning validation, changing tags, or delivering hosted content. Neither is
|
||||
a routine gate after successful publication. Follow the upstream
|
||||
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/release.md#resolve-publication-failures)
|
||||
for outcomes, incomplete evidence, and older attempts without records.
|
||||
|
||||
## 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.8.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.
|
||||
Consumers own their dependency-availability and release checks; their readiness
|
||||
does not delay completion of a Promptkit release.
|
||||
Current absence does not prove a version was never published. Remove a retained
|
||||
local tag only after establishing absence and confirming it was not already
|
||||
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)
|
||||
@@ -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
|
||||
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),
|
||||
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
||||
behavior.
|
||||
|
||||
@@ -38,8 +38,30 @@ consumers.
|
||||
|
||||
## Ideas
|
||||
|
||||
No ideas are currently awaiting selection. Active feature work belongs in its
|
||||
focused roadmap rather than this catalog.
|
||||
### Field-Level Normalization With Preserved List Provenance
|
||||
|
||||
Reevaluate normalizing resource fields directly in ConfigKit's effective
|
||||
configuration after upstream ConfigKit supports recording changes within a
|
||||
list without replacing the source attribution of unchanged fields.
|
||||
|
||||
This could let configuration inspection show PromptKit-normalized values and
|
||||
field-level change history while preserving the origins needed to resolve
|
||||
relative resources such as `content_file`. Assess whether that inspection
|
||||
benefit justifies changing the execution-owned normalization approach.
|
||||
|
||||
Include normalization of roles and cache-control values in configured messages
|
||||
in this reevaluation. This could make inspection reflect the values used for
|
||||
execution and reduce separate normalization paths. Upstream support would need
|
||||
to preserve untouched field origins within nested message lists as well as outer
|
||||
resource lists. PromptKit would continue owning the normalization rules;
|
||||
ConfigKit would record their effects and provenance. Caller-appended messages
|
||||
would still need request-time normalization because they do not pass through
|
||||
ConfigKit.
|
||||
Consider moving these transformations only if it improves inspection consistency
|
||||
and simplifies implementation without changing execution behavior.
|
||||
|
||||
Upstream support is the reconsideration trigger, not a dependency of the
|
||||
current ConfigKit migration or a commitment to adopt the enhancement.
|
||||
|
||||
## Entry Format
|
||||
|
||||
|
||||
12
engine.go
12
engine.go
@@ -8,11 +8,11 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
internalengine "go.maximumdirect.net/promptkit/internal/engine"
|
||||
"go.maximumdirect.net/promptkit/internal/observation"
|
||||
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||
)
|
||||
|
||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||
@@ -251,7 +251,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
// InspectPrompt requires a nonblank promptID and performs an exact,
|
||||
// case-sensitive lookup in the captured configuration snapshot. A
|
||||
// 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
|
||||
// message content files were structurally loaded. Inputs are returned in
|
||||
|
||||
12
errors.go
12
errors.go
@@ -5,12 +5,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/generation"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/profile"
|
||||
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||
)
|
||||
|
||||
func mapPublicError(err error) error {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||
)
|
||||
|
||||
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
//go:embed prompts/*.yaml schemas/*.json
|
||||
@@ -84,13 +84,15 @@ func run() (summary, error) {
|
||||
return summary{}, err
|
||||
}
|
||||
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
||||
backend := promptkit.LocalBackend("https://example.invalid/v1", 1)
|
||||
backend.ID = backendID
|
||||
inputs = append(inputs,
|
||||
configkit.Input{Source: promptkit.BackendSource("example backend", map[string]promptkit.BackendConfig{
|
||||
backendID: promptkit.LocalBackend("https://example.invalid/v1", 1),
|
||||
configkit.Input{Source: promptkit.BackendSource("example backend", []promptkit.BackendConfig{
|
||||
backend,
|
||||
})},
|
||||
configkit.Input{Source: promptkit.ProfileSource("example profiles", map[string]promptkit.ProfileConfig{
|
||||
"proposal-profile": {Backend: &backendID, Model: &proposalModel},
|
||||
"assessment-profile": {Backend: &backendID, Model: &assessmentModel},
|
||||
configkit.Input{Source: promptkit.ProfileSource("example profiles", []promptkit.ProfileConfig{
|
||||
{ID: "proposal-profile", Backend: &backendID, Model: &proposalModel},
|
||||
{ID: "assessment-profile", Backend: &backendID, Model: &assessmentModel},
|
||||
})},
|
||||
)
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
@@ -156,13 +158,16 @@ func run() (summary, error) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
value, err := run()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
|
||||
if err := execute(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
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:
|
||||
example.assessment:
|
||||
version: "1.0.0"
|
||||
- id: example.assessment
|
||||
default_profile: assessment-profile
|
||||
description: Assess a structured proposal independently of schema validity.
|
||||
inputs:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
prompts:
|
||||
example.proposal:
|
||||
version: "1.0.0"
|
||||
- id: example.proposal
|
||||
default_profile: proposal-profile
|
||||
description: Propose a bounded improvement from a synthetic idea.
|
||||
inputs:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
backends:
|
||||
offline:
|
||||
- id: offline
|
||||
endpoint: https://review.example.invalid/v1
|
||||
profiles:
|
||||
audit:
|
||||
- id: audit
|
||||
backend: review/offline
|
||||
model: review-model
|
||||
prompts:
|
||||
check:
|
||||
- id: check
|
||||
default_profile: review/audit
|
||||
description: Inspect a review item without contacting a model provider.
|
||||
messages:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
version: "1"
|
||||
configkit:
|
||||
version: "2"
|
||||
config:
|
||||
imports:
|
||||
- path: fragments/resources.yaml
|
||||
required: true
|
||||
- path: fragments/prepare.yaml
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
required: true
|
||||
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:
|
||||
offline:
|
||||
- id: offline
|
||||
endpoint: https://example.invalid/v1
|
||||
profiles:
|
||||
base:
|
||||
- id: base
|
||||
backend: support/offline
|
||||
model: offline-model
|
||||
timeout_seconds: 90
|
||||
fast:
|
||||
extends: base
|
||||
- id: fast
|
||||
extends: {id: base}
|
||||
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:
|
||||
fast:
|
||||
- id: fast
|
||||
extends: {id: base}
|
||||
timeout_seconds: 30
|
||||
|
||||
@@ -4,15 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
type summary struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
PromptVersion *string `json:"prompt_version,omitempty"`
|
||||
SelectedProfile string `json:"selected_profile"`
|
||||
Model string `json:"model"`
|
||||
MessageCount int `json:"message_count"`
|
||||
@@ -29,26 +30,33 @@ type collectionSummary struct {
|
||||
}
|
||||
|
||||
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",
|
||||
configkit.File("examples/go-library/prepare/collections/support/collection.yaml"),
|
||||
configkit.File(filepath.Join(exampleDir, "collections", "support", "collection.yaml")),
|
||||
)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
review, err := promptkit.CollectionInput(
|
||||
"review",
|
||||
configkit.File("examples/go-library/prepare/collections/review/collection.yaml"),
|
||||
configkit.File(filepath.Join(exampleDir, "collections", "review", "collection.yaml")),
|
||||
)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
override, err := promptkit.CollectionInput(
|
||||
"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 {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{
|
||||
@@ -57,34 +65,31 @@ func main() {
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
||||
Prompt: "support", Profile: "support", Backend: "support",
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
view, err := engine.InspectConfiguration()
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
if _, found := view.Explain(configkit.Keys(
|
||||
"collections", "support", "profiles", "fast", "timeout_seconds",
|
||||
)); !found {
|
||||
exit(fmt.Errorf("collection profile override was not found"))
|
||||
if _, found := view.Explain(configkit.Keys("collections", "support", "profiles").Index(1).Key("timeout_seconds")); !found {
|
||||
return fmt.Errorf("collection profile override was not found")
|
||||
}
|
||||
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||
PromptID: "prepare",
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
||||
requirements.Inputs[0].Name != "note" ||
|
||||
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{
|
||||
@@ -95,39 +100,39 @@ func main() {
|
||||
},
|
||||
}
|
||||
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
prepared, err := scoped.Prepare(ctx, request)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
profile, err := scoped.InspectProfile(ctx, "fast")
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
||||
exit(fmt.Errorf(
|
||||
return fmt.Errorf(
|
||||
"later profile override was not applied: timeout is %d seconds",
|
||||
profile.EffectiveModelParams.TimeoutSeconds,
|
||||
))
|
||||
)
|
||||
}
|
||||
namespaces, err := scoped.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
collections, err := scoped.ListCollections(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
scopedPrompts, err := scoped.ListPrompts(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
||||
Mode: promptkit.NamespaceNamed, Name: "review",
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
scopedPromptIDs := make([]string, len(scopedPrompts))
|
||||
for index := range scopedPrompts {
|
||||
@@ -144,11 +149,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(summary{
|
||||
return encoder.Encode(summary{
|
||||
PromptID: prepared.PromptID,
|
||||
PromptVersion: prepared.PromptVersion,
|
||||
SelectedProfile: prepared.SelectedProfileID,
|
||||
Model: prepared.EffectiveModelParams.Model,
|
||||
MessageCount: len(prepared.Messages),
|
||||
@@ -157,12 +161,5 @@ func main() {
|
||||
Collections: collectionSummaries,
|
||||
ScopedPrompts: scopedPromptIDs,
|
||||
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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
type deterministicClient struct{}
|
||||
@@ -39,41 +41,41 @@ type summary struct {
|
||||
}
|
||||
|
||||
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"
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{
|
||||
"offline-example": {Endpoint: &endpoint, Model: &model},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", []promptkit.ProfileConfig{
|
||||
{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{}))
|
||||
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",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"note": promptkit.Inline("Ada finished the migration review."),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
return err
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(summary{
|
||||
return encoder.Encode(summary{
|
||||
Output: result.RawOutput,
|
||||
ValidationStatus: result.Validation.Status,
|
||||
IsValid: result.Validation.IsValid,
|
||||
Model: result.ModelName,
|
||||
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:
|
||||
example.run:
|
||||
version: "1.0.0"
|
||||
- id: example.run
|
||||
default_profile: offline-example
|
||||
description: Run a prompt with a deterministic injected model client.
|
||||
inputs:
|
||||
|
||||
@@ -12,14 +12,13 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
const capturedPromptDocument = `
|
||||
prompts:
|
||||
captured:
|
||||
version: "1"
|
||||
- id: captured
|
||||
default_profile: offline
|
||||
session_id: '{{.session}}'
|
||||
messages:
|
||||
@@ -40,8 +39,8 @@ func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
||||
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{
|
||||
"offline": {Endpoint: &endpoint, Model: &model},
|
||||
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", []promptkit.ProfileConfig{
|
||||
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||
})}}},
|
||||
},
|
||||
}})
|
||||
@@ -145,12 +144,11 @@ func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *tes
|
||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||
schemas := fstest.MapFS{}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
||||
"offline": {Endpoint: &endpoint, Model: &model},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||
})}}}},
|
||||
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||
"prompts": map[string]any{"structured": map[string]any{
|
||||
"version": "1",
|
||||
"prompts": []any{map[string]any{"id": "structured",
|
||||
"default_profile": "offline",
|
||||
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
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 (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
go.maximumdirect.net/configkit v1.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
go.maximumdirect.net/releasekit v0.8.0 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.maximumdirect.net/configkit v1.0.0 h1:cguQnxkaRk3ClQOZNzzhTdc4bfzbraGKv8CkoCp+HLc=
|
||||
go.maximumdirect.net/configkit v1.0.0/go.mod h1:B6fJ5xBLBF1nuywqfLeEI4sLm29UkIcDLu8i6I6gGXE=
|
||||
go.maximumdirect.net/releasekit v0.8.0 h1:hSaHPbUI7tAhnolfbu7UMj2fvmm9FGBY+jhkygdbxR0=
|
||||
go.maximumdirect.net/releasekit v0.8.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var artifactFileReadBenchmarkSink *domain.Artifact
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/backend"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,8 @@ package capacity
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
type generateResult struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
const benchmarkDefinitionCount = 128
|
||||
@@ -31,13 +31,26 @@ type configurationShape struct {
|
||||
|
||||
func benchmarkPrompt(profileID string) map[string]any {
|
||||
return map[string]any{
|
||||
"version": "1",
|
||||
"default_profile": profileID,
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||
"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 {
|
||||
profiles := make(map[string]any, count)
|
||||
prompts := make(map[string]any, count)
|
||||
@@ -48,7 +61,7 @@ func independentDefinitions(count int) configurationShape {
|
||||
prompts[promptID] = benchmarkPrompt(profileID)
|
||||
}
|
||||
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,
|
||||
effectiveEntries: count * 2,
|
||||
tiers: 1,
|
||||
@@ -71,7 +84,7 @@ func fileBackedDefinitions(count int) configurationShape {
|
||||
})
|
||||
return configurationShape{
|
||||
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"},
|
||||
},
|
||||
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}},
|
||||
}
|
||||
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{
|
||||
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
||||
"profiles": profiles,
|
||||
"prompts": map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))},
|
||||
"profiles": benchmarkRecords(profiles),
|
||||
"prompts": benchmarkRecords(map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))}),
|
||||
})},
|
||||
inputEntries: count + 2,
|
||||
effectiveEntries: count + 2,
|
||||
@@ -106,19 +119,19 @@ func layeredDefinitions(count int) configurationShape {
|
||||
for index := range count {
|
||||
id := fmt.Sprintf("profile-%03d", index)
|
||||
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
||||
operator[id] = map[string]any{"model": "operator-model"}
|
||||
programmatic[id] = map[string]any{"extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
||||
final[id] = map[string]any{"temperature": float64(index%10) / 10}
|
||||
operator[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model"}
|
||||
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{"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{
|
||||
underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": base})}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": operator})}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": programmatic})}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": benchmarkRecords(base)})}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": benchmarkRecords(operator)})}}},
|
||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": benchmarkRecords(programmatic)})}}},
|
||||
},
|
||||
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||
"profiles": final,
|
||||
"prompts": map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))},
|
||||
"profiles": benchmarkRecords(final),
|
||||
"prompts": benchmarkRecords(map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))}),
|
||||
})},
|
||||
inputEntries: count*4 + 1,
|
||||
effectiveEntries: count + 1,
|
||||
@@ -132,8 +145,8 @@ func BenchmarkCollectionFileBackedConstruction(b *testing.B) {
|
||||
collections := make(map[string]any, count)
|
||||
for index := range count {
|
||||
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
||||
"prompts": map[string]any{
|
||||
"example": map[string]any{
|
||||
"prompts": []any{
|
||||
map[string]any{"id": "example",
|
||||
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
},
|
||||
@@ -240,8 +253,8 @@ func inspectionDefinitions(extraParams map[string]any) configurationShape {
|
||||
}
|
||||
return configurationShape{
|
||||
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
||||
"profiles": profiles,
|
||||
"prompts": prompts,
|
||||
"profiles": benchmarkRecords(profiles),
|
||||
"prompts": benchmarkRecords(prompts),
|
||||
})},
|
||||
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
||||
}
|
||||
@@ -262,7 +275,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
||||
}
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package configuration
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if len(parts) >= 4 && parts[1] != "" &&
|
||||
if len(parts) >= 4 && sequenceIndex(parts[1]) &&
|
||||
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
||||
return true
|
||||
}
|
||||
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
||||
(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 (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||
@@ -12,15 +12,15 @@ func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||
path configkit.Path
|
||||
want bool
|
||||
}{
|
||||
{name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true},
|
||||
{name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true},
|
||||
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true},
|
||||
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), 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").Index(1).Key("extra_params").Key("authorization"), 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").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: "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: "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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
72
internal/configuration/interpretation_test.go
Normal file
72
internal/configuration/interpretation_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestTargetInterpretationPreservesComposedNestedValues(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.SmokeTests.Reachability.Endpoint != " https://example.invalid/health " ||
|
||||
*composed.SmokeTests.Authentication.Kind != " NONE " || *composed.SmokeTests.Authentication.Endpoint != " https://example.invalid/key " ||
|
||||
*state.result.Config.Profiles[0].ServiceTier != " standard " || *state.result.Config.Profiles[0].APIKeyEnv != " TOKEN " {
|
||||
t.Fatalf("composed values were mutated: %+v", state.result.Config)
|
||||
}
|
||||
path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint")
|
||||
field, found := state.result.Metadata().Lookup(path)
|
||||
if !found || len(field.Origin.Locations) == 0 || field.Origin.Operation == configkit.ChangeNormalization {
|
||||
t.Fatalf("nested field origin changed: %+v", field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(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("composed prompt changed: %+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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package configuration
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
prompts := append([]domain.PromptIdentity(nil), state.promptIDs...)
|
||||
for index := range prompts {
|
||||
prompts[index].Version = stringPointerValue(prompts[index].Version)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,20 +6,18 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"backends": map[string]any{
|
||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1"},
|
||||
},
|
||||
"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}},
|
||||
"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()}},
|
||||
"partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"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()}},
|
||||
" 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": "z-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||
"partial": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local"}}},
|
||||
"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": []any{map[string]any{"id": "profile ", "backend": "local", "model": "spaced-model"}}, "prompts": []any{listingPromptDefinition("prompt ")}},
|
||||
},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
@@ -48,9 +46,9 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPrompts := []domain.PromptIdentity{
|
||||
{ID: " z/prompt ", Version: stringPointer("v1")},
|
||||
{ID: "a/prompt", Version: stringPointer("v1")},
|
||||
{ID: "z/prompt", Version: stringPointer("v1")},
|
||||
{ID: " z/prompt "},
|
||||
{ID: "a/prompt"},
|
||||
{ID: "z/prompt"},
|
||||
}
|
||||
if !reflect.DeepEqual(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) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"backends": map[string]any{"backend": map[string]any{"endpoint": "https://example.test/v1"}},
|
||||
"profiles": map[string]any{"profile": map[string]any{}},
|
||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
||||
"backends": []any{map[string]any{"id": "backend", "endpoint": "https://example.test/v1"}},
|
||||
"profiles": []any{map[string]any{"id": "profile"}},
|
||||
"prompts": []any{listingPromptDefinition("example")},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -84,7 +82,6 @@ func TestLoadedListsAreIndependent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompts[0].ID = "changed prompt"
|
||||
*prompts[0].Version = "changed version"
|
||||
|
||||
profiles, err = state.ListProfiles(context.Background())
|
||||
if err != nil {
|
||||
@@ -101,7 +98,7 @@ func TestLoadedListsAreIndependent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPrompts := []domain.PromptIdentity{{ID: "example", Version: stringPointer("v1")}}
|
||||
wantPrompts := []domain.PromptIdentity{{ID: "example"}}
|
||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
||||
}
|
||||
@@ -128,8 +125,8 @@ func TestLoadedListsEmptyInventory(t *testing.T) {
|
||||
|
||||
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"profiles": map[string]any{"partial": map[string]any{}},
|
||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
||||
"profiles": []any{map[string]any{"id": "partial"}},
|
||||
"prompts": []any{listingPromptDefinition("example")},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
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{
|
||||
"version": "v1",
|
||||
"id": id,
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}
|
||||
|
||||
@@ -9,29 +9,30 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/backend"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
type document struct {
|
||||
Version *string `yaml:"version"`
|
||||
Backends map[string]backendConfig `yaml:"backends"`
|
||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
||||
Backends []backendConfig `yaml:"backends"`
|
||||
Profiles []profileConfig `yaml:"profiles"`
|
||||
Prompts []promptConfig `yaml:"prompts"`
|
||||
Collections map[string]collectionConfig `yaml:"collections"`
|
||||
}
|
||||
|
||||
type collectionConfig struct {
|
||||
ReleaseVersion *string `yaml:"release_version"`
|
||||
Backends map[string]backendConfig `yaml:"backends"`
|
||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
||||
ReleaseVersion *string `yaml:"release_version"`
|
||||
Backends []backendConfig `yaml:"backends"`
|
||||
Profiles []profileConfig `yaml:"profiles"`
|
||||
Prompts []promptConfig `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type backendConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
APIKeyEnv *string `yaml:"api_key_env"`
|
||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||
@@ -56,6 +57,7 @@ type authenticationProbeConfig struct {
|
||||
}
|
||||
|
||||
type profileConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Backend *string `yaml:"backend"`
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
Model *string `yaml:"model"`
|
||||
@@ -71,7 +73,7 @@ type profileConfig struct {
|
||||
}
|
||||
|
||||
type promptConfig struct {
|
||||
Version *string `yaml:"version"`
|
||||
ID string `yaml:"id"`
|
||||
DefaultProfile *string `yaml:"default_profile"`
|
||||
Description *string `yaml:"description"`
|
||||
SessionID *string `yaml:"session_id"`
|
||||
@@ -118,6 +120,7 @@ const (
|
||||
|
||||
type loaded struct {
|
||||
result *configkit.Result[document]
|
||||
interpreted document
|
||||
backends map[string]map[string]fieldState
|
||||
profiles map[string]map[string]fieldState
|
||||
promptMetadata map[string][]promptInputPresence
|
||||
@@ -141,9 +144,9 @@ type resourceSet struct {
|
||||
isCollection bool
|
||||
namespace string
|
||||
bases resourceBases
|
||||
backends map[string]backendConfig
|
||||
profiles map[string]profileConfig
|
||||
prompts map[string]promptConfig
|
||||
backends []backendConfig
|
||||
profiles []profileConfig
|
||||
prompts []promptConfig
|
||||
}
|
||||
|
||||
func rootResourceBases() resourceBases {
|
||||
@@ -192,8 +195,8 @@ func indexedResourceID(namespace, localKey string) string {
|
||||
func indexedBackends(config *document) map[string]backendConfig {
|
||||
backends := make(map[string]backendConfig)
|
||||
for _, resources := range resourceSets(config) {
|
||||
for localKey, definition := range resources.backends {
|
||||
backends[indexedResourceID(resources.namespace, localKey)] = definition
|
||||
for _, definition := range resources.backends {
|
||||
backends[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||
}
|
||||
}
|
||||
return backends
|
||||
@@ -202,8 +205,8 @@ func indexedBackends(config *document) map[string]backendConfig {
|
||||
func indexedProfiles(config *document) map[string]profileConfig {
|
||||
profiles := make(map[string]profileConfig)
|
||||
for _, resources := range resourceSets(config) {
|
||||
for localKey, definition := range resources.profiles {
|
||||
profiles[indexedResourceID(resources.namespace, localKey)] = definition
|
||||
for _, definition := range resources.profiles {
|
||||
profiles[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
@@ -219,6 +222,36 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Validate: func(config *document, validation *configkit.Validation) {
|
||||
state.interpreted = interpretConfiguration(config, validation)
|
||||
validateEffective(&state.interpreted, validation)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// load performs one ConfigKit lifecycle over the selected source tiers.
|
||||
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||
state := &loaded{
|
||||
@@ -229,27 +262,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
||||
Underlays: underlays,
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[document]{
|
||||
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)
|
||||
},
|
||||
},
|
||||
Definition: configurationDefinition(state),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load configuration: %w", err)
|
||||
@@ -261,10 +274,10 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
||||
if err := state.buildExecutionProfiles(); err != nil {
|
||||
return nil, fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
state.backendIDs = sortedKeys(indexedBackends(&result.Config))
|
||||
state.backendIDs = sortedKeys(indexedBackends(&state.interpreted))
|
||||
state.profileIDs = sortedKeys(state.indexedProfiles)
|
||||
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{
|
||||
"backend": resourceNamespaces(state.backendIDs),
|
||||
@@ -302,7 +315,7 @@ func checkConfigurationVersion(version configkit.Version, validation *configkit.
|
||||
if !version.Present {
|
||||
return nil
|
||||
}
|
||||
if version.Null || version.Text != "1" {
|
||||
if version.Null || version.Text != "2" {
|
||||
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
||||
}
|
||||
return nil
|
||||
@@ -313,11 +326,11 @@ type promptInputPresence struct {
|
||||
description fieldState
|
||||
}
|
||||
|
||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||
for _, id := range sortedKeys(prompts) {
|
||||
inputs := prompts[id].Inputs
|
||||
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||
for promptIndex, prompt := range prompts {
|
||||
inputs := prompt.Inputs
|
||||
states := make([]promptInputPresence, len(inputs))
|
||||
base := basePath.Key(id).Key("inputs")
|
||||
base := basePath.Index(promptIndex).Key("inputs")
|
||||
for index := range inputs {
|
||||
path := base.Index(index)
|
||||
states[index] = promptInputPresence{
|
||||
@@ -325,12 +338,12 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P
|
||||
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 {
|
||||
info, exists := resolution.InputInfo(path)
|
||||
info, exists := resolution.EntryInfo(path)
|
||||
switch {
|
||||
case !exists || !info.Explicit:
|
||||
return fieldAbsent
|
||||
@@ -341,11 +354,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) {
|
||||
for id := range entries {
|
||||
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 index, entry := range entries {
|
||||
states := make(map[string]fieldState, len(fields))
|
||||
for _, field := range fields {
|
||||
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
||||
info, exists := resolution.EntryInfo(basePath.Index(index).Key(field))
|
||||
switch {
|
||||
case !exists || !info.Explicit:
|
||||
states[field] = fieldAbsent
|
||||
@@ -355,7 +368,7 @@ func captureFields[T any](entries map[string]T, basePath configkit.Path, namespa
|
||||
states[field] = fieldValue
|
||||
}
|
||||
}
|
||||
destination[indexedResourceID(namespace, id)] = states
|
||||
destination[indexedResourceID(namespace, identity(entry))] = states
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,57 +381,73 @@ func sortedKeys[T any](entries map[string]T) []string {
|
||||
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
|
||||
func interpretConfiguration(config *document, validation *configkit.Validation) document {
|
||||
interpreted := *config
|
||||
interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation)
|
||||
interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation)
|
||||
interpreted.Prompts = interpretPrompts(config.Prompts)
|
||||
if config.Collections != nil {
|
||||
interpreted.Collections = make(map[string]collectionConfig, len(config.Collections))
|
||||
for namespace, original := range config.Collections {
|
||||
collection := original
|
||||
base := configkit.Keys("collections", namespace)
|
||||
collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation)
|
||||
collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation)
|
||||
collection.Prompts = interpretPrompts(original.Prompts)
|
||||
interpreted.Collections[namespace] = collection
|
||||
}
|
||||
}
|
||||
return false
|
||||
return interpreted
|
||||
}
|
||||
|
||||
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for _, id := range sortedKeys(backends) {
|
||||
backend := backends[id]
|
||||
base := basePath.Key(id)
|
||||
func interpretBackends(source []backendConfig, basePath configkit.Path, validation *configkit.Validation) []backendConfig {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
backends := append([]backendConfig{}, source...)
|
||||
for index := range backends {
|
||||
backend := &backends[index]
|
||||
base := basePath.Index(index)
|
||||
if backend.Endpoint != nil {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
||||
if err != nil {
|
||||
resolution.Error(base.Key("endpoint"), err.Error())
|
||||
validation.Error(base.Key("endpoint"), err.Error())
|
||||
} else {
|
||||
normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
||||
backend.Endpoint = &endpoint
|
||||
}
|
||||
}
|
||||
if backend.APIKeyEnv != nil {
|
||||
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
||||
value := strings.TrimSpace(*backend.APIKeyEnv)
|
||||
backend.APIKeyEnv = &value
|
||||
}
|
||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
||||
if backend.SmokeTests != nil {
|
||||
smokeTests := *backend.SmokeTests
|
||||
backend.SmokeTests = &smokeTests
|
||||
smokePath := base.Key("smoke_tests")
|
||||
if smokeTests.Reachability != nil {
|
||||
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
||||
reachability := *smokeTests.Reachability
|
||||
smokeTests.Reachability = &reachability
|
||||
normalizeDiagnosticEndpoint(&reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), validation)
|
||||
}
|
||||
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)))
|
||||
authentication := *smokeTests.Authentication
|
||||
smokeTests.Authentication = &authentication
|
||||
if authentication.Kind != nil {
|
||||
kind := strings.ToLower(strings.TrimSpace(*authentication.Kind))
|
||||
authentication.Kind = &kind
|
||||
}
|
||||
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
||||
smokePath.Key("authentication").Key("endpoint"), resolution)
|
||||
normalizeDiagnosticEndpoint(&authentication.Endpoint, smokePath.Key("authentication").Key("endpoint"), validation)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
backend.QueueCapacity = &capacity
|
||||
}
|
||||
backends[id] = backend
|
||||
}
|
||||
return backends
|
||||
}
|
||||
|
||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, validation *configkit.Validation) {
|
||||
if value == nil || *value == nil {
|
||||
return
|
||||
}
|
||||
@@ -427,38 +456,36 @@ func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution
|
||||
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")
|
||||
validation.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||
return
|
||||
}
|
||||
normalizeString(value, path, resolution, normalized)
|
||||
*value = &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)
|
||||
func interpretProfiles(source []profileConfig, basePath configkit.Path, validation *configkit.Validation) []profileConfig {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
profiles := append([]profileConfig{}, source...)
|
||||
for index := range profiles {
|
||||
profile := &profiles[index]
|
||||
base := basePath.Index(index)
|
||||
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
||||
if err != nil {
|
||||
resolution.Error(base.Key("endpoint"), err.Error())
|
||||
validation.Error(base.Key("endpoint"), err.Error())
|
||||
} else {
|
||||
normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
||||
profile.Endpoint = &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))
|
||||
for _, value := range []**string{&profile.ServiceTier, &profile.ReasoningEffort, &profile.APIKeyEnv} {
|
||||
if *value != nil {
|
||||
normalized := strings.TrimSpace(**value)
|
||||
*value = &normalized
|
||||
}
|
||||
}
|
||||
profiles[id] = profile
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func validateEffective(config *document, validation *configkit.Validation) {
|
||||
@@ -468,16 +495,15 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
||||
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
||||
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
||||
}
|
||||
for localKey := range resources.backends {
|
||||
backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{}
|
||||
for _, backend := range resources.backends {
|
||||
backendIDs[indexedResourceID(resources.namespace, backend.ID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, resources := range sets {
|
||||
for _, id := range sortedKeys(resources.backends) {
|
||||
backend := resources.backends[id]
|
||||
base := resources.bases.backends.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "backend key must be a nonblank local key without a slash")
|
||||
for index, backend := range resources.backends {
|
||||
base := resources.bases.backends.Index(index)
|
||||
if !domain.IsValidLocalResourceKey(backend.ID) {
|
||||
validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash")
|
||||
}
|
||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||
@@ -518,11 +544,10 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(resources.profiles) {
|
||||
profile := resources.profiles[id]
|
||||
base := resources.bases.profiles.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "profile key must be a nonblank local key without a slash")
|
||||
for index, profile := range resources.profiles {
|
||||
base := resources.bases.profiles.Index(index)
|
||||
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 !domain.IsValidResourceID(*profile.Backend) {
|
||||
@@ -556,11 +581,10 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
||||
}
|
||||
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||
}
|
||||
for _, id := range sortedKeys(resources.prompts) {
|
||||
prompt := resources.prompts[id]
|
||||
base := resources.bases.prompts.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "prompt key must be a nonblank local key without a slash")
|
||||
for index, prompt := range resources.prompts {
|
||||
base := resources.bases.prompts.Index(index)
|
||||
if !domain.IsValidLocalResourceKey(prompt.ID) {
|
||||
validation.Error(base.Key("id"), "prompt ID must be a nonblank local key without a slash")
|
||||
}
|
||||
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
||||
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
||||
|
||||
@@ -1,17 +1,298 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func mapping(label string, values map[string]any) configkit.Input {
|
||||
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 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) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -19,11 +300,11 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||
include bool
|
||||
valid bool
|
||||
}{
|
||||
{name: "string one", version: "1", include: true, valid: true},
|
||||
{name: "integer one", version: int64(1), include: true, valid: true},
|
||||
{name: "string two", version: "2", include: true, valid: true},
|
||||
{name: "integer two", version: int64(2), include: true, valid: true},
|
||||
{name: "omitted", valid: 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) {
|
||||
values := map[string]any{}
|
||||
@@ -35,8 +316,8 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load accepted version: %v", err)
|
||||
}
|
||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") {
|
||||
t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version)
|
||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2") {
|
||||
t.Fatalf("typed version = %v, want 2", loaded.result.Config.Version)
|
||||
}
|
||||
if !test.include && loaded.result.Config.Version != nil {
|
||||
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
||||
@@ -47,9 +328,6 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||
if err == nil || !errors.As(err, &loadErr) {
|
||||
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
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
||||
@@ -63,9 +341,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) {
|
||||
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}}})
|
||||
if err != nil {
|
||||
@@ -76,7 +389,7 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
||||
}
|
||||
|
||||
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") {
|
||||
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
||||
@@ -85,30 +398,25 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
||||
|
||||
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
base := mapping("defaults", map[string]any{
|
||||
"version": int64(1),
|
||||
"backends": map[string]any{
|
||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2},
|
||||
},
|
||||
"version": int64(2),
|
||||
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2}},
|
||||
"collections": map[string]any{"base": map[string]any{
|
||||
"release_version": "",
|
||||
"backends": map[string]any{
|
||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3},
|
||||
},
|
||||
"profiles": map[string]any{
|
||||
"one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||
"backends": []any{map[string]any{"id": "local.v1", "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,
|
||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||
"child.v1": map[string]any{"extends": "one"},
|
||||
"namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"},
|
||||
map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"}},
|
||||
map[string]any{"id": "namespaced", "backend": "base/local.v1", "model": "collection-model"},
|
||||
},
|
||||
}, "invoice": map[string]any{
|
||||
"release_version": "r7",
|
||||
"prompts": map[string]any{
|
||||
"summary": map[string]any{
|
||||
"version": "v1.0",
|
||||
"prompts": []any{
|
||||
map[string]any{"id": "summary",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||
"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."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
},
|
||||
@@ -116,19 +424,23 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
overlay := mapping("operator", map[string]any{
|
||||
"backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}},
|
||||
"collections": map[string]any{"base": map[string]any{"profiles": map[string]any{"child.v1": map[string]any{
|
||||
"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": []any{map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"},
|
||||
"temperature": 0, "top_p": nil, "api_key_required": false,
|
||||
"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}},
|
||||
})
|
||||
if err != nil {
|
||||
var loadErr *configkit.LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
t.Fatalf("load: %v; diagnostics: %+v", err, loadErr.Diagnostics())
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("effective values were not retained: %+v", profile)
|
||||
}
|
||||
@@ -139,7 +451,7 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
||||
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 {
|
||||
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
||||
}
|
||||
@@ -150,10 +462,10 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
t.Fatal("literal prompt ID and version were not decoded")
|
||||
if loaded.result.Config.Collections["invoice"].Prompts[0].Messages[0].Content == nil {
|
||||
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["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
||||
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
||||
@@ -176,20 +488,20 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "root resource with separator",
|
||||
values: map[string]any{"backends": map[string]any{
|
||||
"bad/name": map[string]any{"endpoint": "https://example.test/v1"},
|
||||
values: map[string]any{"backends": []any{
|
||||
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",
|
||||
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."}},
|
||||
"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) {
|
||||
@@ -208,23 +520,31 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
||||
"local": map[string]any{"endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
||||
func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||
map[string]any{"id": "local", "endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
||||
"concurrency_limit": 2, "queue_capacity": nil},
|
||||
}}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := loaded.result.Config.Backends["local"]
|
||||
if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" ||
|
||||
backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" ||
|
||||
backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 {
|
||||
backend, err := loaded.GetBackend("local")
|
||||
if err != nil || backend.Endpoint != "http://localhost:8080/v1" ||
|
||||
backend.APIKeyEnv != "TOKEN_NAME" || backend.QueueCapacity != 1024 {
|
||||
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
||||
}
|
||||
composed := loaded.result.Config.Backends[0]
|
||||
if composed.Endpoint == nil || *composed.Endpoint != " HTTP://localhost:8080/v1 " ||
|
||||
composed.APIKeyEnv == nil || *composed.APIKeyEnv != " TOKEN_NAME " || composed.QueueCapacity != nil {
|
||||
t.Fatalf("composed backend was changed: %+v", composed)
|
||||
}
|
||||
if loaded.backends["local"]["queue_capacity"] != fieldNull {
|
||||
t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"])
|
||||
}
|
||||
field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint"))
|
||||
if !found || field.Origin.Operation == configkit.ChangeNormalization || len(field.Origin.Locations) == 0 {
|
||||
t.Fatalf("endpoint origin was not preserved: %+v", field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||
@@ -238,17 +558,17 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||
{name: "null", smokeTests: nil, include: true, inherited: true},
|
||||
} {
|
||||
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 {
|
||||
backend["smoke_tests"] = test.smokeTests
|
||||
}
|
||||
main := mapping("backend", map[string]any{
|
||||
"backends": map[string]any{"local": backend},
|
||||
"backends": []any{backend},
|
||||
})
|
||||
var underlays []configkit.Underlay
|
||||
if test.inherited {
|
||||
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",
|
||||
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
||||
}},
|
||||
@@ -258,7 +578,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
selected, err := loaded.registry.GetBackend("local")
|
||||
@@ -274,7 +594,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||
|
||||
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||
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",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "catalog-profile",
|
||||
@@ -287,7 +607,7 @@ func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
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{
|
||||
"default_profile": "not-yet-listed",
|
||||
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
||||
@@ -331,37 +651,37 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
||||
{
|
||||
name: "blank default profile",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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) {
|
||||
_, 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",
|
||||
"smoke_tests": test.data,
|
||||
}},
|
||||
@@ -381,22 +701,42 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
||||
underlay := mapping("defaults", map[string]any{"backends": []any{
|
||||
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.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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := loaded.result.Config.Backends["local"]
|
||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
|
||||
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
||||
profile := loaded.result.Config.Profiles[0]
|
||||
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||
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) {
|
||||
_, 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("second", 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": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||
}}})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
@@ -406,33 +746,33 @@ func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
||||
|
||||
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
||||
underlay := mapping("defaults", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{"endpoint": "not a URL"}},
|
||||
"profiles": map[string]any{"partial": map[string]any{"backend": "local"}},
|
||||
"backends": []any{map[string]any{"id": "local", "endpoint": "not a URL"}},
|
||||
"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}}})
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
_, 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
|
||||
if !errors.As(err, &loadError) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||
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",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": " ",
|
||||
@@ -442,7 +782,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
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{
|
||||
"default_profile": "dangling-profile",
|
||||
"reachability": map[string]any{"endpoint": nil},
|
||||
@@ -465,7 +805,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(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: ["))}}}})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,57 +28,42 @@ type promptEntry struct {
|
||||
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))
|
||||
func interpretPrompts(source []promptConfig) []promptConfig {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
prompts := append([]promptConfig{}, source...)
|
||||
for index := range prompts {
|
||||
definition := &prompts[index]
|
||||
for _, value := range []**string{&definition.Description, &definition.SessionID} {
|
||||
if *value != nil {
|
||||
normalized := strings.TrimSpace(**value)
|
||||
*value = &normalized
|
||||
}
|
||||
}
|
||||
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 definition.Inputs != nil {
|
||||
definition.Inputs = append([]promptInputConfig{}, definition.Inputs...)
|
||||
for index := range definition.Inputs {
|
||||
input := &definition.Inputs[index]
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
for _, value := range []**string{&input.ContentType, &input.Description} {
|
||||
if *value != nil {
|
||||
normalized := strings.TrimSpace(**value)
|
||||
*value = &normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if inputsChanged {
|
||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
||||
if definition.Output != nil {
|
||||
output := *definition.Output
|
||||
definition.Output = &output
|
||||
if output.SchemaPath != nil {
|
||||
normalized := strings.TrimSpace(*output.SchemaPath)
|
||||
output.SchemaPath = &normalized
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
func (state *loaded) materializePrompts() error {
|
||||
@@ -91,15 +76,14 @@ func (state *loaded) materializePrompts() error {
|
||||
}
|
||||
return metadata.Lookup(path)
|
||||
}
|
||||
for _, resources := range resourceSets(&state.result.Config) {
|
||||
for _, id := range sortedKeys(resources.prompts) {
|
||||
promptBase := resources.bases.prompts.Key(id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
for _, resources := range resourceSets(&state.interpreted) {
|
||||
for index, raw := range resources.prompts {
|
||||
promptBase := resources.bases.prompts.Index(index)
|
||||
if strings.TrimSpace(raw.ID) == "" {
|
||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||
}
|
||||
fullID := indexedResourceID(resources.namespace, id)
|
||||
raw := resources.prompts[id]
|
||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
||||
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,11 +93,11 @@ func (state *loaded) materializePrompts() error {
|
||||
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 {
|
||||
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 strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||
@@ -242,7 +226,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
||||
entry.definition = definition
|
||||
entry.hash = hash
|
||||
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...),
|
||||
OutputContract: contract,
|
||||
}
|
||||
@@ -305,7 +289,6 @@ func (state *loaded) lookupPromptEntry(id string) (*promptEntry, error) {
|
||||
}
|
||||
|
||||
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
||||
definition.Version = stringPointerValue(definition.Version)
|
||||
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
||||
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
||||
for index := range definition.Templates {
|
||||
@@ -318,7 +301,6 @@ func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefin
|
||||
}
|
||||
|
||||
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
||||
inspection.PromptVersion = stringPointerValue(inspection.PromptVersion)
|
||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||
return &inspection
|
||||
}
|
||||
|
||||
@@ -10,23 +10,24 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||
)
|
||||
|
||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
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")},
|
||||
"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/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||
"app/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}}")},
|
||||
"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}}")},
|
||||
}
|
||||
namespace := configkit.NewFilesystem("embedded", files)
|
||||
state, err := load(configkit.Input{
|
||||
Source: namespace.File("app/main.yaml"), Imports: true,
|
||||
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 {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
@@ -41,11 +42,29 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
||||
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]
|
||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||
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("composed metadata was changed: %+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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
files["catalog/body.md"].Data = []byte("Changed")
|
||||
files["app/body.md"].Data = []byte("Changed replacement")
|
||||
files["other/body.md"].Data = []byte("Changed other collection")
|
||||
again, againInspection, err := state.prompt("docs/child")
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
filesystem := &countingFS{FS: fstest.MapFS{
|
||||
"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)
|
||||
}
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +143,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
definitionPath := filepath.Join(root, "definition.yaml")
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
func TestLoadRejectsMalformedStaticSyntax(t *testing.T) {
|
||||
_, err := load(mapping("bad-template", map[string]any{"prompts": []any{map[string]any{"id": "example",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
||||
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) {
|
||||
@@ -261,12 +185,10 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
||||
}
|
||||
}
|
||||
selectedPrompt := makePrompt("selected")
|
||||
selectedPrompt["version"] = "v2"
|
||||
selectedPrompt["id"] = "selected"
|
||||
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
||||
"profiles": map[string]any{"partial": map[string]any{}},
|
||||
"prompts": map[string]any{
|
||||
"selected": selectedPrompt,
|
||||
},
|
||||
"profiles": []any{map[string]any{"id": "partial"}},
|
||||
"prompts": []any{selectedPrompt},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -281,8 +203,7 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot prompt ID: %v", err)
|
||||
}
|
||||
if inspection.PromptID != definition.ID || inspection.PromptVersion == nil || definition.Version == nil ||
|
||||
*inspection.PromptVersion != *definition.Version ||
|
||||
if inspection.PromptID != definition.ID ||
|
||||
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "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) {
|
||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{
|
||||
"version": "v1",
|
||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||
state, err := load(mapping("empty-content", map[string]any{"prompts": []any{map[string]any{"id": " exact.id ",
|
||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
||||
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
||||
"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) {
|
||||
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",
|
||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
@@ -353,7 +273,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
||||
if !errors.As(err, &loadError) {
|
||||
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 {
|
||||
t.Fatal("null prompt collection was accepted")
|
||||
}
|
||||
@@ -361,8 +281,8 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
||||
|
||||
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
||||
state, err := load(mapping("requirement-metadata", map[string]any{
|
||||
"prompts": map[string]any{"example": map[string]any{
|
||||
"version": "v1",
|
||||
"prompts": []any{map[string]any{
|
||||
"id": "example",
|
||||
"session_id": "{{.session}}",
|
||||
"inputs": []any{
|
||||
map[string]any{"name": "absent"},
|
||||
@@ -435,8 +355,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
||||
input[name] = value
|
||||
}
|
||||
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
||||
"prompts": map[string]any{"same": map[string]any{
|
||||
"version": "v1",
|
||||
"prompts": []any{map[string]any{
|
||||
"id": "same",
|
||||
"inputs": []any{input},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"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 {
|
||||
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}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}
|
||||
|
||||
@@ -4,16 +4,16 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
"go.maximumdirect.net/promptkit/internal/backend"
|
||||
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||
)
|
||||
|
||||
func (state *loaded) buildExecutionProfiles() error {
|
||||
backendConfigs := indexedBackends(&state.result.Config)
|
||||
profileConfigs := indexedProfiles(&state.result.Config)
|
||||
backendConfigs := indexedBackends(&state.interpreted)
|
||||
profileConfigs := indexedProfiles(&state.interpreted)
|
||||
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
||||
for _, id := range sortedKeys(backendConfigs) {
|
||||
config := backendConfigs[id]
|
||||
|
||||
@@ -9,36 +9,88 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
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) {
|
||||
state, err := load(mapping("effective-targets", map[string]any{
|
||||
"backends": map[string]any{
|
||||
"first": map[string]any{"endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
||||
"backends": []any{
|
||||
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}}},
|
||||
"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,
|
||||
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
||||
},
|
||||
"profiles": map[string]any{
|
||||
"base": map[string]any{"backend": "first", "model": "model", "temperature": 0.8,
|
||||
"profiles": []any{
|
||||
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,
|
||||
"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,
|
||||
"api_key_required": false,
|
||||
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
||||
"direct-key": map[string]any{"backend": "second", "api_key_required": true},
|
||||
"named-key": map[string]any{"backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
||||
"no-provider-map": map[string]any{"backend": "second", "extra_params": nil},
|
||||
"no-timeout": map[string]any{"backend": "second", "timeout_seconds": 0},
|
||||
"partial": map[string]any{},
|
||||
"endpoint-only": map[string]any{"endpoint": "https://standalone.example/v1"},
|
||||
"cleared-endpoint": map[string]any{"extends": "base", "endpoint": nil},
|
||||
map[string]any{"id": "direct-key", "backend": "second", "api_key_required": true},
|
||||
map[string]any{"id": "named-key", "backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
||||
map[string]any{"id": "no-provider-map", "backend": "second", "extra_params": nil},
|
||||
map[string]any{"id": "no-timeout", "backend": "second", "timeout_seconds": 0},
|
||||
map[string]any{"id": "partial"},
|
||||
map[string]any{"id": "endpoint-only", "endpoint": "https://standalone.example/v1"},
|
||||
map[string]any{"id": "cleared-endpoint", "extends": map[string]any{"id": "base"}, "endpoint": nil},
|
||||
},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
@@ -133,9 +185,9 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
state, err := load(mapping("wire-controls", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{"endpoint": server.URL + "/v1"}},
|
||||
"profiles": map[string]any{"direct": map[string]any{
|
||||
"backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
||||
"backends": []any{map[string]any{"id": "local", "endpoint": server.URL + "/v1"}},
|
||||
"profiles": []any{map[string]any{
|
||||
"id": "direct", "backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
||||
}},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ package defaults
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -80,7 +80,6 @@ type RunResult struct {
|
||||
RawOutput string
|
||||
Validation ValidationResult
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
SessionID string
|
||||
RenderedPromptHash string
|
||||
@@ -100,7 +99,6 @@ type RunResult struct {
|
||||
// It must never include resolved API key values, model output, or validation data.
|
||||
type PreparedRun struct {
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
SelectedProfileID string
|
||||
SelectedBackendID string
|
||||
@@ -134,16 +132,14 @@ type Artifact struct {
|
||||
Hash string
|
||||
}
|
||||
|
||||
// PromptIdentity identifies one configured prompt and its descriptive version label.
|
||||
// PromptIdentity identifies one configured prompt.
|
||||
type PromptIdentity struct {
|
||||
ID string
|
||||
Version *string
|
||||
ID string
|
||||
}
|
||||
|
||||
// PromptDefinition represents a configured prompt execution definition.
|
||||
type PromptDefinition struct {
|
||||
ID string `yaml:"id"`
|
||||
Version *string `yaml:"version"`
|
||||
DefaultProfile string `yaml:"default_profile"`
|
||||
Description string `yaml:"description"`
|
||||
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.
|
||||
type PromptInspection struct {
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
DefaultProfileID string
|
||||
Inputs []PromptInput
|
||||
|
||||
@@ -68,7 +68,6 @@ type ExecutionEvent struct {
|
||||
RunID string
|
||||
Time time.Time
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
Model string
|
||||
|
||||
@@ -48,11 +48,10 @@ type RequirementIssue struct {
|
||||
|
||||
// RequirementInspection contains the inventories discovered for one prompt.
|
||||
type RequirementInspection struct {
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
Inputs []InputRequirement
|
||||
Variables []VariableRequirement
|
||||
Complete bool
|
||||
Issues []RequirementIssue
|
||||
PromptID string
|
||||
ProfileID string
|
||||
Inputs []InputRequirement
|
||||
Variables []VariableRequirement
|
||||
Complete bool
|
||||
Issues []RequirementIssue
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package domain
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
||||
@@ -65,7 +65,6 @@ type SmokeTestTarget struct {
|
||||
BackendID string
|
||||
ProfileID string
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
DefaultSourceBackendID string
|
||||
SelectionSource string
|
||||
Endpoint string
|
||||
@@ -180,7 +179,6 @@ func CloneSmokeTestRequest(request SmokeTestRequest) (SmokeTestRequest, error) {
|
||||
func CloneSmokeTestReport(report SmokeTestReport) (SmokeTestReport, error) {
|
||||
cloned := report
|
||||
cloned.Applicable = cloneBool(report.Applicable)
|
||||
cloned.Target.PromptVersion = cloneString(report.Target.PromptVersion)
|
||||
cloned.Facts = cloneSmokeTestFacts(report.Facts)
|
||||
cloned.Timing = cloneSmokeTestTiming(report.Timing)
|
||||
var err error
|
||||
|
||||
@@ -77,11 +77,9 @@ func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) {
|
||||
reachable := true
|
||||
status := 200
|
||||
wait := 3 * time.Millisecond
|
||||
promptVersion := "v1"
|
||||
report := SmokeTestReport{
|
||||
Applicable: &applicable,
|
||||
Target: SmokeTestTarget{
|
||||
PromptVersion: &promptVersion,
|
||||
EffectiveSettings: ExecutionTarget{
|
||||
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].Facts.EndpointReachable = false
|
||||
*cloned.Attempts[0].Timing.CapacityWait = time.Second
|
||||
*cloned.Target.PromptVersion = "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")
|
||||
}
|
||||
if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
||||
|
||||
@@ -11,17 +11,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/artifact"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/generation"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||
"go.maximumdirect.net/promptkit/internal/validate"
|
||||
)
|
||||
|
||||
// Config carries construction settings selected by the public facade.
|
||||
|
||||
@@ -7,20 +7,20 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func engineSmokeBackendInput() configkit.Input {
|
||||
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",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "test-profile",
|
||||
"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",
|
||||
"model": "synthetic-model",
|
||||
}},
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/observation"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
type transportFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
@@ -3,8 +3,8 @@ package generation
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
// GenerationEvidence contains invocation-local request evidence without raw
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
func TestGenerateWithEvidenceRetainsAttemptsAndUsage(t *testing.T) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/observation"
|
||||
)
|
||||
|
||||
func recordEvents(t *testing.T, ctx context.Context, events *[]domain.ExecutionEvent) context.Context {
|
||||
|
||||
@@ -2,7 +2,7 @@ package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// Client executes a rendered prompt against an LLM endpoint.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// HTTPExecutionEvidence contains only facts observable at the configured
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetWithEvidenceCapturesResponseAndOmitsURLUserInfo(t *testing.T) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var prepareGenerationBenchmarkSink *PreparedGeneration
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestPreparedGenerationEvidenceSeparatesResponseAndGenerationFacts(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestPreparedGenerationFreezesPayloadAndAuthentication(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// Recorder accumulates one execution's measured attempts and usage. Its owner
|
||||
@@ -33,11 +33,10 @@ type Recorder struct {
|
||||
|
||||
// Selection contains resolved identities only. Unresolved fields stay empty.
|
||||
type Selection struct {
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
Model string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
Model string
|
||||
}
|
||||
|
||||
// AttemptData describes one completed active-permit acquisition and optional
|
||||
@@ -104,7 +103,6 @@ func (r *Recorder) Start() {
|
||||
|
||||
// UpdateSelection replaces the known resolved identities for later events.
|
||||
func (r *Recorder) UpdateSelection(selection Selection) {
|
||||
selection.PromptVersion = copyStringPointer(selection.PromptVersion)
|
||||
r.selection = selection
|
||||
}
|
||||
|
||||
@@ -180,19 +178,11 @@ func (r *Recorder) Finish(data FinishData) {
|
||||
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
|
||||
return domain.ExecutionEvent{
|
||||
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
|
||||
PromptVersion: copyStringPointer(r.selection.PromptVersion), ProfileID: r.selection.ProfileID,
|
||||
ProfileID: r.selection.ProfileID,
|
||||
BackendID: r.selection.BackendID, Model: r.selection.Model,
|
||||
}
|
||||
}
|
||||
|
||||
func copyStringPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (r *Recorder) deliver(event domain.ExecutionEvent) {
|
||||
if r.observer != nil {
|
||||
r.observer(event)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestExecutionBoundaryPreservesCallerContext(t *testing.T) {
|
||||
@@ -50,8 +50,7 @@ func TestRecorderAccountsDispatchedAttemptsAndReportedUsage(t *testing.T) {
|
||||
var events []domain.ExecutionEvent
|
||||
recorder := newRecorder("run-1", now, func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||
recorder.Start()
|
||||
version := "v1"
|
||||
recorder.UpdateSelection(Selection{PromptID: "prompt", PromptVersion: &version, ProfileID: "profile", BackendID: "backend", Model: "model"})
|
||||
recorder.UpdateSelection(Selection{PromptID: "prompt", ProfileID: "profile", BackendID: "backend", Model: "model"})
|
||||
recorder.Attempt(AttemptData{
|
||||
Generation: 1, Attempt: 1, Dispatched: true,
|
||||
CapacityWait: 2 * time.Millisecond, GenerationDuration: 10 * time.Millisecond,
|
||||
|
||||
@@ -3,7 +3,7 @@ package observation
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestUsageAccumulatorPreservesPresenceAndNoAttemptSemantics(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"go.maximumdirect.net/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// ErrProfileNotFound identifies an absent profile in the captured index.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user