48 Commits

Author SHA1 Message Date
a0441fb2d1 Prepare v0.17.0 notes and retire completed roadmaps 2026-11-03 02:56:48 +00:00
32547e1e29 Fix the historical profile inheritance link 2026-11-03 02:52:16 +00:00
bcc9ebd2e0 Record the release validation link issue 2026-11-03 02:49:12 +00:00
c7d4318122 Update guidance for Go example tests 2026-11-03 02:31:35 +00:00
175140ae5a Remove the custom example release check 2026-11-03 01:38:30 +00:00
66b98831d3 Test the observation example in Go 2026-11-03 01:38:02 +00:00
3e3cc677fb Test the preparation example in Go 2026-11-03 01:37:44 +00:00
8896d8733b Test the execution example in Go 2026-11-03 01:37:16 +00:00
7bbdb55888 Plan the Go example testing migration 2026-11-03 01:30:30 +00:00
79dfd97afa Plan Go-based testing for maintained examples 2026-11-03 01:00:45 +00:00
995c309560 Adopt ReleaseKit v0.8.0 and saved-attempt recovery 2026-11-03 00:49:47 +00:00
ea4fec9b1b Move Promptkit to its vanity module path 2026-11-02 14:38:00 +00:00
1de3e1fe38 Keep the release runbook focused on Promptkit steps 2026-11-02 14:24:04 +00:00
061f01c081 Run the selected ReleaseKit tool through Go 2026-11-02 14:21:52 +00:00
937b9723d4 Focus release tests on Promptkit wiring 2026-11-02 14:20:28 +00:00
367af5feca Add hosted Gitea release delivery 2026-11-02 14:10:37 +00:00
fd73cd0662 Adopt ReleaseKit v0.7.0 and update recovery guidance 2026-11-02 14:04:42 +00:00
81a55bc62c Adopt ReleaseKit v0.6.0 and its new module path 2026-11-02 13:35:00 +00:00
2bc976de10 Clarify future message normalization opportunities 2026-11-02 13:22:36 +00:00
fc568aa0b2 Complete the ConfigKit resource migration 2026-11-02 13:00:27 +00:00
b0bfe857fe Document the consumer migration to identified resources 2026-11-02 12:58:39 +00:00
2ccbee3758 Document identified resource formats and interpretation 2026-11-02 12:57:20 +00:00
0eee3ec962 Update examples and benchmarks for identified resources 2026-11-02 12:56:05 +00:00
130c4cc447 Update diagnostic and observation contracts 2026-11-02 12:54:10 +00:00
f661176b06 Update public preparation and execution fixtures 2026-11-02 12:53:00 +00:00
3e67743fbf Update internal execution fixtures for identified resources 2026-11-02 12:51:26 +00:00
6b8eefd18b Update public configuration and inventory contracts 2026-11-02 12:50:58 +00:00
682cb84c82 Update configuration and engine fixtures for identified resources 2026-11-02 12:48:47 +00:00
dbc345e566 Verify identified resource composition and inheritance 2026-11-02 12:47:25 +00:00
afcfae2aea Preserve prompt origins while interpreting metadata 2026-11-02 12:47:10 +00:00
eaca50763b Interpret backend and profile settings without changing source values 2026-11-02 12:45:53 +00:00
cb83b8aca6 Include message normalization in future provenance review 2026-11-02 12:43:35 +00:00
0b026861a5 Revise configuration plans to preserve source provenance 2026-11-02 12:20:44 +00:00
da8496b45b Record future provenance-preserving normalization support 2026-11-02 12:11:44 +00:00
e01b23259a Remove public prompt version metadata 2026-11-02 04:04:35 +00:00
500b0aa1c8 Remove internal prompt version metadata 2026-11-02 04:03:39 +00:00
fd6e9742a1 Use explicit IDs in configuration source builders 2026-11-02 04:01:01 +00:00
a6f4d67e15 Preserve inspection redaction for resource lists 2026-11-02 03:58:49 +00:00
c3ce15a4ab Capture prompts from identified lists 2026-11-02 03:56:23 +00:00
8e8f61f53f Index and validate identified resources 2026-11-02 03:55:28 +00:00
b7565affe5 Preserve resource field presence in lists 2026-11-02 03:54:14 +00:00
aba890aa86 Define identified configuration resources 2026-11-02 03:53:09 +00:00
0b271267a0 Update configuration source controls 2026-11-02 03:52:25 +00:00
a4e97bf102 Adopt ConfigKit v1 and Go 1.27 2026-11-02 03:51:39 +00:00
6ae005ed3a Plan the ConfigKit v1 resource migration 2026-11-02 03:46:24 +00:00
07a55e45f2 Settle source builders on explicit-ID slices 2026-11-02 03:33:29 +00:00
2b3875008b Plan ConfigKit v1 and identified resource migration 2026-11-02 03:18:21 +00:00
a51d12d898 Correct collection profile inspection example 2026-10-18 14:42:10 +00:00
175 changed files with 2422 additions and 2244 deletions

2
.gitignore vendored
View File

@@ -22,7 +22,7 @@
go.work
go.work.sum
# Local ReleaseKit executable
# Local ReleaseKit delivery credentials
.releasekit/
# env file

View File

@@ -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`.

View File

@@ -7,7 +7,7 @@ import (
"sync/atomic"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
"go.maximumdirect.net/promptkit"
)
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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 {

View File

@@ -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,
}},
})}}}},
}})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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"},

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
}

View 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)
}
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
})
}

View 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)
}
}
}

View File

@@ -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)
})
}

View 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)
}
}

View File

@@ -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:

View File

@@ -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"}}`}},

View File

@@ -9,7 +9,7 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
"go.maximumdirect.net/promptkit"
)
func TestBuiltInGenerationError(t *testing.T) {

10
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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 (

View File

@@ -11,7 +11,7 @@ import (
"runtime"
"testing"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/domain"
)
var artifactFileReadBenchmarkSink *domain.Artifact

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -6,7 +6,7 @@ import (
"errors"
"fmt"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/domain"
)
const (

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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) {

View 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)
}
}

View File

@@ -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
}

View File

@@ -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"},
}

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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"},
}}}

View File

@@ -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]

View File

@@ -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 {

View File

@@ -3,7 +3,7 @@ package defaults
import (
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/domain"
)
const (

View File

@@ -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

View File

@@ -68,7 +68,6 @@ type ExecutionEvent struct {
RunID string
Time time.Time
PromptID string
PromptVersion *string
ProfileID string
BackendID string
Model string

View File

@@ -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
}

View File

@@ -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

View File

@@ -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" {

View File

@@ -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.

View File

@@ -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",
}},

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -15,7 +15,7 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/domain"
)
var (

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/domain"
)
var prepareGenerationBenchmarkSink *PreparedGeneration

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 &copy
}
func (r *Recorder) deliver(event domain.ExecutionEvent) {
if r.observer != nil {
r.observer(event)

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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