Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f3212bfb6 | |||
| 4faf43cc29 | |||
| c9663ec63c | |||
| d8c98ac575 | |||
| 5ee602cc4b | |||
| 96c619f7d4 | |||
| b3b5ec3820 | |||
| 5dd1418953 | |||
| f5c900cb0a | |||
| c5f3de13f3 | |||
| 88725e79ab | |||
| 6c015a17fa | |||
| 9463c9463a | |||
| 1fbd175e53 | |||
| 4b4ce43796 | |||
| d78474a3bf | |||
| acaf2ccd8b | |||
| 97e5739bfb | |||
| 90f796aef7 | |||
| 0dec3dcec0 | |||
| 448e64dc40 | |||
| de46935a0e | |||
| c69703b2b6 | |||
| f660b6e0fc | |||
| 5f56c65427 | |||
| cfa3a85308 | |||
| 1ff6d778af | |||
| 3f93763c9c | |||
| 4aeb58f913 | |||
| ac1e150168 | |||
| ff57dbf8b3 | |||
| beb18cc9d6 | |||
| 7203e5d6a1 | |||
| 356563a6e5 | |||
| 6673284312 | |||
| 6f42e1e007 | |||
| cee9bbc7b2 | |||
| 7db5572b62 | |||
| a91bc1608b | |||
| e7a3665443 | |||
| 59e8010623 | |||
| ada5377f32 | |||
| df208e132b | |||
| 8c0fb15727 | |||
| 2e2fba4993 | |||
| 68668cee56 | |||
| f745b14b71 | |||
| dbba1485a7 | |||
| 7c435032b6 | |||
| e834e7d806 | |||
| a4901cbb0e | |||
| 5dcfc2e953 | |||
| a02561d40f | |||
| 6112ee8fb4 | |||
| 0aceff8c08 | |||
| cddb2b389c | |||
| e056cb1607 | |||
| 1e122f0473 | |||
| 2f0f5fa607 | |||
| 7d500ee62f | |||
| 7be677c80b | |||
| 1c075bd510 | |||
| b8dc8a49a9 | |||
| cd373ee46b | |||
| e881bbdcf4 | |||
| 106c808442 | |||
| 4ec35f5ca6 | |||
| 6a1a32ae49 | |||
| e85ea2db35 | |||
| 3ef2a9e18e | |||
| a85a6788b4 | |||
| 21fea46fb3 | |||
| 62bf82a56b | |||
| bd3dfa992c | |||
| 425f53a62e | |||
| fcc94eb9e9 | |||
| 95730a3c11 | |||
| c542b81666 | |||
| e8e112186e | |||
| aa08d46ef6 | |||
| 668d09997a | |||
| 69203dcb12 | |||
| 3b760310a6 | |||
| b39460a507 | |||
| 5177db0611 | |||
| 31f1e6a89d | |||
| f7085d76f6 | |||
| cf0bfd4d76 | |||
| 6b1323d1fe | |||
| fb87fabc2e | |||
| 5adc1c1e64 | |||
| 0d27252999 | |||
| d9f6aad305 | |||
| fda0338134 | |||
| 2b3d165ce2 | |||
| 43f55edb98 | |||
| 4950e1dee2 | |||
| d240364776 | |||
| 6f57f2f30a | |||
| 2c50eca033 | |||
| ac44a4f325 | |||
| 9e05c3ac8b | |||
| 2c83925c88 | |||
| e0cd63923d | |||
| 75204d6134 | |||
| 5ce6e01f27 | |||
| 5cec25a40c | |||
| 855aae6daa | |||
| 43abbcb414 | |||
| 9f33fed823 | |||
| 7a98feb399 | |||
| 626134e5e1 | |||
| e7ed315671 | |||
| d7d9a7dd65 | |||
| 18f6a474e0 | |||
| fb7ebe6a6c | |||
| a0441fb2d1 | |||
| 32547e1e29 | |||
| bcc9ebd2e0 | |||
| c7d4318122 | |||
| 175140ae5a | |||
| 66b98831d3 | |||
| 3e3cc677fb | |||
| 8896d8733b | |||
| 7bbdb55888 | |||
| 79dfd97afa | |||
| 995c309560 | |||
| ea4fec9b1b | |||
| 1de3e1fe38 | |||
| 061f01c081 | |||
| 937b9723d4 | |||
| 367af5feca | |||
| fd73cd0662 | |||
| 81a55bc62c | |||
| 2bc976de10 | |||
| fc568aa0b2 | |||
| b0bfe857fe | |||
| 2ccbee3758 | |||
| 0eee3ec962 | |||
| 130c4cc447 | |||
| f661176b06 | |||
| 3e67743fbf | |||
| 6b8eefd18b | |||
| 682cb84c82 | |||
| dbc345e566 | |||
| afcfae2aea | |||
| eaca50763b | |||
| cb83b8aca6 | |||
| 0b026861a5 | |||
| da8496b45b | |||
| e01b23259a | |||
| 500b0aa1c8 | |||
| fd6e9742a1 | |||
| a6f4d67e15 | |||
| c3ce15a4ab | |||
| 8e8f61f53f | |||
| b7565affe5 | |||
| aba890aa86 | |||
| 0b271267a0 | |||
| a4e97bf102 | |||
| 6ae005ed3a | |||
| 07a55e45f2 | |||
| 2b3875008b | |||
| a51d12d898 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,7 +22,7 @@
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# Local ReleaseKit executable
|
# Local ReleaseKit delivery credentials
|
||||||
.releasekit/
|
.releasekit/
|
||||||
|
|
||||||
# env file
|
# env file
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -4,7 +4,7 @@ Promptkit is a reusable Go library for preparing and executing prompt-defined
|
|||||||
LLM workflows. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
go.maximumdirect.net/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `promptkit` package provides the supported public engine. Consumers
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
@@ -26,6 +26,8 @@ source-relative prompt content, supplies an inline input, and prepares through
|
|||||||
a scoped view. The example also demonstrates collection inventories and
|
a scoped view. The example also demonstrates collection inventories and
|
||||||
namespace-filtered discovery, without contacting a provider or requiring
|
namespace-filtered discovery, without contacting a provider or requiring
|
||||||
credentials.
|
credentials.
|
||||||
|
The example disables the bundled OpenRouter configuration to keep its
|
||||||
|
inventories limited to its own offline fixtures.
|
||||||
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
||||||
ordered file/embedded sources, programmatic mappings, and how to replace an
|
ordered file/embedded sources, programmatic mappings, and how to replace an
|
||||||
engine when source files change. Applications adapting the prior keyed
|
engine when source files change. Applications adapting the prior keyed
|
||||||
@@ -43,10 +45,22 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## Release Guidance
|
||||||
|
|
||||||
|
The [v0.18.0 release notes](docs/releases/v0.18.0.md) cover the default-on
|
||||||
|
OpenRouter backend and profiles, recorded configuration normalization, and
|
||||||
|
ConfigKit v1.1. The notes explain inventory changes and the bundle opt-out;
|
||||||
|
the Go 1.27 minimum remains unchanged.
|
||||||
|
|
||||||
|
The [v0.17.0 release notes](docs/releases/v0.17.0.md) cover the vanity module
|
||||||
|
path, ConfigKit v1, identified resource lists, removal of prompt-version
|
||||||
|
metadata, and the Go 1.27 minimum. This is a breaking pre-v1 migration.
|
||||||
|
|
||||||
|
Consumers using the former Go module path should follow the
|
||||||
|
[module-path migration guidance](docs/consumers/configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
||||||
diagnostics, configuration collections, scoped engine views, and discovery.
|
diagnostics, configuration collections, scoped engine views, and discovery.
|
||||||
Upgrading consumers should review the breaking changes to bundled configuration
|
Upgrading consumers should also review the current identified-resource and
|
||||||
and prompt version selection and follow the linked migration guide.
|
ConfigKit v1 changes in the [migration guide](docs/consumers/configuration-migration.md).
|
||||||
|
|
||||||
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
||||||
validation of concrete requests with `Engine.ValidateRequest`.
|
validation of concrete requests with `Engine.ValidateRequest`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
||||||
|
|||||||
43
artifact_ownership_contract_test.go
Normal file
43
artifact_ownership_contract_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type independentArtifactReader struct{}
|
||||||
|
|
||||||
|
func (independentArtifactReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||||
|
return &promptkit.Artifact{Body: []byte(ref.Body), Hash: ref.Body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPreparationWithTransferredArtifacts(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("ownership", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}},
|
||||||
|
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "inputs": []any{map[string]any{"name": "document", "required": true}}, "messages": []any{map[string]any{"role": "user", "content": `{{input "document"}}`}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithArtifactReader(independentArtifactReader{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for i := range 24 {
|
||||||
|
group.Go(func() {
|
||||||
|
value := fmt.Sprintf("body-%d", i)
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"document": promptkit.Inline(value)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("prepare: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(prepared.Messages) != 1 || prepared.Messages[0].Content != value || prepared.InputHashes["document"] != value {
|
||||||
|
t.Errorf("prepared = %+v", prepared)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
artifactadapter "go.maximumdirect.net/promptkit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -71,7 +71,7 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
message := "Hello {{.name}}"
|
message := "Hello {{.name}}"
|
||||||
response := "ok"
|
response := "ok"
|
||||||
output := map[string]any{"format": "text", "validation_mode": "none"}
|
output := map[string]any{"format": "text", "validation_mode": "none"}
|
||||||
profile := map[string]any{"endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
profile := map[string]any{"id": "profile", "endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
||||||
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
||||||
var expectedMessage string
|
var expectedMessage string
|
||||||
|
|
||||||
@@ -99,9 +99,8 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := map[string]any{
|
config := map[string]any{
|
||||||
"profiles": map[string]any{"profile": profile},
|
"profiles": []any{profile},
|
||||||
"prompts": map[string]any{"workflow": map[string]any{
|
"prompts": []any{map[string]any{"id": "workflow",
|
||||||
"version": "v1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": message}},
|
"messages": []any{map[string]any{"role": "user", "content": message}},
|
||||||
"output": output,
|
"output": output,
|
||||||
@@ -128,7 +127,7 @@ func verifyAssembledWorkflowFixture(b *testing.B, fixture assembledWorkflowFixtu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.PromptID != "workflow" || prepared.PromptVersion == nil || *prepared.PromptVersion != "v1" || len(prepared.Messages) != 1 ||
|
if prepared.PromptID != "workflow" || len(prepared.Messages) != 1 ||
|
||||||
prepared.Messages[0].Content != fixture.expectedMessage {
|
prepared.Messages[0].Content != fixture.expectedMessage {
|
||||||
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
||||||
}
|
}
|
||||||
|
|||||||
26
backends.go
26
backends.go
@@ -1,8 +1,8 @@
|
|||||||
package promptkit
|
package promptkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
||||||
@@ -14,13 +14,15 @@ const BackendRakestrawHome = backend.RakestrawHomeID
|
|||||||
// BackendLocal is a conventional ID for a caller-configured local backend.
|
// BackendLocal is a conventional ID for a caller-configured local backend.
|
||||||
const BackendLocal = "local"
|
const BackendLocal = "local"
|
||||||
|
|
||||||
// BackendConfig is a presence-aware convenience value for a keyed backend
|
// BackendConfig is a presence-aware convenience value for an identified backend
|
||||||
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
||||||
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
||||||
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
||||||
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
||||||
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
||||||
type BackendConfig struct {
|
type BackendConfig struct {
|
||||||
|
// ID is the local backend identity within its resource list.
|
||||||
|
ID string
|
||||||
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
||||||
Endpoint *string
|
Endpoint *string
|
||||||
// APIKeyEnv optionally names an environment variable, not a credential.
|
// APIKeyEnv optionally names an environment variable, not a credential.
|
||||||
@@ -71,15 +73,17 @@ type AuthenticationProbeConfig struct {
|
|||||||
Endpoint *string
|
Endpoint *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendSource creates a labeled ConfigKit mapping from keyed backend values.
|
// BackendSource creates a labeled ConfigKit mapping containing backend records
|
||||||
|
// in caller order. IDs are explicit and duplicates are left for loading to reject.
|
||||||
// It encodes fields without validating them; NewEngine validates the final
|
// It encodes fields without validating them; NewEngine validates the final
|
||||||
// effective configuration after all selected source layers are combined.
|
// effective configuration after all selected source layers are combined.
|
||||||
// Mapping captures a copy of supported input values at creation; unsupported
|
// Mapping captures a copy of supported input values at creation; unsupported
|
||||||
// values fail when the source is loaded.
|
// values fail when the source is loaded. ExtraParams must use ConfigKit Mapping
|
||||||
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source {
|
// input forms; callers must explicitly convert numbers or containers as needed.
|
||||||
definitions := make(map[string]any, len(backends))
|
func BackendSource(label string, backends []BackendConfig) configkit.Source {
|
||||||
for id, config := range backends {
|
definitions := make([]any, 0, len(backends))
|
||||||
fields := make(map[string]any)
|
for _, config := range backends {
|
||||||
|
fields := map[string]any{"id": config.ID}
|
||||||
if config.Endpoint != nil {
|
if config.Endpoint != nil {
|
||||||
fields["endpoint"] = *config.Endpoint
|
fields["endpoint"] = *config.Endpoint
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
|||||||
fields["queue_capacity"] = *config.QueueCapacity
|
fields["queue_capacity"] = *config.QueueCapacity
|
||||||
}
|
}
|
||||||
if config.ExtraParams != nil {
|
if config.ExtraParams != nil {
|
||||||
fields["extra_params"] = providerMapForMapping(config.ExtraParams)
|
fields["extra_params"] = config.ExtraParams
|
||||||
}
|
}
|
||||||
if config.SmokeTests != nil {
|
if config.SmokeTests != nil {
|
||||||
smokeTests := make(map[string]any)
|
smokeTests := make(map[string]any)
|
||||||
@@ -119,7 +123,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
|||||||
}
|
}
|
||||||
fields["smoke_tests"] = smokeTests
|
fields["smoke_tests"] = smokeTests
|
||||||
}
|
}
|
||||||
definitions[id] = fields
|
definitions = append(definitions, fields)
|
||||||
}
|
}
|
||||||
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
||||||
}
|
}
|
||||||
|
|||||||
114
bundled_composition_contract_test.go
Normal file
114
bundled_composition_contract_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
|
||||||
|
underlay := configkit.Input{Source: configkit.Mapping("caller underlay", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "gpt-sol-latest", "model": "underlay-model"}},
|
||||||
|
})}
|
||||||
|
underlayEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
underlayProfile, err := underlayEngine.InspectProfile(context.Background(), "gpt-sol-latest")
|
||||||
|
if err != nil || underlayProfile.EffectiveModelParams.Model != "underlay-model" || underlayProfile.EffectiveModelParams.BackendID != "" {
|
||||||
|
t.Fatalf("underlay replacement = %+v, %v", underlayProfile, err)
|
||||||
|
}
|
||||||
|
main := configkit.Input{Source: configkit.Mapping("caller main", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}, map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "gpt-sol-latest", "model": "main-model"},
|
||||||
|
map[string]any{"id": "custom", "extends": map[string]any{"id": "gpt-luna-latest"}, "model": "custom-model"},
|
||||||
|
map[string]any{"id": "auto", "backend": "other", "model": "other-model"},
|
||||||
|
},
|
||||||
|
"collections": map[string]any{"openrouter": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://collection.invalid/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "profile", "backend": "openrouter/local", "model": "collection-model"}},
|
||||||
|
}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Main: &main}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inspect := func(id string) promptkit.ExecutionTarget {
|
||||||
|
t.Helper()
|
||||||
|
profile, err := engine.InspectProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return profile.EffectiveModelParams
|
||||||
|
}
|
||||||
|
if target := inspect("gpt-sol-latest"); target.Model != "main-model" || target.BackendID != "" {
|
||||||
|
t.Fatalf("replacement = %+v", target)
|
||||||
|
}
|
||||||
|
if target := inspect("custom"); target.Model != "custom-model" || target.BackendID != "openrouter" || target.Endpoint != "https://local.invalid/v1" {
|
||||||
|
t.Fatalf("inherited = %+v", target)
|
||||||
|
}
|
||||||
|
if target := inspect("gpt-luna-latest"); target.Endpoint != "https://local.invalid/v1" || target.APIKeyEnv != "" {
|
||||||
|
t.Fatalf("backend replacement = %+v", target)
|
||||||
|
}
|
||||||
|
if target := inspect("openrouter/profile"); target.BackendID != "openrouter/local" || target.Model != "collection-model" {
|
||||||
|
t.Fatalf("collection = %+v", target)
|
||||||
|
}
|
||||||
|
target := inspect("auto-low")
|
||||||
|
wantPlugins := map[string]any{"plugins": []any{map[string]any{"id": "auto-router", "cost_tier": "low"}}}
|
||||||
|
if target.BackendID != "other" || target.Model != "other-model" || !reflect.DeepEqual(target.ExtraParams, wantPlugins) {
|
||||||
|
t.Errorf("inherited tier = %+v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledCompositionSequenceModes(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, mode, id string
|
||||||
|
wantErr bool
|
||||||
|
hasAuto bool
|
||||||
|
}{
|
||||||
|
{"duplicate append", "append", "auto", true, false},
|
||||||
|
{"distinct append", "append", "custom", false, true},
|
||||||
|
{"replace", "replace", "custom", false, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("sequence", map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": test.mode}}},
|
||||||
|
"profiles": []any{map[string]any{"id": test.id, "model": "custom-model"}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate rejection")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles, err := engine.ListProfiles(context.Background())
|
||||||
|
if err != nil || !slices.Contains(profiles, "custom") || slices.Contains(profiles, "auto") != test.hasAuto {
|
||||||
|
t.Fatalf("profiles = %v, %v", profiles, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledCompositionRejectsStrictConflictsAndMissingBackends(t *testing.T) {
|
||||||
|
profile := configkit.Input{Source: configkit.Mapping("profile", map[string]any{"profiles": []any{map[string]any{"id": "auto", "model": "caller"}}})}
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile, profile}}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("strict group accepted duplicate records")
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("replace backends", map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"backends"}, "mode": "replace"}}},
|
||||||
|
"backends": []any{map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
|
||||||
|
})}
|
||||||
|
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}); err == nil {
|
||||||
|
t.Fatal("missing bundled backend was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
89
bundled_execution_contract_test.go
Normal file
89
bundled_execution_contract_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bundledPromptConfig(client *http.Client) promptkit.Config {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "prompt", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
}}})}
|
||||||
|
return promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledProfilePreparationAndExecution(t *testing.T) {
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
||||||
|
engine, err := promptkit.NewEngine(bundledPromptConfig(nil), promptkit.WithLLMClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrProfileRequired) {
|
||||||
|
t.Fatalf("missing profile = %v", err)
|
||||||
|
}
|
||||||
|
temperature := 0.3
|
||||||
|
request := promptkit.RunRequest{PromptID: "prompt", ProfileID: "gpt-luna-latest", Execution: &promptkit.ExecutionTargetOverride{Temperature: &temperature}}
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
details := prepared.Details()
|
||||||
|
if details.SelectedProfileID != "gpt-luna-latest" || details.EffectiveModelParams.Model != "~openai/gpt-luna-latest" || details.EffectiveModelParams.BackendID != "openrouter" || details.EffectiveModelParams.Temperature != temperature {
|
||||||
|
t.Fatalf("prepared target = %+v", details.EffectiveModelParams)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 1 || client.requests[0].Target.Model != "~openai/gpt-luna-latest" || client.requests[0].Target.BackendID != "openrouter" {
|
||||||
|
t.Fatalf("requests = %+v", client.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledProfileHTTPPayloads(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
|
||||||
|
for _, test := range []struct{ id, model, tier string }{
|
||||||
|
{"auto-low", "openrouter/auto", "low"}, {"gpt-luna-latest", "~openai/gpt-luna-latest", ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.id, func(t *testing.T) {
|
||||||
|
var body map[string]any
|
||||||
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
if request.Header.Get("Authorization") != "Bearer synthetic-key" {
|
||||||
|
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(bundledPromptConfig(&http.Client{Transport: transport}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ProfileID: test.id}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["model"] != test.model || body["extra_params"] != nil {
|
||||||
|
t.Fatalf("request = %+v", body)
|
||||||
|
}
|
||||||
|
if test.tier == "" {
|
||||||
|
if _, exists := body["plugins"]; exists {
|
||||||
|
t.Fatalf("unexpected plugins = %+v", body)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := []any{map[string]any{"id": "auto-router", "cost_tier": test.tier}}
|
||||||
|
if !reflect.DeepEqual(body["plugins"], want) {
|
||||||
|
t.Fatalf("plugins = %#v, want %#v", body["plugins"], want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
117
bundled_openrouter_contract_test.go
Normal file
117
bundled_openrouter_contract_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rejectBundleTransport struct{}
|
||||||
|
|
||||||
|
func (rejectBundleTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("unexpected HTTP request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledOpenRouterOptionsAndInventory(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
options []promptkit.Option
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{"default", nil, true}, {"enabled", []promptkit.Option{promptkit.WithBundledOpenRouter(true)}, true},
|
||||||
|
{"disabled", []promptkit.Option{promptkit.WithBundledOpenRouter(false)}, false},
|
||||||
|
{"last disables", []promptkit.Option{promptkit.WithBundledOpenRouter(true), promptkit.WithBundledOpenRouter(false)}, false},
|
||||||
|
{"last enables", []promptkit.Option{promptkit.WithBundledOpenRouter(false), promptkit.WithBundledOpenRouter(true)}, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: rejectBundleTransport{}}}, test.options...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles, err := engine.ListProfiles(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backends, err := engine.ListBackends(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prompts, err := engine.ListPrompts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collections, err := engine.ListCollections(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if (len(profiles) > 0) != test.enabled || len(backends) != boolCount(test.enabled) || len(prompts) != 0 || len(collections) != 0 {
|
||||||
|
t.Fatalf("inventories: %v %v %v %v", profiles, backends, prompts, collections)
|
||||||
|
}
|
||||||
|
if test.enabled {
|
||||||
|
if !reflect.DeepEqual(backends, []string{"openrouter"}) {
|
||||||
|
t.Fatalf("bundle inventory: %v %v", profiles, backends)
|
||||||
|
}
|
||||||
|
profile, err := engine.InspectProfile(context.Background(), "auto")
|
||||||
|
if err != nil || profile.EffectiveModelParams.BackendID != "openrouter" {
|
||||||
|
t.Fatalf("bundle profile = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
view, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmtSources(view.Sources()), "Promptkit bundled OpenRouter") {
|
||||||
|
t.Fatal("bundle source missing")
|
||||||
|
}
|
||||||
|
if explanation, found := view.Explain(configkit.Keys("backends").Index(0).Key("endpoint")); !found || !strings.Contains(explanation.Effective.YAML, "https://openrouter.ai/api/v1") || len(explanation.History) == 0 {
|
||||||
|
t.Fatalf("bundle explanation = %+v", explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolCount(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
func fmtSources(sources []configkit.SourceInfo) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, source := range sources {
|
||||||
|
b.WriteString(source.Label)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundlePreservesCallerUnderlayBackingArray(t *testing.T) {
|
||||||
|
source := configkit.Mapping("caller", map[string]any{"profiles": []any{map[string]any{"id": "caller", "model": "model"}}})
|
||||||
|
groups := make([]configkit.Underlay, 1, 2)
|
||||||
|
groups[0] = configkit.Underlay{Inputs: []configkit.Input{{Source: source}}}
|
||||||
|
before := append([]configkit.Underlay(nil), groups...)
|
||||||
|
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: groups}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(groups, before) || !reflect.DeepEqual(groups[:2][1], configkit.Underlay{}) {
|
||||||
|
t.Fatal("caller underlays were modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledBundleKeepsCallerOpenRouter(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("caller", map[string]any{"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}}})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backends, err := engine.ListBackends(context.Background())
|
||||||
|
if err != nil || !reflect.DeepEqual(backends, []string{"openrouter"}) {
|
||||||
|
t.Fatalf("backends = %v, %v", backends, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
bundled_smoke_contract_test.go
Normal file
181
bundled_smoke_contract_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cancelingAuthenticationBody struct {
|
||||||
|
remaining int
|
||||||
|
cancel context.CancelFunc
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *cancelingAuthenticationBody) Read(p []byte) (int, error) {
|
||||||
|
n := min(len(p), b.remaining)
|
||||||
|
for i := range p[:n] {
|
||||||
|
p[i] = 'x'
|
||||||
|
}
|
||||||
|
b.remaining -= n
|
||||||
|
if b.remaining == 0 {
|
||||||
|
b.cancel()
|
||||||
|
return n, context.Canceled
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
func (b *cancelingAuthenticationBody) Close() error { b.closed = true; return nil }
|
||||||
|
|
||||||
|
func TestAuthenticationReadCancellationWinsOverOverflow(t *testing.T) {
|
||||||
|
for _, size := range []int{1024, (1 << 20) + 1} {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
body := &cancelingAuthenticationBody{remaining: size, cancel: cancel}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: body}, nil
|
||||||
|
})}})
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := engine.SmokeTest(ctx, promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter"})
|
||||||
|
cancel()
|
||||||
|
if !errors.Is(err, context.Canceled) || report == nil || report.Completed || report.Failure != promptkit.ExecutionFailureCanceled || report.Facts.CredentialsAccepted != nil || report.Facts.ResponseReceived == nil || !*report.Facts.ResponseReceived || !body.closed {
|
||||||
|
t.Fatalf("size %d: report=%+v error=%v closed=%v", size, report, err, body.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeHTTPDefaults(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
|
||||||
|
var urls []string
|
||||||
|
var models []string
|
||||||
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
urls = append(urls, request.URL.String())
|
||||||
|
body := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
|
||||||
|
if request.Method == http.MethodPost {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
models = append(models, payload["model"].(string))
|
||||||
|
content := "PROMPTKIT_OK"
|
||||||
|
if _, structured := payload["response_format"]; structured {
|
||||||
|
content = `{"status":"ok"}`
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": content}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = string(encoded)
|
||||||
|
} else if strings.HasSuffix(request.URL.Path, "/key") && request.Header.Get("Authorization") != "Bearer synthetic-key" {
|
||||||
|
t.Errorf("authentication header missing")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := engine.ListProfiles(context.Background())
|
||||||
|
inspectionBefore, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []promptkit.SmokeTestKind{promptkit.SmokeTestReachability, promptkit.SmokeTestAuthentication, promptkit.SmokeTestTextInference, promptkit.SmokeTestStructuredOutput} {
|
||||||
|
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: kind, BackendID: "openrouter"})
|
||||||
|
if err != nil || report == nil || !report.Completed || report.Failure != "" {
|
||||||
|
t.Fatalf("%s report = %+v, %v", kind, report, err)
|
||||||
|
}
|
||||||
|
if kind == promptkit.SmokeTestAuthentication && (report.Facts.CredentialsAccepted == nil || !*report.Facts.CredentialsAccepted) {
|
||||||
|
t.Fatalf("authentication facts = %+v", report.Facts)
|
||||||
|
}
|
||||||
|
if kind == promptkit.SmokeTestTextInference || kind == promptkit.SmokeTestStructuredOutput {
|
||||||
|
if report.Target.ProfileID != "gpt-luna-latest" || report.Target.EffectiveSettings.Model != "~openai/gpt-luna-latest" {
|
||||||
|
t.Fatalf("target = %+v", report.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantURLs := []string{"https://openrouter.ai/api/v1", "https://openrouter.ai/api/v1/key", "https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions"}
|
||||||
|
if !reflect.DeepEqual(urls, wantURLs) || !reflect.DeepEqual(models, []string{"~openai/gpt-luna-latest", "~openai/gpt-luna-latest"}) {
|
||||||
|
t.Fatalf("urls %v, models %v", urls, models)
|
||||||
|
}
|
||||||
|
after, _ := engine.ListProfiles(context.Background())
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
t.Fatalf("inventory changed: %v %v", before, after)
|
||||||
|
}
|
||||||
|
inspectionAfter, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diff, err := inspectionBefore.Diff(inspectionAfter)
|
||||||
|
if err != nil || len(diff) != 0 {
|
||||||
|
t.Fatalf("inspection changed: %+v, %v", diff, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) {
|
||||||
|
replacement := configkit.Input{Source: configkit.Mapping("replacement", map[string]any{"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}}})}
|
||||||
|
replacementClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
|
||||||
|
replacementEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &replacement}}, promptkit.WithLLMClient(replacementClient))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacementReport, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
|
||||||
|
if err != nil || replacementReport.Target.ProfileID != "gpt-luna-latest" || replacementReport.Target.EffectiveSettings.Model != "replacement" {
|
||||||
|
t.Fatalf("replaced default = %+v, %v", replacementReport, err)
|
||||||
|
}
|
||||||
|
replacementClient.response.Content = "unexpected"
|
||||||
|
mismatch, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
|
||||||
|
if err != nil || mismatch == nil || !mismatch.Completed {
|
||||||
|
t.Fatalf("concluded mismatch = %+v, %v", mismatch, err)
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://example.invalid/v1", "smoke_tests": map[string]any{"default_profile": "custom"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}, map[string]any{"id": "custom", "backend": "openrouter", "model": "custom-model"}},
|
||||||
|
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "free", "messages": []any{map[string]any{"role": "user", "content": "private application marker"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
|
||||||
|
})}
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithLLMClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
request promptkit.SmokeTestRequest
|
||||||
|
profile, model string
|
||||||
|
}{
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter"}, "custom", "custom-model"},
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt"}, "free", "openrouter/free"},
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt", ProfileID: "gpt-luna-latest"}, "gpt-luna-latest", "replacement"},
|
||||||
|
} {
|
||||||
|
test.request.Kind = promptkit.SmokeTestTextInference
|
||||||
|
report, err := engine.SmokeTest(context.Background(), test.request)
|
||||||
|
if err != nil || report.Target.ProfileID != test.profile || report.Target.EffectiveSettings.Model != test.model {
|
||||||
|
t.Fatalf("report = %+v, %v", report, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(client.requests[len(client.requests)-1].Prompt.Messages[0].Content, "private application marker") {
|
||||||
|
t.Fatal("application content used by diagnostic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeAuthenticationResponseOverflow(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1)))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter", APIKey: "private-key-marker"})
|
||||||
|
if err != nil || report == nil || !report.Completed || report.Reason != promptkit.SmokeTestReasonAuthenticationResponseLimitExceeded || report.Failure != promptkit.ExecutionFailureOther || report.Facts.CredentialsAccepted != nil {
|
||||||
|
t.Fatalf("overflow = %+v, %v", report, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
||||||
@@ -247,11 +247,12 @@ func newBackendCapacityEngine(
|
|||||||
promptID, content = "input-prompt", `{{input "input"}}`
|
promptID, content = "input-prompt", `{{input "input"}}`
|
||||||
inputs = []any{map[string]any{"name": "input", "required": true}}
|
inputs = []any{map[string]any{"name": "input", "required": true}}
|
||||||
}
|
}
|
||||||
backend := map[string]any{"endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
backend := map[string]any{"id": "limited", "endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
||||||
if queueCapacity != nil {
|
if queueCapacity != nil {
|
||||||
backend["queue_capacity"] = *queueCapacity
|
backend["queue_capacity"] = *queueCapacity
|
||||||
}
|
}
|
||||||
definition := map[string]any{
|
definition := map[string]any{
|
||||||
|
"id": promptID,
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
@@ -265,9 +266,9 @@ func newBackendCapacityEngine(
|
|||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("capacity fixture", map[string]any{
|
Source: configkit.Mapping("capacity fixture", map[string]any{
|
||||||
"backends": map[string]any{"limited": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"profile": map[string]any{"backend": "limited", "model": "model"}},
|
"profiles": []any{map[string]any{"id": "profile", "backend": "limited", "model": "model"}},
|
||||||
"prompts": map[string]any{promptID: promptDefinitionWithVersion("1", definition)},
|
"prompts": []any{definition},
|
||||||
}),
|
}),
|
||||||
}}}, options...)
|
}}}, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
|||||||
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
if got, want := difference.Path.String(), "/profiles/offline/model"; got != want {
|
if got, want := difference.Path.String(), "/profiles"; got != want {
|
||||||
t.Fatalf("difference path = %q, want %q", got, want)
|
t.Fatalf("difference path = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
||||||
@@ -81,6 +81,34 @@ func TestConfigurationInspectionDiffIgnoresSourceHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationInspectionDiffUsesNormalizedValues(t *testing.T) {
|
||||||
|
inspect := func(endpoint string) promptkit.ConfigurationInspection {
|
||||||
|
t.Helper()
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("backend", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": endpoint}},
|
||||||
|
})}}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
left := inspect(" HTTPS://example.invalid/v1 ")
|
||||||
|
equal := inspect("https://example.invalid/v1")
|
||||||
|
differences, err := left.Diff(equal)
|
||||||
|
if err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("normalized diff = %+v, %v", differences, err)
|
||||||
|
}
|
||||||
|
changed := inspect("https://other.invalid/v1")
|
||||||
|
differences, err = left.Diff(changed)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
|
t.Fatalf("changed diff = %+v, %v", differences, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
||||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||||
"sequence": []any{"earlier", "unchanged"},
|
"sequence": []any{"earlier", "unchanged"},
|
||||||
@@ -94,18 +122,14 @@ func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare presence and sequence values: %v", err)
|
t.Fatalf("compare presence and sequence values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("atomic backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
difference := differences[0]
|
||||||
nullable, ok := byPath["/backends/local/extra_params/nullable"]
|
if !difference.Left.Present || !difference.Right.Present ||
|
||||||
if !ok || nullable.Left.Present || !nullable.Right.Present || strings.TrimSpace(nullable.Right.YAML) != "null" {
|
strings.Contains(difference.Left.YAML, "nullable:") || !strings.Contains(difference.Right.YAML, "nullable: null") ||
|
||||||
t.Fatalf("absent-to-null difference = (%+v, %t)", nullable, ok)
|
!strings.Contains(difference.Left.YAML, "earlier") || !strings.Contains(difference.Right.YAML, "later") {
|
||||||
}
|
t.Fatalf("atomic sequence snapshots = %+v", difference)
|
||||||
sequence, ok := byPath["/backends/local/extra_params/sequence"]
|
|
||||||
if !ok || !sequence.Left.Present || !sequence.Right.Present ||
|
|
||||||
!strings.Contains(sequence.Left.YAML, "earlier") || !strings.Contains(sequence.Right.YAML, "later") {
|
|
||||||
t.Fatalf("atomic sequence difference = (%+v, %t)", sequence, ok)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,22 +147,19 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive values: %v", err)
|
t.Fatalf("compare sensitive values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("sensitive backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
secret := differences[0]
|
||||||
secret, ok := byPath["/backends/local/extra_params/token"]
|
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||||
if !ok || !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
t.Fatalf("sensitive difference = %+v", secret)
|
||||||
t.Fatalf("sensitive difference = (%+v, %t)", secret, ok)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
||||||
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
||||||
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
||||||
}
|
}
|
||||||
visible, ok := byPath["/backends/local/extra_params/visible"]
|
if !strings.Contains(secret.Left.YAML, "before-value") || !strings.Contains(secret.Right.YAML, "after-value") {
|
||||||
if !ok || visible.Left.Redacted || visible.Right.Redacted || visible.HiddenChanged ||
|
t.Fatalf("public sibling missing from snapshots: %+v", secret)
|
||||||
!strings.Contains(visible.Left.YAML, "before-value") || !strings.Contains(visible.Right.YAML, "after-value") {
|
|
||||||
t.Fatalf("public sibling difference = (%+v, %t)", visible, ok)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
differences[0].Path = configkit.Keys("caller", "mutation")
|
differences[0].Path = configkit.Keys("caller", "mutation")
|
||||||
@@ -148,18 +169,15 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
||||||
}
|
}
|
||||||
if len(again) != 2 {
|
if len(again) != 1 {
|
||||||
t.Fatalf("repeat difference count = %d, want 2: %+v", len(again), again)
|
t.Fatalf("repeat difference count = %d, want 1: %+v", len(again), again)
|
||||||
}
|
}
|
||||||
againByPath := differencesByPath(again)
|
againByPath := differencesByPath(again)
|
||||||
if _, exists := againByPath["/caller/mutation"]; exists {
|
if _, exists := againByPath["/caller/mutation"]; exists {
|
||||||
t.Fatal("mutating returned path reached retained inspection")
|
t.Fatal("mutating returned path reached retained inspection")
|
||||||
}
|
}
|
||||||
if _, exists := againByPath["/backends/local/extra_params/token"]; !exists {
|
if _, exists := againByPath["/backends"]; !exists {
|
||||||
t.Fatalf("original sensitive path was lost after mutation: %+v", again)
|
t.Fatalf("original backend path was lost after mutation: %+v", again)
|
||||||
}
|
|
||||||
if _, exists := againByPath["/backends/local/extra_params/visible"]; !exists {
|
|
||||||
t.Fatalf("original public path was lost after mutation: %+v", again)
|
|
||||||
}
|
}
|
||||||
for _, difference := range again {
|
for _, difference := range again {
|
||||||
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
||||||
@@ -183,7 +201,7 @@ func TestConfigurationInspectionDiffMasksSensitiveShapeReplacement(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive shape replacement: %v", err)
|
t.Fatalf("compare sensitive shape replacement: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 1 || differences[0].Path.String() != "/backends/local/extra_params/details" {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("shape replacement differences = %+v", differences)
|
t.Fatalf("shape replacement differences = %+v", differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
@@ -202,16 +220,16 @@ func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationIns
|
|||||||
|
|
||||||
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backend := map[string]any{"endpoint": "https://example.invalid/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}
|
||||||
if extraParams != nil {
|
if extraParams != nil {
|
||||||
backend["extra_params"] = extraParams
|
backend["extra_params"] = extraParams
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"offline": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local",
|
"id": "offline", "backend": "local",
|
||||||
"model": model,
|
"model": model,
|
||||||
}},
|
}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}})
|
}})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigurationInspection is a read-only view of one Engine's captured
|
// ConfigurationInspection is a read-only view of one Engine's captured
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,34 +12,37 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{
|
||||||
"endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
|
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "api_key_env": "OPTIONAL_KEY",
|
||||||
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
||||||
}},
|
}},
|
||||||
"collections": map[string]any{"literal": map[string]any{
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{
|
||||||
"endpoint": "https://example.invalid/v1",
|
map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "namespaced-private-sentinel"}},
|
||||||
"extra_params": map[string]any{"token": "namespaced-private-sentinel"},
|
map[string]any{"id": "child", "extends": map[string]any{"id": "local"}},
|
||||||
}},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
map[string]any{"id": "base.v1", "backend": "local", "model": "default-model"},
|
||||||
"id": map[string]any{"extends": "base.v1"},
|
map[string]any{"id": "id", "extends": map[string]any{"id": "base.v1"}},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
||||||
"collections": map[string]any{"literal": map[string]any{"profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}}},
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "replacement-private-sentinel"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "base.v1", "backend": "local", "model": "operator-model"}},
|
||||||
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct layered engine: %v", err)
|
t.Fatalf("construct layered engine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -46,29 +50,43 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("inspect configuration: %v", err)
|
t.Fatalf("inspect configuration: %v", err)
|
||||||
}
|
}
|
||||||
modelPath := configkit.Keys("collections", "literal", "profiles", "base.v1", "model")
|
modelPath := configkit.Keys("collections", "literal", "profiles").Index(0).Key("model")
|
||||||
model, found := view.Explain(modelPath)
|
model, found := view.Explain(modelPath)
|
||||||
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 {
|
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) == 0 {
|
||||||
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
||||||
}
|
}
|
||||||
inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model")
|
inheritedPath := configkit.Keys("collections", "literal", "profiles").Index(1).Key("model")
|
||||||
inherited, found := view.Explain(inheritedPath)
|
inherited, found := view.Explain(inheritedPath)
|
||||||
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
||||||
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
||||||
}
|
}
|
||||||
if inheritedPath.String() != "/collections/literal/profiles/id/model" {
|
if inheritedPath.String() != "/collections/literal/profiles/1/model" {
|
||||||
t.Fatalf("literal-key path = %q", inheritedPath)
|
t.Fatalf("literal-key path = %q", inheritedPath)
|
||||||
}
|
}
|
||||||
secretPath := configkit.Keys("backends", "local", "extra_params", "token")
|
secretPath := configkit.Keys("backends").Index(0).Key("extra_params").Key("token")
|
||||||
|
endpointPath := configkit.Keys("backends").Index(0).Key("endpoint")
|
||||||
|
endpoint, found := view.Explain(endpointPath)
|
||||||
|
if !found || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") || strings.Contains(fmt.Sprintf("%+v", view.Metadata().History(endpointPath)), "private-sentinel") {
|
||||||
|
t.Fatalf("normalized endpoint explanation = %+v", endpoint)
|
||||||
|
}
|
||||||
secret, found := view.Explain(secretPath)
|
secret, found := view.Explain(secretPath)
|
||||||
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
||||||
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
||||||
}
|
}
|
||||||
namespacedSecretPath := configkit.Keys("collections", "literal", "backends", "local", "extra_params", "token")
|
namespacedSecretPath := configkit.Keys("collections", "literal", "backends").Index(0).Key("extra_params").Key("token")
|
||||||
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
||||||
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
||||||
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
||||||
}
|
}
|
||||||
|
inheritedSecretPath := configkit.Keys("collections", "literal", "backends").Index(1).Key("extra_params").Key("token")
|
||||||
|
inheritedSecret, found := view.Explain(inheritedSecretPath)
|
||||||
|
if !found || !inheritedSecret.Effective.Redacted || strings.Contains(inheritedSecret.Effective.YAML, "replacement-private-sentinel") {
|
||||||
|
t.Fatalf("inherited provider value was not redacted: (%+v, %t)", inheritedSecret, found)
|
||||||
|
}
|
||||||
|
metadata := fmt.Sprintf("%+v", view.Metadata().History(inheritedSecretPath))
|
||||||
|
if strings.Contains(metadata, "replacement-private-sentinel") || strings.Contains(metadata, "namespaced-private-sentinel") {
|
||||||
|
t.Fatal("metadata disclosed a sensitive provider value")
|
||||||
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
if err := view.WriteEffective(&effective); err != nil {
|
if err := view.WriteEffective(&effective); err != nil {
|
||||||
t.Fatalf("write effective configuration: %v", err)
|
t.Fatalf("write effective configuration: %v", err)
|
||||||
@@ -104,9 +122,26 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationInspectionFailureDoesNotDiscloseSensitiveValues(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
|
Source: configkit.Mapping("invalid backend", map[string]any{
|
||||||
|
"collections": map[string]any{"private": map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "backend", "endpoint": "invalid endpoint", "extra_params": map[string]any{"token": "failure-private-sentinel"}},
|
||||||
|
}}},
|
||||||
|
}),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid backend error = %v, want load error", err)
|
||||||
|
}
|
||||||
|
if text := fmt.Sprintf("%v %+v", err, loadErr.Diagnostics()); strings.Contains(text, "failure-private-sentinel") {
|
||||||
|
t.Fatalf("failure diagnostics disclosed a sensitive value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("main", map[string]any{"version": "1"}),
|
Source: configkit.Mapping("main", map[string]any{"version": "2"}),
|
||||||
}}})
|
}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine with accepted declaration: %v", err)
|
t.Fatalf("construct engine with accepted declaration: %v", err)
|
||||||
@@ -117,7 +152,7 @@ func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T
|
|||||||
}
|
}
|
||||||
path := configkit.Keys("version")
|
path := configkit.Keys("version")
|
||||||
field, found := inspection.Explain(path)
|
field, found := inspection.Explain(path)
|
||||||
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "1") {
|
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "2") {
|
||||||
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
||||||
}
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
@@ -150,9 +185,8 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
t.Fatalf("zero explanation writer = %v", err)
|
t.Fatalf("zero explanation writer = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, model := "https://configured.example/v1", "configured-model"
|
profile := configkit.Input{Source: configkit.Mapping("inspection test profile", map[string]any{
|
||||||
profile := configkit.Input{Source: promptkit.ProfileSource("inspection test profile", map[string]promptkit.ProfileConfig{
|
"profiles": []any{map[string]any{"id": "inspection-profile", "endpoint": "https://configured.example/v1", "model": "configured-model"}},
|
||||||
"inspection-profile": {Endpoint: &endpoint, Model: &model},
|
|
||||||
})}
|
})}
|
||||||
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
||||||
@@ -169,14 +203,15 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("effective writer error = %v", err)
|
t.Fatalf("effective writer error = %v", err)
|
||||||
}
|
}
|
||||||
if err := view.WriteExplanation(writer, configkit.Keys("profiles", "inspection-profile", "model")); !errors.Is(err, writeErr) {
|
if err := view.WriteExplanation(writer, configkit.Keys("profiles").Index(0).Key("model")); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("explanation writer error = %v", err)
|
t.Fatalf("explanation writer error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
||||||
|
const promptDocument = "prompts:\n - id: captured\n default_profile: offline\n messages:\n - role: user\n content_file: body.tmpl\n output:\n format: text\n validation_mode: none\n"
|
||||||
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
||||||
@@ -184,7 +219,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("select inputs: %v", err)
|
t.Fatalf("select inputs: %v", err)
|
||||||
}
|
}
|
||||||
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
||||||
@@ -195,16 +230,16 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
client := countingLLMClient{calls: &modelCalls}
|
client := countingLLMClient{calls: &modelCalls}
|
||||||
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("offline profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": model}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine: %v", err)
|
t.Fatalf("construct engine: %v", err)
|
||||||
}
|
}
|
||||||
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("comparison profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("comparison profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &otherModel},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": otherModel}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,7 +261,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
if err != nil || len(differences) != 1 {
|
if err != nil || len(differences) != 1 {
|
||||||
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
||||||
}
|
}
|
||||||
if differences[0].Path.String() != "/profiles/offline/model" ||
|
if differences[0].Path.String() != "/profiles" ||
|
||||||
!strings.Contains(differences[0].Left.YAML, model) ||
|
!strings.Contains(differences[0].Left.YAML, model) ||
|
||||||
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
||||||
t.Fatalf("captured difference = %+v", differences[0])
|
t.Fatalf("captured difference = %+v", differences[0])
|
||||||
@@ -238,7 +273,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys("prompts", "captured", "messages").Index(0).Key("content_file")); !found {
|
if _, found := view.Explain(configkit.Keys("prompts").Index(0).Key("messages").Index(0).Key("content_file")); !found {
|
||||||
t.Fatal("captured prompt content path was not inspectable")
|
t.Fatal("captured prompt content path was not inspectable")
|
||||||
}
|
}
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectionInput mounts source at the selected collection's configuration
|
// CollectionInput mounts source at the selected collection's configuration
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceDocument struct {
|
type sourceDocument struct {
|
||||||
@@ -37,10 +37,10 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "collection.yaml")
|
mainPath := filepath.Join(directory, "collection.yaml")
|
||||||
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
||||||
if err := os.WriteFile(mainPath, []byte("configkit:\n imports:\n - path: defaults.yaml\n"), 0o600); err != nil {
|
if err := os.WriteFile(mainPath, []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(defaultsPath, []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
if err := os.WriteFile(defaultsPath, []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
||||||
@@ -50,7 +50,7 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -58,15 +58,15 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with native collection import: %v", err)
|
t.Fatalf("construct engine with native collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\n")},
|
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
|
||||||
"defaults.yaml": {Data: []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
"defaults.yaml": {Data: []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
||||||
}
|
}
|
||||||
filesystem := configkit.NewFilesystem("embedded collection", files)
|
filesystem := configkit.NewFilesystem("embedded collection", files)
|
||||||
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
||||||
@@ -77,7 +77,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -85,7 +85,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with embedded collection import: %v", err)
|
t.Fatalf("construct engine with embedded collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||||
@@ -38,18 +38,19 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := simpleConfig("hello")
|
cfg := simpleConfig("hello")
|
||||||
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
||||||
|
endpoint, model := "http://example.test/v1", "model"
|
||||||
lowerName := name
|
lowerName := name
|
||||||
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
||||||
Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{
|
Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &lowerName},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &lowerName},
|
||||||
}),
|
}),
|
||||||
}}})
|
}}})
|
||||||
env := " " + name + " "
|
env := " " + name + " "
|
||||||
if test.clear {
|
if test.clear {
|
||||||
env = ""
|
env = ""
|
||||||
}
|
}
|
||||||
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{
|
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &env},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &env},
|
||||||
})}
|
})}
|
||||||
var calls int
|
var calls int
|
||||||
var auth string
|
var auth string
|
||||||
@@ -73,21 +74,23 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
||||||
backendID, model := "workstation", "offline-model"
|
backendID, model := "workstation", "offline-model"
|
||||||
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
||||||
"prompts": map[string]any{"hello": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "1",
|
"id": "hello",
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}},
|
}},
|
||||||
})}
|
})}
|
||||||
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
||||||
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
|
localBackend.ID = backendID
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend,
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Backend: &backendID, Model: &model},
|
{ID: "offline", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
Main: &prompt,
|
Main: &prompt,
|
||||||
@@ -113,13 +116,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
|
|||||||
model := "configured-model"
|
model := "configured-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
|
||||||
openRouterID: {Endpoint: &openRouterEndpoint},
|
{ID: openRouterID, Endpoint: &openRouterEndpoint},
|
||||||
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
|
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
|
||||||
"openrouter-profile": {Backend: &openRouterID, Model: &model},
|
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
|
||||||
"rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model},
|
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -141,12 +144,11 @@ func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *test
|
|||||||
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
||||||
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
profiles:
|
profiles:
|
||||||
application:
|
- id: application
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
model: default-model
|
model: default-model
|
||||||
prompts:
|
prompts:
|
||||||
hello:
|
- id: hello
|
||||||
version: "1"
|
|
||||||
default_profile: application
|
default_profile: application
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -160,16 +162,16 @@ prompts:
|
|||||||
t.Fatalf("select embedded defaults: %v", err)
|
t.Fatalf("select embedded defaults: %v", err)
|
||||||
}
|
}
|
||||||
operatorDirectory := t.TempDir()
|
operatorDirectory := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n application:\n model: operator-model\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n - id: application\n endpoint: https://example.invalid/v1\n model: operator-model\n"), 0o644); err != nil {
|
||||||
t.Fatalf("write operator input: %v", err)
|
t.Fatalf("write operator input: %v", err)
|
||||||
}
|
}
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("select operator inputs: %v", err)
|
t.Fatalf("select operator inputs: %v", err)
|
||||||
}
|
}
|
||||||
programmaticModel := "programmatic-model"
|
programmaticModel, programmaticEndpoint := "programmatic-model", "https://example.invalid/v1"
|
||||||
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{
|
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{
|
||||||
"application": {Model: &programmaticModel},
|
{ID: "application", Endpoint: &programmaticEndpoint, Model: &programmaticModel},
|
||||||
})}
|
})}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -205,7 +207,7 @@ prompts:
|
|||||||
|
|
||||||
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("invalid", map[string]any{"profiles": map[string]any{"broken": map[string]any{"unknown": true}}}),
|
Source: configkit.Mapping("invalid", map[string]any{"profiles": []any{map[string]any{"id": "broken", "unknown": true}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadErr *configkit.LoadError
|
var loadErr *configkit.LoadError
|
||||||
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
||||||
@@ -225,11 +227,11 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
|
|||||||
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
|
||||||
backendID: {Endpoint: &endpoint},
|
{ID: backendID, Endpoint: &endpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
|
||||||
"application-profile": {Backend: &backendID, Model: &model},
|
{ID: "application-profile", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -251,8 +253,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
|||||||
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
||||||
authenticationKind := " OPENROUTER_KEY "
|
authenticationKind := " OPENROUTER_KEY "
|
||||||
authenticationEndpoint := "https://probe.example/key?scope=current"
|
authenticationEndpoint := "https://probe.example/key?scope=current"
|
||||||
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
|
source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
|
||||||
"local": {
|
{
|
||||||
|
ID: "local",
|
||||||
Endpoint: &backendEndpoint,
|
Endpoint: &backendEndpoint,
|
||||||
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
||||||
DefaultProfile: &defaultProfile,
|
DefaultProfile: &defaultProfile,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeLLMClient struct {
|
type fakeLLMClient struct {
|
||||||
@@ -60,11 +60,10 @@ func newSimpleEngine(t *testing.T, content string, options ...promptkit.Option)
|
|||||||
func simpleConfig(content string) promptkit.Config {
|
func simpleConfig(content string) promptkit.Config {
|
||||||
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
||||||
"profiles": map[string]any{"profile": map[string]any{
|
"profiles": []any{map[string]any{"id": "profile",
|
||||||
"endpoint": "http://example.test/v1", "model": "model",
|
"endpoint": "http://example.test/v1", "model": "model",
|
||||||
}},
|
}},
|
||||||
"prompts": map[string]any{"prompt": map[string]any{
|
"prompts": []any{map[string]any{"id": "prompt",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
|||||||
90
convert.go
90
convert.go
@@ -2,11 +2,10 @@ package promptkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
@@ -72,7 +71,6 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
|||||||
}
|
}
|
||||||
return &PreparedRun{
|
return &PreparedRun{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: copyStringPtr(prepared.PromptVersion),
|
|
||||||
PromptHash: prepared.PromptHash,
|
PromptHash: prepared.PromptHash,
|
||||||
SelectedProfileID: prepared.SelectedProfileID,
|
SelectedProfileID: prepared.SelectedProfileID,
|
||||||
SelectedBackendID: prepared.SelectedBackendID,
|
SelectedBackendID: prepared.SelectedBackendID,
|
||||||
@@ -99,7 +97,6 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
|||||||
RawOutput: result.RawOutput,
|
RawOutput: result.RawOutput,
|
||||||
Validation: fromDomainValidationResult(result.Validation),
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
PromptID: result.PromptID,
|
PromptID: result.PromptID,
|
||||||
PromptVersion: copyStringPtr(result.PromptVersion),
|
|
||||||
PromptHash: result.PromptHash,
|
PromptHash: result.PromptHash,
|
||||||
SessionID: result.SessionID,
|
SessionID: result.SessionID,
|
||||||
RenderedPromptHash: result.RenderedPromptHash,
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
@@ -257,7 +254,6 @@ func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInsp
|
|||||||
}
|
}
|
||||||
return &PromptInspection{
|
return &PromptInspection{
|
||||||
PromptID: inspection.PromptID,
|
PromptID: inspection.PromptID,
|
||||||
PromptVersion: copyStringPtr(inspection.PromptVersion),
|
|
||||||
PromptHash: inspection.PromptHash,
|
PromptHash: inspection.PromptHash,
|
||||||
DefaultProfileID: inspection.DefaultProfileID,
|
DefaultProfileID: inspection.DefaultProfileID,
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
@@ -398,88 +394,10 @@ func copyAnyMap(src map[string]any) map[string]any {
|
|||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]any, len(src))
|
return jsonvalue.CloneTrusted(src).(map[string]any)
|
||||||
for k, v := range src {
|
|
||||||
out[k] = copyAny(v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyAny(value any) any {
|
func copyAny(value any) any { return jsonvalue.CloneTrusted(value) }
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
return copyAnyMap(v)
|
|
||||||
case []any:
|
|
||||||
out := make([]any, len(v))
|
|
||||||
for i, item := range v {
|
|
||||||
out[i] = copyAny(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
return copyStringSlice(v)
|
|
||||||
case []byte:
|
|
||||||
return copyBytes(v)
|
|
||||||
default:
|
|
||||||
return copyReflectValue(reflect.ValueOf(value)).Interface()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyReflectValue(value reflect.Value) reflect.Value {
|
|
||||||
if !value.IsValid() {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Interface:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
copied := copyReflectValue(value.Elem())
|
|
||||||
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
|
|
||||||
return copied
|
|
||||||
}
|
|
||||||
out := reflect.New(value.Type()).Elem()
|
|
||||||
out.Set(copied)
|
|
||||||
return out
|
|
||||||
case reflect.Pointer:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.New(value.Type().Elem())
|
|
||||||
out.Elem().Set(copyReflectValue(value.Elem()))
|
|
||||||
return out
|
|
||||||
case reflect.Map:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
|
||||||
iter := value.MapRange()
|
|
||||||
for iter.Next() {
|
|
||||||
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case reflect.Slice:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case reflect.Array:
|
|
||||||
out := reflect.New(value.Type()).Elem()
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyStringSlice(src []string) []string {
|
func copyStringSlice(src []string) []string {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToDomainAppendedMessages(t *testing.T) {
|
func TestToDomainAppendedMessages(t *testing.T) {
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
# Configuration And API Migration
|
# Configuration And API Migration
|
||||||
|
|
||||||
Promptkit no longer installs a provider catalog or selects prompt definitions
|
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
|
||||||
by descriptive version labels. Applications now choose configuration inputs
|
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
|
||||||
explicitly, and execution selects one prompt by its exact ID. This guide
|
and removes prompt-version labels.
|
||||||
|
Promptkit installs a default-on bundled OpenRouter catalog; applications choose configuration
|
||||||
|
inputs explicitly, and execution selects one prompt by its exact ID. This guide
|
||||||
explains the shape changes and decisions an application must make; it is not an
|
explains the shape changes and decisions an application must make; it is not an
|
||||||
automatic migration tool. The [framework format reference](../formats.md) owns
|
automatic migration tool. The [framework format reference](../formats.md) owns
|
||||||
the current configuration contract, and the root package GoDoc owns exact API
|
the current configuration contract, and the root package GoDoc owns exact API
|
||||||
signatures and errors.
|
signatures and errors.
|
||||||
|
|
||||||
|
## Update Go Imports
|
||||||
|
|
||||||
|
Promptkit's module and public package import path is now
|
||||||
|
`go.maximumdirect.net/promptkit`, replacing
|
||||||
|
`gitea.maximumdirect.net/eric/promptkit`. Update application, library, test, and
|
||||||
|
generated imports, then select a release using the new declaration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@VERSION
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `VERSION` with a release that declares the vanity module path.
|
||||||
|
Earlier tags, including v0.16.0, retain their original module declaration and
|
||||||
|
use the former import path. No permanent `replace` directive is needed for the
|
||||||
|
cutover. Source browser, licensing, and hosted-release links continue to use
|
||||||
|
the Forge repository URLs, independently of Go's module identifier.
|
||||||
|
|
||||||
|
The package name remains `promptkit`. The path change itself does not rename
|
||||||
|
exported APIs or change their behavior, but Go treats packages imported through
|
||||||
|
the two paths as distinct. Update dependent packages that exchange Promptkit
|
||||||
|
types together and review any old-path dependencies retained after tidying.
|
||||||
|
The configuration and API adaptations below are also part of this migration.
|
||||||
|
|
||||||
## Select And Compose The Configuration You Need
|
## Select And Compose The Configuration You Need
|
||||||
|
|
||||||
Supply each backend, profile, prompt, schema, and collection source that the
|
Supply each backend, profile, prompt, schema, and collection source that the
|
||||||
@@ -30,21 +56,19 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Collection resource keys are local keys, while their runtime identities are
|
Collection resource IDs are local IDs, while their runtime identities are
|
||||||
qualified. For example, `collections.openrouter.profiles.gemini-flash-latest`
|
qualified. For example, a profile with `id: gemini-flash-latest` in collection
|
||||||
is selected as `openrouter/gemini-flash-latest`. Stored backend, prompt-default,
|
`openrouter` is selected as `openrouter/gemini-flash-latest`. Stored backend,
|
||||||
and other resource references must use their exact intended IDs; Promptkit does
|
prompt-default, and other resource references must use their exact intended IDs;
|
||||||
not rewrite references when a source is mounted. Profile `extends` remains a
|
Promptkit does not rewrite references when a source is mounted. `extends`
|
||||||
sibling local key in the same collection, not a cross-collection reference.
|
selects a sibling ID in the same list, not a cross-collection reference.
|
||||||
Review collisions and all references when moving resources into collections.
|
Review collisions and all references when moving resources into collections.
|
||||||
|
|
||||||
The root `version` is an optional ConfigKit source header. A collection's
|
The optional source-root `version` header now accepts `2` or `"2"`; headerless
|
||||||
`release_version` and a prompt's `version` are optional descriptive labels;
|
sources remain valid. A collection's `release_version` remains an optional
|
||||||
neither selects a resource or changes execution. Preserve a label only when it
|
label. Prompt-version labels have been removed: choose one definition per ID
|
||||||
is useful to your application. A formerly version-keyed prompt mapping must be
|
and store any application-specific release label outside Promptkit. In a
|
||||||
reduced to one deliberately chosen definition per prompt ID, with its old label
|
standalone imported prompt item, root `version` is a source header, not a label.
|
||||||
retained as prompt metadata if desired. Promptkit cannot choose a winner from a
|
|
||||||
consumer's version history.
|
|
||||||
|
|
||||||
For example, change a version-keyed definition:
|
For example, change a version-keyed definition:
|
||||||
|
|
||||||
@@ -60,12 +84,11 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
to one direct definition. Its optional `version` is only a label:
|
to one identified definition:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
report:
|
- id: report
|
||||||
version: "2.0"
|
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
content: Review this report.
|
content: Review this report.
|
||||||
@@ -74,6 +97,53 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The same conversion applies to backends and profiles. Sibling inheritance uses
|
||||||
|
identity references, and a later same-ID record replaces the whole lower record:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
- id: base
|
||||||
|
endpoint: https://example.invalid/v1
|
||||||
|
model: example-model
|
||||||
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
|
timeout_seconds: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a lower resource's fields, add a distinct inherited variant as
|
||||||
|
above; when replacing the same ID, supply the complete intended record. Generic
|
||||||
|
append, replace, imports, and inheritance are described by the
|
||||||
|
[ConfigKit v1 configuration contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
|
||||||
|
Source-root controls are now `config:`, not `configkit:`. Imports are optional
|
||||||
|
unless the selected import declares `required: true`.
|
||||||
|
|
||||||
|
Update ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0: its Go
|
||||||
|
types are incompatible with the old module path. Promptkit now requires Go
|
||||||
|
1.27.0 or newer. Source builders take ordered slices with explicit IDs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
endpoint, model := "https://example.invalid/v1", "example-model"
|
||||||
|
base := "base"
|
||||||
|
source := promptkit.ProfileSource("application", []promptkit.ProfileConfig{
|
||||||
|
{ID: "base", Endpoint: &endpoint, Model: &model},
|
||||||
|
{ID: "fast", Extends: &base},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace former maps passed to `BackendSource` in the same way, setting each
|
||||||
|
`BackendConfig.ID`. Duplicates are retained for loading to reject. Resource
|
||||||
|
inspection paths now address list indexes, for example
|
||||||
|
`configkit.Keys("profiles").Index(0).Key("model")`. Configuration inspection
|
||||||
|
and diffs expose resolved values, recorded normalization and provenance.
|
||||||
|
`InspectProfile` additionally applies backend completion; request preparation
|
||||||
|
can apply further overrides, so the views are not promised to be identical.
|
||||||
|
|
||||||
|
Remove `PromptVersion` and `PromptIdentity.Version` uses and the corresponding
|
||||||
|
`prompt_version` JSON members. Prompt definition hashes are opaque equality
|
||||||
|
values and may also change because redundant captured fields were removed.
|
||||||
|
Invalidate persisted caches or comparisons that span this change; hashes from
|
||||||
|
inspection and preparation of the same captured definition still agree.
|
||||||
|
|
||||||
## Identity Mapping
|
## Identity Mapping
|
||||||
|
|
||||||
The reviewed catalog identities have slash-free profile keys. Their documented
|
The reviewed catalog identities have slash-free profile keys. Their documented
|
||||||
@@ -100,8 +170,8 @@ Gemini profile in the same `openrouter` collection:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
standard:
|
- id: standard
|
||||||
extends: gemini-flash-latest
|
extends: {id: gemini-flash-latest}
|
||||||
```
|
```
|
||||||
|
|
||||||
An optional auto profile may instead select the exact backend and model values:
|
An optional auto profile may instead select the exact backend and model values:
|
||||||
@@ -110,7 +180,7 @@ An optional auto profile may instead select the exact backend and model values:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
auto:
|
- id: auto
|
||||||
backend: openrouter/default
|
backend: openrouter/default
|
||||||
model: openrouter/auto
|
model: openrouter/auto
|
||||||
```
|
```
|
||||||
@@ -142,10 +212,8 @@ if err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
If the former format contained multiple versions under one ID, choose the one
|
If the former format contained multiple versions under one ID, choose the one
|
||||||
definition the application should install, and optionally keep its label in
|
definition the application should install. Promptkit no longer retains or
|
||||||
the prompt's `version` field. That label remains descriptive metadata and is
|
returns its former descriptive label.
|
||||||
reported by inspection or results where applicable; it never chooses the
|
|
||||||
definition.
|
|
||||||
|
|
||||||
The public inventory APIs can help an operator inspect a captured effective
|
The public inventory APIs can help an operator inspect a captured effective
|
||||||
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ file contracts.
|
|||||||
Import the package as:
|
Import the package as:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "gitea.maximumdirect.net/eric/promptkit"
|
import "go.maximumdirect.net/promptkit"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For migration from the former module path, see
|
||||||
|
[Update Go Imports](configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
|
Configuration APIs use `go.maximumdirect.net/configkit` v1.1.0. Its types are
|
||||||
|
not interchangeable with the old ConfigKit module path.
|
||||||
|
|
||||||
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
demonstrates explicit collection composition and inventory. Applications
|
demonstrates explicit collection composition and inventory. Applications
|
||||||
adapting older provider or versioned-prompt configuration can use the
|
adapting older provider or versioned-prompt configuration can use the
|
||||||
@@ -28,7 +34,8 @@ programs.
|
|||||||
## Go Compatibility
|
## Go Compatibility
|
||||||
|
|
||||||
The `go` directive in the selected release's [module metadata](../../go.mod)
|
The `go` directive in the selected release's [module metadata](../../go.mod)
|
||||||
declares its minimum supported consumer Go version. Maintainers build and
|
declares its minimum supported consumer Go version, currently Go 1.27.0.
|
||||||
|
Maintainers build and
|
||||||
validate releases with the locally selected Go patch release; the minimum
|
validate releases with the locally selected Go patch release; the minimum
|
||||||
does not simply track that patch version.
|
does not simply track that patch version.
|
||||||
|
|
||||||
@@ -45,6 +52,32 @@ Create an engine with [`NewEngine`](../../engine.go). Select each directory
|
|||||||
explicitly and place its inputs in an ordered ConfigKit underlay. A group
|
explicitly and place its inputs in an ordered ConfigKit underlay. A group
|
||||||
strictly composes its files; later groups can overlay earlier ones:
|
strictly composes its files; later groups can overlay earlier ones:
|
||||||
|
|
||||||
|
The bundled OpenRouter backend and profiles are installed in a lower underlay
|
||||||
|
by default. Select a profile explicitly, for example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{
|
||||||
|
"id": "my-prompt",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
|
||||||
|
if err != nil { return err }
|
||||||
|
result, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "my-prompt", ProfileID: "gpt-luna-latest"})
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend looks up
|
||||||
|
`OPENROUTER_API_KEY` when available; credentials are not required at engine
|
||||||
|
construction. For the complete inventory and diagnostic defaults see the
|
||||||
|
[format reference](../formats.md#bundled-openrouter-configuration). Use
|
||||||
|
`promptkit.WithBundledOpenRouter(false)` for application-only inventories.
|
||||||
|
Caller records replace matching bundled records in full; use `extends` with a
|
||||||
|
distinct ID to inherit. Replacing `auto` also changes its cost-tier children's
|
||||||
|
inherited target, while their plugins remain. External collections remain
|
||||||
|
supported.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
@@ -108,8 +141,8 @@ defaults, err := promptkit.FilesystemInputs("embedded defaults", applicationFS,
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
profile := promptkit.ProfileSource("application choices", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("application choices", []promptkit.ProfileConfig{
|
||||||
"summary": {Model: &selectedModel},
|
{ID: "summary", Endpoint: &selectedEndpoint, Model: &selectedModel},
|
||||||
})
|
})
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
@@ -120,7 +153,9 @@ engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.Configura
|
|||||||
}})
|
}})
|
||||||
```
|
```
|
||||||
|
|
||||||
Only add the operator group when the application selects that directory; an
|
Each later same-ID resource replaces the complete earlier record; use a distinct
|
||||||
|
inherited sibling for field-level customization. Only add the operator group
|
||||||
|
when the application selects that directory; an
|
||||||
explicitly selected missing or unreadable root is an error. Each helper finds
|
explicitly selected missing or unreadable root is an error. Each helper finds
|
||||||
`.yaml` and `.yml` files recursively and disables imports by default. The
|
`.yaml` and `.yml` files recursively and disables imports by default. The
|
||||||
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
||||||
@@ -131,8 +166,8 @@ labeled mapping as an input. A mapping owns a snapshot of supported values:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
document := configkit.Mapping("application configuration", map[string]any{
|
document := configkit.Mapping("application configuration", map[string]any{
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"local-summary": map[string]any{
|
map[string]any{"id": "local-summary",
|
||||||
"backend": "local", "model": "example-model",
|
"backend": "local", "model": "example-model",
|
||||||
"temperature": nil, // Suppress an inherited provider control.
|
"temperature": nil, // Suppress an inherited provider control.
|
||||||
},
|
},
|
||||||
@@ -143,7 +178,12 @@ input := configkit.Input{Source: document}
|
|||||||
```
|
```
|
||||||
|
|
||||||
`ProfileSource` and `BackendSource` are smaller typed conveniences when null
|
`ProfileSource` and `BackendSource` are smaller typed conveniences when null
|
||||||
is not needed. Backend and profile definitions are supplied by the caller;
|
is not needed. Their provider maps use ConfigKit Mapping input forms: built-in
|
||||||
|
scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or
|
||||||
|
typed containers explicitly to an appropriate supported value, choosing numeric
|
||||||
|
precision for your application. Mapping captures supported values immediately;
|
||||||
|
unsupported or cyclic values are reported when loaded. Runtime override values
|
||||||
|
follow their separate rules. Backend and profile definitions are supplied by the caller;
|
||||||
there is no registration-only backend namespace.
|
there is no registration-only backend namespace.
|
||||||
|
|
||||||
When configuration or message files change, build and validate a new engine,
|
When configuration or message files change, build and validate a new engine,
|
||||||
@@ -202,14 +242,15 @@ sensitive application data.
|
|||||||
## Inspect The Captured Configuration
|
## Inspect The Captured Configuration
|
||||||
|
|
||||||
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
||||||
operator-selected report of effective configuration, source descriptions,
|
operator-selected report of resolved configuration, source descriptions,
|
||||||
diagnostics, and path history. For a literal ID containing a slash, construct
|
diagnostics, and path history. Configuration inspection uses list indexes in
|
||||||
the semantic path with `configkit.Keys`:
|
the collection's nested path rather than qualified runtime IDs. For a profile
|
||||||
|
at index `i` in collection `team`, construct the path with `configkit.Keys`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
path := configkit.Keys("profiles", "team/summary", "model")
|
path := configkit.Keys("collections", "team", "profiles").Index(i).Key("model")
|
||||||
explanation, found := view.Explain(path)
|
explanation, found := view.Explain(path)
|
||||||
if found && explanation.Effective.Redacted {
|
if found && explanation.Effective.Redacted {
|
||||||
// Apply the application's disclosure policy before sharing the report.
|
// Apply the application's disclosure policy before sharing the report.
|
||||||
@@ -220,8 +261,10 @@ if found && explanation.Effective.Redacted {
|
|||||||
the application decides whether those reports may be disclosed. Credential-
|
the application decides whether those reports may be disclosed. Credential-
|
||||||
shaped provider keys are redacted, but arbitrary prompt and provider content
|
shaped provider keys are redacted, but arbitrary prompt and provider content
|
||||||
is not necessarily safe to share. Inspection uses the engine's captured result
|
is not necessarily safe to share. Inspection uses the engine's captured result
|
||||||
and does not reopen sources or resolve credentials. `InspectProfile` instead
|
and does not reopen sources or resolve credentials. It retains effective values,
|
||||||
shows backend-completed execution settings for one profile, and `Prepare` or
|
including recorded field normalization and queue defaulting, alongside source
|
||||||
|
provenance.
|
||||||
|
`InspectProfile` instead shows backend-completed execution settings for one profile, and `Prepare` or
|
||||||
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
||||||
and validation resources. None of those views is a substitute for the others.
|
and validation resources. None of those views is a substitute for the others.
|
||||||
|
|
||||||
@@ -252,11 +295,13 @@ for _, difference := range differences {
|
|||||||
```
|
```
|
||||||
|
|
||||||
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
||||||
and ConfigKit's [v0.8.0 effective-configuration diff guidance](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs)
|
and ConfigKit's [v1.1.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs)
|
||||||
for the exact comparison contract. The operation compares only frozen effective
|
for the exact comparison contract. The operation compares only frozen effective
|
||||||
configuration values: it does not reread external files or compare their
|
configuration values: it does not reread external files or compare their
|
||||||
contents, resolved credential contents, later execution preparation, or
|
contents, resolved credential contents, later execution preparation, or
|
||||||
request-specific overrides. An empty diff is not proof of equivalent execution.
|
request-specific overrides. Inputs normalizing to the same effective value do
|
||||||
|
not create a value diff; lists are compared atomically. An empty diff is not
|
||||||
|
proof of equivalent execution.
|
||||||
Records can contain ordinary application data, so use the application's
|
Records can contain ordinary application data, so use the application's
|
||||||
disclosure policy when presenting them.
|
disclosure policy when presenting them.
|
||||||
|
|
||||||
@@ -620,7 +665,7 @@ copy, and credential behavior. The
|
|||||||
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
||||||
framework defaults.
|
framework defaults.
|
||||||
|
|
||||||
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with keyed
|
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with identified
|
||||||
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
||||||
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
||||||
|
|
||||||
@@ -633,13 +678,13 @@ and `WithBackend` options have no compatibility wrappers. Use
|
|||||||
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
||||||
ConfigKit file inputs for individual files, and `ProfileSource`,
|
ConfigKit file inputs for individual files, and `ProfileSource`,
|
||||||
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
||||||
are keys in configuration, not registrations. Choose caller underlays in
|
are fields in configuration, not registrations. Choose caller underlays in
|
||||||
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
||||||
highest-priority input.
|
highest-priority input.
|
||||||
|
|
||||||
Move embedded IDs into keyed `profiles` and `prompts` maps; each prompt ID now
|
Move resource IDs into identified `backends`, `profiles`, and `prompts` lists;
|
||||||
contains one installed definition, with any descriptive `version` label inside
|
each prompt ID identifies one installed definition without a version label.
|
||||||
that definition. Convert `base_profile` to ConfigKit `extends`. For the current
|
Convert `base_profile` to ConfigKit `extends: {id: parent}`. For the current
|
||||||
collection identity mapping, ID-only prompt selection, and required
|
collection identity mapping, ID-only prompt selection, and required
|
||||||
caller-owned choices, see the
|
caller-owned choices, see the
|
||||||
[configuration migration guide](configuration-migration.md). Exact current
|
[configuration migration guide](configuration-migration.md). Exact current
|
||||||
@@ -651,11 +696,11 @@ the engine to pick up changed definitions or message files.
|
|||||||
### Choose A Profile Ownership Model
|
### Choose A Profile Ownership Model
|
||||||
|
|
||||||
Supply backend and profile definitions through the configuration sources your
|
Supply backend and profile definitions through the configuration sources your
|
||||||
application selects. PromptKit does not install provider catalogs or built-in
|
application selects, alongside the default-on bundled OpenRouter inventory.
|
||||||
profile inventories; conventional backend ID constants are names only. Define
|
Conventional backend ID constants are names only. Define or replace the model,
|
||||||
the model, endpoint, credentials lookup, and settings that your application
|
endpoint, credentials lookup, and settings that your application
|
||||||
intends to support. A profile may inherit a sibling profile by its local key in
|
intends to support. A profile may inherit a sibling profile by its local key in
|
||||||
the same root or collection mapping; its backend reference uses the exact
|
the same root or collection list; its backend reference uses the exact
|
||||||
configured identity. Caller-selected source layers determine the final values. The
|
configured identity. Caller-selected source layers determine the final values. The
|
||||||
[format reference](../formats.md) describes the definition fields and
|
[format reference](../formats.md) describes the definition fields and
|
||||||
composition rules.
|
composition rules.
|
||||||
@@ -674,18 +719,19 @@ if err != nil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := inspection.EffectiveModelParams
|
target := inspection.EffectiveModelParams
|
||||||
|
if inspection.APIKeyRequired {
|
||||||
|
// Arrange a usable direct or configured environment credential.
|
||||||
|
}
|
||||||
if target.APIKeyEnv != "" {
|
if target.APIKeyEnv != "" {
|
||||||
// This is a configured optional environment lookup source.
|
// This is the configured environment lookup source, not its value.
|
||||||
} else if inspection.APIKeyRequired {
|
|
||||||
// Arrange a direct credential before later execution.
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use this configuration-time boundary when only the profile and its target need
|
Use this configuration-time boundary when only the profile and its target need
|
||||||
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
||||||
or rendering work; use prepared execution when that work must remain tied to a
|
or rendering work; use prepared execution when that work must remain tied to a
|
||||||
later execution. A reported `APIKeyEnv` is a configured optional source, while
|
later execution. A reported `APIKeyEnv` identifies a configured source, while
|
||||||
`APIKeyRequired` is the explicit local requirement. The
|
`APIKeyRequired` is the explicit requirement; both may be present. The
|
||||||
[credential format reference](../formats.md#credentials) and the method's
|
[credential format reference](../formats.md#credentials) and the method's
|
||||||
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
|
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
|
||||||
contracts.
|
contracts.
|
||||||
@@ -728,8 +774,8 @@ it and shared backend identity or capacity policy is unnecessary:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, model := "http://localhost:8000/v1", "example-model"
|
endpoint, model := "http://localhost:8000/v1", "example-model"
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Endpoint: &endpoint, Model: &model},
|
{ID: "local-summary", Endpoint: &endpoint, Model: &model},
|
||||||
})
|
})
|
||||||
// Add configkit.Input{Source: profile} to a caller underlay.
|
// Add configkit.Input{Source: profile} to a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -744,11 +790,13 @@ identity, endpoint, and concurrency limit:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
backendID, model := promptkit.BackendLocal, "example-model"
|
backendID, model := promptkit.BackendLocal, "example-model"
|
||||||
backend := promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend.ID = backendID
|
||||||
|
backend := promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
|
localBackend,
|
||||||
})
|
})
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Backend: &backendID, Model: &model},
|
{ID: "local-summary", Backend: &backendID, Model: &model},
|
||||||
})
|
})
|
||||||
// Add both sources as inputs in a caller underlay.
|
// Add both sources as inputs in a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -759,13 +807,14 @@ capacity of 1024.
|
|||||||
|
|
||||||
#### Configure A Complete Backend
|
#### Configure A Complete Backend
|
||||||
|
|
||||||
Use a keyed `BackendConfig` value for authentication, extra request parameters, an
|
Use an identified `BackendConfig` value for authentication, extra request parameters, an
|
||||||
explicit queue capacity, a custom ID, or multiple local endpoints:
|
explicit queue capacity, a custom ID, or multiple local endpoints:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
||||||
backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendConfig{
|
backend := promptkit.BackendSource("GPU backend", []promptkit.BackendConfig{
|
||||||
"local-gpu": {
|
{
|
||||||
|
ID: "local-gpu",
|
||||||
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
||||||
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
||||||
ExtraParams: map[string]any{"provider_option": "enabled"},
|
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||||
@@ -774,7 +823,7 @@ backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendCo
|
|||||||
```
|
```
|
||||||
|
|
||||||
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
||||||
`ProfileConfig.Backend` pointer or a keyed file profile. All backend
|
`ProfileConfig.Backend` pointer or an identified file profile. All backend
|
||||||
contributions use the same ConfigKit layering rules, including overlays of
|
contributions use the same ConfigKit layering rules, including overlays of
|
||||||
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
||||||
defines exact omission and construction behavior.
|
defines exact omission and construction behavior.
|
||||||
@@ -804,7 +853,7 @@ backend := promptkit.BackendConfig{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The pointer distinguishes an explicit zero from omission. Use keyed
|
The pointer distinguishes an explicit zero from omission. Use identified
|
||||||
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
||||||
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
||||||
profiles and custom backends without a configured limit remain unrestricted.
|
profiles and custom backends without a configured limit remain unrestricted.
|
||||||
@@ -836,6 +885,12 @@ secret-handling policies.
|
|||||||
|
|
||||||
## Extension Interfaces
|
## Extension Interfaces
|
||||||
|
|
||||||
|
JSON Schema validation and native structured output use a self-contained root
|
||||||
|
document. Internal references are supported; references to other files or
|
||||||
|
network resources fail during preparation. `WithSchemaFile` selects one root
|
||||||
|
file, while `WithSchemaFS` and `SchemaDir` select a root source. Prepared
|
||||||
|
executions retain their captured schema even if the source later changes.
|
||||||
|
|
||||||
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
||||||
behavior does not fit the application. Their GoDoc defines concurrent use,
|
behavior does not fit the application. Their GoDoc defines concurrent use,
|
||||||
context handling, ownership of copied values, nil responses, and preservation
|
context handling, ownership of copied values, nil responses, and preservation
|
||||||
@@ -843,6 +898,11 @@ of collaborator errors. Implementations must honor cancellation, safely manage
|
|||||||
copies they retain, avoid unsafe logging of content or credentials, and enforce
|
copies they retain, avoid unsafe logging of content or credentials, and enforce
|
||||||
the application policy that motivated the injection.
|
the application policy that motivated the injection.
|
||||||
|
|
||||||
|
A successful `ArtifactReader.Read` transfers the returned artifact and mutable
|
||||||
|
body storage to Promptkit. Readers that previously reused a returned buffer
|
||||||
|
must instead return independent storage and stop mutating it after return;
|
||||||
|
private scratch buffers may still be reused when the returned bytes are copied.
|
||||||
|
|
||||||
## Handle Errors
|
## Handle Errors
|
||||||
|
|
||||||
Use `errors.Is` with the
|
Use `errors.Is` with the
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ exist.
|
|||||||
|
|
||||||
## Maintainer Validation
|
## Maintainer Validation
|
||||||
|
|
||||||
|
Use the newest Go installation already available locally for ordinary
|
||||||
|
development checks. Put its `bin` directory first on `PATH` and set
|
||||||
|
`GOTOOLCHAIN=local` to keep that toolchain selected. For repository-isolated
|
||||||
|
checks, set `GOWORK=off`; consumers may use their own workspaces. The selected
|
||||||
|
validation patch does not by itself change the module's consumer minimum. See
|
||||||
|
the [release toolchain policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
|
and [release procedure](release.md#prepare-the-candidate) for release selection.
|
||||||
|
|
||||||
For ordinary work, select validation proportionate to the change. Do not repeat
|
For ordinary work, select validation proportionate to the change. Do not repeat
|
||||||
successful checks for unchanged content merely to create a commit. During
|
successful checks for unchanged content merely to create a commit. During
|
||||||
development, run relevant Go tests and vet, format changed Go files, and execute
|
development, run relevant Go tests and vet, format changed Go files, and execute
|
||||||
@@ -67,7 +75,6 @@ the maintained examples when they are affected:
|
|||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -w path/to/changed.go
|
gofmt -w path/to/changed.go
|
||||||
python3 scripts/check-examples.py
|
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,12 +84,12 @@ describes its requirements and recovery. For an optional diagnostic of a
|
|||||||
committed candidate without publication, run `./scripts/releasekit.sh check
|
committed candidate without publication, run `./scripts/releasekit.sh check
|
||||||
VERSION`; this is not a required pre-publication step.
|
VERSION`; this is not a required pre-publication step.
|
||||||
|
|
||||||
The standalone `python3 scripts/check-examples.py` command checks semantic
|
The example workflows are covered by the Go suite; use
|
||||||
expectations for the three offline consumer examples. The focused
|
`go test ./examples/go-library/...` to run them separately during development.
|
||||||
`python3 scripts/test-release.py` command exercises the release tooling against
|
The focused `python3 scripts/test-release.py` command exercises the release
|
||||||
disposable local repositories; use it when changing that tooling, not as a
|
tooling against disposable local repositories; use it when changing that
|
||||||
recurring release gate. Tests and examples require no provider credentials or
|
tooling, not as a recurring release gate. Tests and examples require no provider
|
||||||
live inference calls.
|
credentials or live inference calls.
|
||||||
|
|
||||||
Ordinary validation can run on intended uncommitted work. Release validation
|
Ordinary validation can run on intended uncommitted work. Release validation
|
||||||
uses committed candidate contents and the selected local Go installation.
|
uses committed candidate contents and the selected local Go installation.
|
||||||
|
|||||||
164
docs/formats.md
164
docs/formats.md
@@ -6,31 +6,28 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
|
|||||||
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
||||||
how to select sources and use an engine; the
|
how to select sources and use an engine; the
|
||||||
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
owns outbound wire behavior. [ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit)
|
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md)
|
||||||
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||||
This reference describes what Promptkit does with the final effective values.
|
This reference describes what Promptkit does with the final effective values.
|
||||||
|
|
||||||
A configuration document may contain keyed top-level `backends`, `profiles`,
|
A configuration document may contain top-level `backends`, `profiles`, and
|
||||||
and `prompts` mappings, plus a `collections` mapping of namespaced resource
|
`prompts` lists, plus a `collections` mapping of namespaced resource groups.
|
||||||
groups. Resource keys are literal IDs, not filename-derived identities. Each
|
Each resource has an explicit literal `id`, not a filename-derived identity.
|
||||||
prompt ID maps directly to one definition; an optional `version` string is
|
IDs and references must be nonblank and remain exact: Promptkit does not trim or
|
||||||
descriptive metadata, not a selection key. Keys and references must be nonblank
|
re-key them. Unknown fields are errors. Promptkit installs a default-on bundled
|
||||||
and remain exact: Promptkit does not trim or re-key them. Old embedded `id` and
|
OpenRouter underlay; callers still explicitly select a profile.
|
||||||
`base_profile` fields are invalid. Unknown fields are errors. Promptkit installs
|
|
||||||
no provider or profile catalog; callers select the definitions they need.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
local:
|
- id: local
|
||||||
endpoint: http://localhost:8000/v1
|
endpoint: http://localhost:8000/v1
|
||||||
concurrency_limit: 2
|
concurrency_limit: 2
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -42,17 +39,17 @@ collections:
|
|||||||
support:
|
support:
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
prompts:
|
prompts:
|
||||||
answer:
|
- id: answer
|
||||||
default_profile: support/fast
|
default_profile: support/fast
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -66,31 +63,34 @@ collections:
|
|||||||
|
|
||||||
An input may omit a source header. When present, the source-root `version` is
|
An input may omit a source header. When present, the source-root `version` is
|
||||||
reserved for ConfigKit's specification header and accepts only the exact string
|
reserved for ConfigKit's specification header and accepts only the exact string
|
||||||
`"1"` or strict decimal integer `1`; null and other values are invalid. This
|
`"2"` or strict decimal integer `2`; null and other values are invalid. This
|
||||||
header is distinct from a prompt's nested `version` label and a collection's
|
header is distinct from a collection's `release_version` and ConfigKit's own
|
||||||
`release_version`. ConfigKit checks and consumes accepted headers on non-main
|
software version. ConfigKit checks and consumes accepted headers on non-main
|
||||||
inputs; a declaration on the main input remains visible in explicit
|
inputs; a declaration on the main input remains visible in explicit
|
||||||
configuration inspection. Headerless inputs still undergo ordinary final-value
|
configuration inspection. Headerless inputs still undergo ordinary final-value
|
||||||
validation. ConfigKit owns generic import and source-history behavior.
|
validation. ConfigKit owns generic import and source-history behavior.
|
||||||
|
For a standalone prompt item, a root `version` is only a source header, not
|
||||||
|
prompt metadata.
|
||||||
|
|
||||||
The root resource mappings contain unqualified IDs. Each `collections` entry
|
The root resource lists contain unqualified IDs. Each `collections` entry
|
||||||
has a namespace key and may contain `release_version` plus the same three
|
has a namespace key and may contain `release_version` plus the same three
|
||||||
resource mappings. Collection resource keys are local keys; their runtime IDs
|
resource lists. Collection resource IDs are local keys; their runtime IDs
|
||||||
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
||||||
namespace and local key must be nonblank and slash-free, and references must
|
namespace and local key must be nonblank and slash-free, and references must
|
||||||
name their intended exact ID; mounting a source does not rewrite references.
|
name their intended exact ID; mounting a source does not rewrite references.
|
||||||
For example, the `support/fast` profile above stores `support/offline` as its
|
For example, the `support/fast` profile above stores `support/offline` as its
|
||||||
backend ID. In `collections.support.profiles.fast`, ConfigKit `extends: base`
|
backend ID. `extends: {id: base}` refers to a sibling in the same resource
|
||||||
refers to a sibling profile in that same mapping. It is not a qualified or
|
list. It is not a qualified or cross-collection reference.
|
||||||
cross-collection reference.
|
|
||||||
|
|
||||||
`release_version` and prompt `version` are optional opaque labels. Absent or
|
`release_version` is an optional opaque collection label. Absent or null means
|
||||||
null means no label; an explicitly empty string is retained as a declared
|
no label; an explicitly empty string is retained. It does not select a resource
|
||||||
label. Neither label selects a resource or changes execution. Runtime lookups
|
or change execution. Runtime lookups
|
||||||
use the captured effective resource indexes. `InspectConfiguration` is an
|
use the captured effective resource indexes. `InspectConfiguration` is an
|
||||||
explicit operator view of effective settings and provenance; source history is
|
explicit operator view of resolved settings and provenance. Selected field
|
||||||
not a second runtime resolver, and the retained effective shape is not flattened
|
normalizations and the derived queue default are recorded in that tree, with
|
||||||
or rewritten into those indexes.
|
source history retained. Request-time completion remains separate. Source
|
||||||
|
history is not a second runtime resolver, and the
|
||||||
|
retained effective shape is not flattened or rewritten into those indexes.
|
||||||
|
|
||||||
For examples of explicitly selecting native collection inputs and inspecting
|
For examples of explicitly selecting native collection inputs and inspecting
|
||||||
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
||||||
@@ -116,12 +116,11 @@ see the consumer guide's [resource and collection listing](consumers/pkg-promptk
|
|||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
The `prompts` mapping selects a literal prompt ID whose value is the definition:
|
The `prompts` list contains definitions selected by their literal IDs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
description: Summarize a synthetic meeting note.
|
description: Summarize a synthetic meeting note.
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
@@ -237,7 +236,8 @@ It may contain `default_profile`, `reachability`, and `authentication`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
openrouter:
|
- id: openrouter
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
smoke_tests:
|
smoke_tests:
|
||||||
default_profile: application-profile
|
default_profile: application-profile
|
||||||
reachability:
|
reachability:
|
||||||
@@ -266,21 +266,22 @@ These values are configuration only: engine construction does not contact a
|
|||||||
backend.
|
backend.
|
||||||
|
|
||||||
These fields are resolved through the ordinary ConfigKit source layers before
|
These fields are resolved through the ordinary ConfigKit source layers before
|
||||||
Promptkit validates the final effective values. A higher-priority mapping can
|
Promptkit validates the final effective values. Sibling inheritance can overlay
|
||||||
override individual nested fields; an explicit null can clear the whole
|
individual nested fields; a later same-ID resource replaces the whole record.
|
||||||
`smoke_tests` block or one of its nested objects. An endpoint set to null uses
|
An explicit null can clear the whole `smoke_tests` block or one of its nested
|
||||||
|
objects. An endpoint set to null uses
|
||||||
its documented fallback, while an explicit blank endpoint is invalid. The
|
its documented fallback, while an explicit blank endpoint is invalid. The
|
||||||
metadata does not change ordinary generation credential requirements or
|
metadata does not change ordinary generation credential requirements or
|
||||||
provider settings.
|
provider settings.
|
||||||
|
|
||||||
## Profile Definitions
|
## Profile Definitions
|
||||||
|
|
||||||
Profiles are keyed by literal ID and may inherit a named parent with
|
Profiles have literal IDs and may inherit a sibling parent with
|
||||||
`extends`:
|
`extends`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
@@ -288,8 +289,8 @@ profiles:
|
|||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
extra_params:
|
extra_params:
|
||||||
provider_option: enabled
|
provider_option: enabled
|
||||||
local-summary-fast:
|
- id: local-summary-fast
|
||||||
extends: local-summary
|
extends: {id: local-summary}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
reasoning_effort: low
|
reasoning_effort: low
|
||||||
```
|
```
|
||||||
@@ -331,9 +332,58 @@ For example, a runtime `extra_params["extends"]` key is ordinary provider
|
|||||||
data, not a configuration inheritance instruction. An explicit zero timeout
|
data, not a configuration inheritance instruction. An explicit zero timeout
|
||||||
does not disable the caller context or HTTP transport timeout.
|
does not disable the caller context or HTTP transport timeout.
|
||||||
|
|
||||||
|
### Bundled OpenRouter Configuration
|
||||||
|
|
||||||
|
The default bundle installs root backend `openrouter`, endpoint
|
||||||
|
`https://openrouter.ai/api/v1`, optional credential environment name
|
||||||
|
`OPENROUTER_API_KEY`, and smoke metadata selecting `gpt-luna-latest` with the
|
||||||
|
`openrouter_key` authentication adapter. Existing diagnostic endpoint fallback
|
||||||
|
applies. No prompt, implicit profile selection, or tuning defaults are installed.
|
||||||
|
Disable the entire bundle with `WithBundledOpenRouter(false)`.
|
||||||
|
|
||||||
|
The root profile IDs and their models are:
|
||||||
|
|
||||||
|
| Profile | Model |
|
||||||
|
| --- | --- |
|
||||||
|
| `gpt-astra-latest` | `~openai/gpt-astra-latest` |
|
||||||
|
| `gpt-sol-latest` | `~openai/gpt-sol-latest` |
|
||||||
|
| `gpt-terra-latest` | `~openai/gpt-terra-latest` |
|
||||||
|
| `gpt-luna-latest` | `~openai/gpt-luna-latest` |
|
||||||
|
| `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
||||||
|
| `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
||||||
|
| `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
||||||
|
| `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
||||||
|
| `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
||||||
|
| `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
||||||
|
| `auto` | `openrouter/auto` |
|
||||||
|
| `free` | `openrouter/free` |
|
||||||
|
|
||||||
|
The profiles `auto-low`, `auto-medium`, `auto-high`, `auto-xhigh`, and
|
||||||
|
`auto-max` extend `auto` and add an `auto-router` plugin with the corresponding
|
||||||
|
`cost_tier`. Replacing `auto` changes their inherited backend and model while
|
||||||
|
retaining their plugins. These are root IDs even when a model contains `/`.
|
||||||
|
For example, a caller can replace `auto` as a complete record and extend another
|
||||||
|
bundled profile under a new ID:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
- id: auto
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/free
|
||||||
|
- id: my-luna
|
||||||
|
extends: {id: gpt-luna-latest}
|
||||||
|
timeout_seconds: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
See OpenRouter's [alias examples](https://openrouter.ai/~openai/gpt-luna-latest),
|
||||||
|
[auto router and cost tiers](https://openrouter.ai/docs/guides/routing/routers/auto-router),
|
||||||
|
and [free router](https://openrouter.ai/docs/guides/routing/routers/free-router)
|
||||||
|
for provider behavior and availability.
|
||||||
|
|
||||||
### Source And Profile Precedence
|
### Source And Profile Precedence
|
||||||
|
|
||||||
`NewEngine` composes caller underlays in their given order, followed by an
|
`NewEngine` composes the bundled OpenRouter underlay first by default, then
|
||||||
|
caller underlays in their given order, followed by an
|
||||||
optional main input. Later layers overlay earlier ones. Inputs within one
|
optional main input. Later layers overlay earlier ones. Inputs within one
|
||||||
underlay group are strictly composed: conflicting contributions are errors,
|
underlay group are strictly composed: conflicting contributions are errors,
|
||||||
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
||||||
@@ -343,34 +393,42 @@ nothing. Imports are disabled by these helpers unless the caller enables them
|
|||||||
on an input. Single files can use ConfigKit inputs directly.
|
on an input. Single files can use ConfigKit inputs directly.
|
||||||
|
|
||||||
Root backends, profiles, and prompts, as well as collection declarations and
|
Root backends, profiles, and prompts, as well as collection declarations and
|
||||||
their resource mappings, follow these same source rules. `CollectionInput`
|
their resource lists, follow these same source rules. `CollectionInput`
|
||||||
mounts one explicitly selected source at a collection destination and enables
|
mounts one explicitly selected source at a collection destination and enables
|
||||||
ConfigKit imports for that input; it does not discover or select sources.
|
ConfigKit imports for that input; it does not discover or select sources.
|
||||||
A later profile or backend can change individual fields of a lower definition.
|
A later same-ID record replaces the lower definition in full. Use a distinct
|
||||||
|
inherited sibling when a variant should retain its parent's fields.
|
||||||
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
||||||
neither is selected, preparation fails. Exact profile inspection selects one
|
neither is selected, preparation fails. Exact profile inspection selects one
|
||||||
explicit ID and does not consult a prompt default.
|
explicit ID and does not consult a prompt default.
|
||||||
|
|
||||||
### Profile Inheritance
|
### Resource Inheritance
|
||||||
|
|
||||||
ConfigKit resolves `extends` on effective profile definitions. One profile
|
ConfigKit resolves `extends: {id: parent}` for backends, profiles, and prompts.
|
||||||
may inherit a sibling profile by local key in the same root or collection
|
Each child inherits a surviving sibling in the same root or collection list;
|
||||||
mapping; collection inheritance cannot cross namespaces. Presence, explicit
|
collection inheritance cannot cross namespaces. Parents are ordinary validated
|
||||||
|
resources, and profiles may remain partial until request completion. Presence, explicit
|
||||||
null, and recursively merged maps determine the final fields. Other resource
|
null, and recursively merged maps determine the final fields. Other resource
|
||||||
references are exact IDs and use their qualified form when they select a
|
references are exact IDs and use their qualified form when they select a
|
||||||
collection resource. There is no Promptkit-specific depth limit or second
|
collection resource. There is no Promptkit-specific depth limit or second
|
||||||
base-chain resolver. Missing references and cycles fail at construction. For
|
base-chain resolver. Missing references and cycles fail at construction. For
|
||||||
generic inheritance and layer mechanics, consult
|
generic inheritance and layer mechanics, consult
|
||||||
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit).
|
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
Schemas are JSON documents selected by a prompt or request `schema_path`.
|
Schemas are JSON documents selected by a prompt or request `schema_path`.
|
||||||
Directory and `fs.FS` schema paths resolve within the configured root;
|
Directory and `fs.FS` schema paths resolve within the configured root. Each
|
||||||
referenced nested schemas resolve relative to the owning schema document.
|
schema must be self-contained: in-document references, IDs, and anchors are
|
||||||
|
supported, but references requiring another file or network resource fail.
|
||||||
|
References must also work without the source filename as a base; use fragment
|
||||||
|
references or explicit schema IDs rather than relying on that filename.
|
||||||
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
||||||
invalid, or unresolvable schema causes an operational validation error.
|
invalid, or unresolvable schema causes an operational validation error.
|
||||||
Valid JSON that fails the schema instead yields a failed validation result.
|
Valid JSON that fails the schema instead yields a failed validation result.
|
||||||
|
Schema documents are detached without applying the provider extra-parameter
|
||||||
|
copy limits; this does not change the schema decoder, compiler, or
|
||||||
|
self-contained-resource rules.
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
|||||||
Before the client is called, the engine resolves framework, backend, profile,
|
Before the client is called, the engine resolves framework, backend, profile,
|
||||||
and request values into one execution target. Endpoint configuration is trimmed
|
and request values into one execution target. Endpoint configuration is trimmed
|
||||||
and must be an absolute HTTP or HTTPS URL with a host and without user
|
and must be an absolute HTTP or HTTPS URL with a host and without user
|
||||||
information, a query, or a fragment. A non-empty endpoint from the target
|
information, a query, or a fragment. The target's endpoint is validated again
|
||||||
overrides the client's configured base URL. The final selected endpoint is
|
before transport.
|
||||||
validated again before transport.
|
|
||||||
|
|
||||||
The completion URL is composed through parsed URL path operations. Nested base
|
The completion URL is composed through parsed URL path operations. Nested base
|
||||||
paths are retained, repeated trailing slashes are normalized, and the result
|
paths are retained, repeated trailing slashes are normalized, and the result
|
||||||
has exactly one appended `/chat/completions` suffix. Generation fails before
|
has exactly one appended `/chat/completions` suffix. Generation fails before
|
||||||
sending when neither source supplies a valid endpoint.
|
sending when the target does not supply a valid endpoint.
|
||||||
|
|
||||||
The target's backend ID is routing metadata for prepared values, results, and
|
The target's backend ID is routing metadata for prepared values, results, and
|
||||||
injected clients. The built-in client does not derive the URL from that ID and
|
injected clients. The built-in client does not derive the URL from that ID and
|
||||||
@@ -70,7 +69,9 @@ and numeric `usage`; unrelated fields and account limits are ignored. This is
|
|||||||
only a minimal identifying subset of OpenRouter's documented
|
only a minimal identifying subset of OpenRouter's documented
|
||||||
[current-key response](https://github.com/OpenRouterTeam/docs/blob/main/api_reference/limits.mdx#checking-your-limits),
|
[current-key response](https://github.com/OpenRouterTeam/docs/blob/main/api_reference/limits.mdx#checking-your-limits),
|
||||||
not validation of the full account document. Other statuses and unrecognized
|
not validation of the full account document. Other statuses and unrecognized
|
||||||
200 responses do not establish acceptance.
|
200 responses do not establish acceptance. A 200 response exceeding the bounded
|
||||||
|
recognition allowance reports `authentication_response_limit_exceeded`, leaving
|
||||||
|
credential acceptance unknown; see the [public reason](../../smoke_diagnostics.go).
|
||||||
|
|
||||||
Diagnostic HTTP evidence describes the last request observed at Promptkit's
|
Diagnostic HTTP evidence describes the last request observed at Promptkit's
|
||||||
RoundTripper boundary and any response associated with it. It includes the
|
RoundTripper boundary and any response associated with it. It includes the
|
||||||
@@ -87,8 +88,7 @@ transport for HTTP-only diagnostics.
|
|||||||
## Request Body
|
## Request Body
|
||||||
|
|
||||||
The request body always contains `model` and `messages`. The execution
|
The request body always contains `model` and `messages`. The execution
|
||||||
target's model takes precedence over the client's configured model, and one
|
target's model must be available.
|
||||||
must be available.
|
|
||||||
|
|
||||||
Each ordinary message contains its `role` and string `content`. A
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
cache-controlled message instead uses a text content block containing `type`,
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
@@ -115,9 +115,10 @@ The client conditionally includes:
|
|||||||
strict flag, and schema document. Plain JSON validation does not add an
|
strict flag, and schema document. Plain JSON validation does not add an
|
||||||
object-only response constraint.
|
object-only response constraint.
|
||||||
|
|
||||||
The engine resolves backend, profile, and request extra-parameter maps by
|
Configured profile maps recursively overlay backend defaults, while a nonempty
|
||||||
whole-map replacement rather than key merging. The resulting effective map is
|
runtime override replaces the complete configured map. The [format reference](../formats.md#defaults-and-overrides)
|
||||||
merged into the top-level body. Empty keys, values that cannot be encoded, and
|
owns these resolution rules. The resulting effective map is merged into the
|
||||||
|
top-level body. Empty keys, values that cannot be encoded, and
|
||||||
collisions with these reserved fields are rejected before any provider call:
|
collisions with these reserved fields are rejected before any provider call:
|
||||||
|
|
||||||
- `model`
|
- `model`
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ Preparation and validation never hold an active permit.
|
|||||||
|
|
||||||
The manager's `Acquire` operation owns that active-permit acquisition and
|
The manager's `Acquire` operation owns that active-permit acquisition and
|
||||||
returns an idempotent release function. The coordinator uses it around
|
returns an idempotent release function. The coordinator uses it around
|
||||||
individual attempts. The earlier one-shot wrapper still delegates to the same
|
individual attempts. There is one queue and one active limit per limited backend
|
||||||
operation for focused internal callers and tests, but engine assembly no
|
pool.
|
||||||
longer uses it. There is one queue and one active limit per limited backend pool.
|
|
||||||
|
|
||||||
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
||||||
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
||||||
@@ -121,7 +120,7 @@ validation, bounded admission, idempotent release, context handling, and
|
|||||||
unlimited admission. The
|
unlimited admission. The
|
||||||
[client tests](../../internal/capacity/client_test.go) own peak enforcement,
|
[client tests](../../internal/capacity/client_test.go) own peak enforcement,
|
||||||
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
||||||
unlimited calls, passthrough behavior, and panic release.
|
unlimited calls, and panic release using test-only one-shot calls.
|
||||||
|
|
||||||
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
||||||
admission, lease lifetime, failure release, and shared initial/repair
|
admission, lease lifetime, failure release, and shared initial/repair
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ client uses its endpoint, credential metadata, generation fields, and extra
|
|||||||
parameters. `BackendID` remains routing metadata
|
parameters. `BackendID` remains routing metadata
|
||||||
for the generation boundary and is not mapped into the provider payload.
|
for the generation boundary and is not mapped into the provider payload.
|
||||||
|
|
||||||
Construction trims and validates a nonempty configured base URL and clones any
|
Construction clones any supplied `http.Client` so Promptkit can apply its
|
||||||
supplied `http.Client` so Promptkit can apply its timeout default without
|
timeout default without mutating the caller's client. Generation preparation
|
||||||
mutating the caller's client. An empty configured base remains valid because a
|
then:
|
||||||
resolved request target may supply the endpoint. Generation preparation then:
|
|
||||||
|
|
||||||
1. validates shared execution-setting invariants and the final selected base
|
1. validates shared execution-setting invariants and the final selected base
|
||||||
endpoint;
|
endpoint;
|
||||||
@@ -42,6 +41,10 @@ resolved request target may supply the endpoint. Generation preparation then:
|
|||||||
the complete-request failure remains a request failure;
|
the complete-request failure remains a request failure;
|
||||||
6. resolves authentication and freezes the URL, body, and optional credential.
|
6. resolves authentication and freezes the URL, body, and optional credential.
|
||||||
|
|
||||||
|
Provider values are detached with trusted copying before encoding. Custom JSON
|
||||||
|
encoding methods and per-value encoding-failure diagnostics operate on those
|
||||||
|
private copies rather than retained prepared or diagnostic metadata.
|
||||||
|
|
||||||
Each execution of that prepared request creates a fresh HTTP request, body
|
Each execution of that prepared request creates a fresh HTTP request, body
|
||||||
reader, and attempt timeout context. It closes the response body and returns
|
reader, and attempt timeout context. It closes the response body and returns
|
||||||
the HTTP status, decoded response or error, reported token values and presence,
|
the HTTP status, decoded response or error, reported token values and presence,
|
||||||
@@ -77,7 +80,8 @@ not retain headers, credentials, raw request or response bodies, or a redirect
|
|||||||
trace. These are observations at Promptkit's RoundTripper boundary, not proof
|
trace. These are observations at Promptkit's RoundTripper boundary, not proof
|
||||||
that bytes reached a peer or that a custom transport did no hidden dispatch.
|
that bytes reached a peer or that a custom transport did no hidden dispatch.
|
||||||
Reachability and named authentication probes use the same configured built-in
|
Reachability and named authentication probes use the same configured built-in
|
||||||
client but discard their response bodies after local recognition. The
|
client. The OpenRouter adapter owns endpoint derivation, bounded account-response
|
||||||
|
recognition, and body closure; the smoke coordinator interprets its evidence. The
|
||||||
[integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics)
|
[integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics)
|
||||||
owns their observable HTTP semantics.
|
owns their observable HTTP semantics.
|
||||||
|
|
||||||
@@ -105,14 +109,13 @@ public ownership and redaction semantics belong to the
|
|||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
|
|
||||||
The package preserves distinct error identities for invalid client
|
The package preserves distinct error identities for invalid generation
|
||||||
configuration, invalid generation requests, request execution failures,
|
requests, request execution failures, non-success provider statuses, and
|
||||||
non-success provider statuses, and malformed successful responses. Provider
|
malformed successful responses. Provider response bodies are never exposed in
|
||||||
response bodies are never exposed in raw form through non-success errors.
|
raw form through non-success errors.
|
||||||
|
|
||||||
Invalid nonempty configured endpoints are configuration failures. A missing or
|
A missing or invalid final selected endpoint is an invalid generation request
|
||||||
invalid final selected endpoint is an invalid generation request and is
|
and is rejected before transport.
|
||||||
rejected before transport.
|
|
||||||
|
|
||||||
Authentication resolves a trimmed direct key before a trimmed configured
|
Authentication resolves a trimmed direct key before a trimmed configured
|
||||||
environment value. Optional missing, empty, or whitespace-only sources do not
|
environment value. Optional missing, empty, or whitespace-only sources do not
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ lifecycle instrumentation spans the facade and runner boundaries.
|
|||||||
Prepared execution starts only after its private payload is atomically claimed;
|
Prepared execution starts only after its private payload is atomically claimed;
|
||||||
the facade receives that invocation's recorder privately and emits the terminal
|
the facade receives that invocation's recorder privately and emits the terminal
|
||||||
event after public error mapping. Failed claims and preparation emit nothing.
|
event after public error mapping. Failed claims and preparation emit nothing.
|
||||||
|
The public projection retains private operation-owner metadata separately from
|
||||||
|
inspectable child causes. Terminal classification uses that owner, preserving
|
||||||
|
context precedence and generation-specific transport/provider details rather
|
||||||
|
than mistaking a collaborator's nested lookup error for the parent's operation.
|
||||||
|
|
||||||
The facade masks inherited recorder and prepared-claim state when entering
|
The facade masks inherited recorder and prepared-claim state when entering
|
||||||
preparation or execution. Delegated calls from an injected collaborator retain
|
preparation or execution. Delegated calls from an injected collaborator retain
|
||||||
@@ -25,8 +29,7 @@ other content. Events are values with no mutable containers. Callback panics
|
|||||||
propagate to the caller.
|
propagate to the caller.
|
||||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||||
the invocation, including namespace qualification when supplied by an engine
|
the invocation, including namespace qualification when supplied by an engine
|
||||||
view. A prompt's optional version label is descriptive metadata, not a
|
view.
|
||||||
selection key.
|
|
||||||
Per-operation callbacks are ordered and synchronous; different operations may
|
Per-operation callbacks are ordered and synchronous; different operations may
|
||||||
invoke the same callback concurrently. The recorder marks each measured phase's
|
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||||
end before delivery, so callback work does not inflate provider duration.
|
end before delivery, so callback work does not inflate provider duration.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ contributor workflow and validation.
|
|||||||
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
|
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible provider values, and detaches previously accepted values without repeating validation. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||||
| `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
|
| `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
|
||||||
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
|
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
|
||||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) |
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) |
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ The internal engine applies each namespace view to slashless caller selections
|
|||||||
before they reach the runner. The runner therefore selects captured prompts and
|
before they reach the runner. The runner therefore selects captured prompts and
|
||||||
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
||||||
through, while configured defaults and stored references are used as authored
|
through, while configured defaults and stored references are used as authored
|
||||||
and are not requalified. Prompt version labels are returned as optional
|
and are not requalified.
|
||||||
descriptive metadata; they are not prompt selectors.
|
|
||||||
|
|
||||||
Ordinary preparation selects the full prompt definition and construction-time
|
Ordinary preparation selects the full prompt definition and construction-time
|
||||||
hash. It retains its early request-ID check before direct-session
|
hash. It retains its early request-ID check before direct-session
|
||||||
normalization. Referenced message content and static template syntax were
|
normalization. Referenced message content and static template syntax were
|
||||||
captured when the engine was built.
|
captured when the engine was built.
|
||||||
|
Rendering uses those captured parses, cloning them per call to bind request
|
||||||
|
input helpers; it does not parse templates during execution.
|
||||||
|
|
||||||
`InspectPrompt` selects captured metadata through an optional repository
|
`InspectPrompt` selects captured metadata directly from the repository. It
|
||||||
capability, falling back to the full definition and existing hash resolution
|
returns owned metadata without
|
||||||
for internal repositories without it. It returns owned metadata without
|
|
||||||
rendering templates or touching profile, artifact, schema, renderer, validator,
|
rendering templates or touching profile, artifact, schema, renderer, validator,
|
||||||
admission, or model collaborators. The root
|
admission, or model collaborators. The root
|
||||||
[`Engine.InspectPrompt`](../../engine.go) GoDoc owns the public operation's
|
[`Engine.InspectPrompt`](../../engine.go) GoDoc owns the public operation's
|
||||||
@@ -58,8 +58,7 @@ snapshot by the selected prompt ID. The analyzer receives only
|
|||||||
those identity fields rather than an execution definition. It analyzes the
|
those identity fields rather than an execution definition. It analyzes the
|
||||||
snapshot with the supplied caller variables and normalized direct-session
|
snapshot with the supplied caller variables and normalized direct-session
|
||||||
bypass; it does not resolve target readiness or enter the preparation
|
bypass; it does not resolve target readiness or enter the preparation
|
||||||
pipeline. Missing snapshot capability is a prompt-load failure rather than a
|
pipeline. An unavailable captured snapshot is a prompt-load failure. The root
|
||||||
source fallback. The root
|
|
||||||
[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public
|
[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public
|
||||||
operation's exact contract.
|
operation's exact contract.
|
||||||
|
|
||||||
@@ -143,11 +142,10 @@ one execution-precedence and error-ordering implementation while allowing a
|
|||||||
full backend pool to reject work before expensive schema, artifact, and
|
full backend pool to reject work before expensive schema, artifact, and
|
||||||
rendering operations. `Prepare` discards the plan after returning its public
|
rendering operations. `Prepare` discards the plan after returning its public
|
||||||
metadata. `Run` retains the plan through initial and repaired-output validation
|
metadata. `Run` retains the plan through initial and repaired-output validation
|
||||||
and discards it when the operation ends. Prepared execution transfers the
|
and discards it when the operation ends. Prepared execution retains one
|
||||||
completed preparation's owned message, input-hash, and copied-schema values
|
immutable, redacted metadata snapshot shared by execution and internal details.
|
||||||
into its private payload, while deep-copying mutable provider extras and
|
The facade detaches mutable public details. Claim completion or discard clears
|
||||||
creating an independent details snapshot. Claim completion or discard clears
|
execution-only payload references without modifying the retained metadata.
|
||||||
the execution payload without removing the details snapshot.
|
|
||||||
|
|
||||||
The root facade's `Engine.ValidateRequest` uses the same complete preparation
|
The root facade's `Engine.ValidateRequest` uses the same complete preparation
|
||||||
path as `Prepare` and discards the resulting internal prepared value without
|
path as `Prepare` and discards the resulting internal prepared value without
|
||||||
@@ -177,7 +175,7 @@ encoding of that session, every role and content value, and cache-control
|
|||||||
presence and values; its hexadecimal value is opaque.
|
presence and values; its hexadecimal value is opaque.
|
||||||
|
|
||||||
The configuration snapshot is read-only after engine construction. Concurrent
|
The configuration snapshot is read-only after engine construction. Concurrent
|
||||||
`Prepare` and `Run` calls copy their resolved targets and keep all invocation
|
`Prepare` and `Run` calls use detached captured targets and keep invocation
|
||||||
state local.
|
state local.
|
||||||
|
|
||||||
## Run Flow
|
## Run Flow
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ source semantics. Internal packages are not consumer APIs.
|
|||||||
`internal/configuration` is the single adapter from ConfigKit's final
|
`internal/configuration` is the single adapter from ConfigKit's final
|
||||||
effective configuration to execution-owned definitions. It performs one
|
effective configuration to execution-owned definitions. It performs one
|
||||||
layered load, captures effective presence (including explicit null), records
|
layered load, captures effective presence (including explicit null), records
|
||||||
normalization and defaults through resolution, and validates all effective
|
field-level normalization and derived defaults during resolution, and validates all effective
|
||||||
root and collection backend, profile, and prompt values. Replaced lower-layer
|
root and collection backend, profile, and prompt values. Replaced lower-layer
|
||||||
domain values are not separately validated. It indexes root resources under
|
domain values are not separately validated. It indexes root resources under
|
||||||
their unqualified IDs and collection resources under their full
|
their unqualified IDs and collection resources under their full
|
||||||
`namespace/local-key` IDs while retaining ConfigKit's effective tree in its
|
`namespace/local-key` IDs while retaining ConfigKit's resolved tree. It builds an immutable backend index and presence-aware profile
|
||||||
authored shape. It builds an immutable backend index and presence-aware profile
|
|
||||||
targets, applying final backend defaults only after profile inheritance. Runtime
|
targets, applying final backend defaults only after profile inheritance. Runtime
|
||||||
lookups use those final indexes and exact stored references; they do not rewrite
|
lookups use those final indexes and exact stored references; they do not rewrite
|
||||||
or resolve resources from ConfigKit source history. The runtime/domain packages
|
or resolve resources from ConfigKit source history. The runtime/domain packages
|
||||||
@@ -27,22 +26,22 @@ do not depend on ConfigKit.
|
|||||||
ConfigKit owns source headers, imports, composition, and source evidence.
|
ConfigKit owns source headers, imports, composition, and source evidence.
|
||||||
Promptkit does not preparse contributions: it validates the final effective
|
Promptkit does not preparse contributions: it validates the final effective
|
||||||
values once, captures the indexed identities and their original resource
|
values once, captures the indexed identities and their original resource
|
||||||
contexts, and retains ConfigKit's result for explicit operator inspection.
|
contexts, and retains ConfigKit's resolved result for explicit operator inspection.
|
||||||
Accepted non-main source headers are consumed during composition; the main
|
Accepted non-main source headers are consumed during composition; the main
|
||||||
source declaration remains available in that inspection view. Prompt and
|
source declaration remains available in that inspection view. Collection
|
||||||
collection release labels are descriptive metadata, not selection inputs. The
|
release labels are descriptive metadata, not selection inputs. The
|
||||||
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
||||||
owns the exact source-header and collection contracts.
|
owns the exact source-header and collection contracts.
|
||||||
|
|
||||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||||
once to ConfigKit v0.8.0 and returns its public difference records. Comparison
|
once to ConfigKit v1.1.0 and returns its public difference records. Comparison
|
||||||
uses only frozen effective values and does not reopen sources, resolve runtime
|
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||||
both results; reports may still contain ordinary application data. See the
|
both results; reports may still contain ordinary application data. See the
|
||||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||||
and ConfigKit's [v0.8.0 comparison contract](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs).
|
and ConfigKit's [v1.1.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs).
|
||||||
|
|
||||||
After resource materialization and effective-profile snapshot construction, the
|
After resource materialization and effective-profile snapshot construction, the
|
||||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
adapter captures lexically ordered identity inventories for backends, profiles,
|
||||||
@@ -53,7 +52,9 @@ captured values without consulting ConfigKit or copying resource definitions.
|
|||||||
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
|
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
|
||||||
files under an explicit root, provide resource context, and leave imports
|
files under an explicit root, provide resource context, and leave imports
|
||||||
disabled. Each returned slice belongs to one strict-composition group; file
|
disabled. Each returned slice belongs to one strict-composition group; file
|
||||||
order does not confer priority. Caller underlays are composed in their given
|
order does not confer priority. Engine assembly prepends a dedicated embedded
|
||||||
|
OpenRouter underlay unless disabled, without modifying caller-owned slices.
|
||||||
|
Caller underlays are composed in their given
|
||||||
order, followed by the optional main input. The
|
order, followed by the optional main input. The
|
||||||
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
||||||
real loader and final-value handoff.
|
real loader and final-value handoff.
|
||||||
@@ -84,11 +85,14 @@ returning and retains no source capability for public inspection.
|
|||||||
|
|
||||||
Prompt materialization shares a lazy metadata snapshot across root and collection
|
Prompt materialization shares a lazy metadata snapshot across root and collection
|
||||||
resources during construction, avoiding a full metadata copy per collection.
|
resources during construction, avoiding a full metadata copy per collection.
|
||||||
|
Input metadata, message content and templates, and output contracts are captured
|
||||||
|
separately before the definition, inspection, and hash are assembled.
|
||||||
|
|
||||||
Role and cache-control normalization is applied to execution-owned message
|
Normalization and defaults are recorded at individual resolved field paths,
|
||||||
copies. ConfigKit v0.8.0 retains effective input message metadata and its
|
including message roles and cache controls. List indexes identify fields for
|
||||||
resource origins; recording a sequence-wide normalization in the retained
|
presence, validation, sensitivity, and resource context; full IDs select
|
||||||
configuration would replace origins of unchanged content-file fields.
|
captured runtime indexes. Unchanged content-file fields retain their origins.
|
||||||
|
Backend-to-profile and request-time completion remain separate runtime work.
|
||||||
|
|
||||||
The runner selects full definitions from this index for preparation and uses
|
The runner selects full definitions from this index for preparation and uses
|
||||||
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
||||||
@@ -142,10 +146,10 @@ change rendering behavior.
|
|||||||
## Profiles
|
## Profiles
|
||||||
|
|
||||||
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
||||||
`extends` names a sibling local key; it is not a cross-collection reference.
|
`extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference.
|
||||||
The configuration adapter validates final effective resource references and
|
The configuration adapter validates final effective resource references and
|
||||||
field values, then builds target
|
field values, then captures backend definitions and profile target snapshots
|
||||||
snapshots with backend defaults and presence/suppression state. A profile can
|
with backend defaults and presence/suppression state. A profile can
|
||||||
remain partial until runtime overrides complete it. `InspectProfile` selects
|
remain partial until runtime overrides complete it. `InspectProfile` selects
|
||||||
that captured target without prompt, artifact, schema, credential lookup, or
|
that captured target without prompt, artifact, schema, credential lookup, or
|
||||||
provider work. Preparation applies runtime overrides and then checks target
|
provider work. Preparation applies runtime overrides and then checks target
|
||||||
@@ -214,12 +218,14 @@ instead of first making a response-sized string copy.
|
|||||||
|
|
||||||
Every preparation operation creates one operation-local validation plan. None,
|
Every preparation operation creates one operation-local validation plan. None,
|
||||||
basic, and JSON modes retain the effective output contract without source
|
basic, and JSON modes retain the effective output contract without source
|
||||||
access. JSON Schema mode loads the root document once, resolves and compiles
|
access. JSON Schema mode loads the root document once, compiles its declared
|
||||||
each transitive reference, and retains the compiled validator. Schema compiler
|
schema locations, and retains the compiled root validator. External resources
|
||||||
resources use canonical escaped file or private-scheme URLs; loaders decode
|
are rejected without file or network loading; in-document references remain
|
||||||
their paths once and enforce the configured source boundary. The
|
supported. Root resources use canonical escaped file or private-scheme URLs. The
|
||||||
provider-facing structured-output metadata uses the root document captured by
|
provider-facing structured-output metadata uses the root document captured by
|
||||||
the same plan.
|
the same plan. Its detached schema document is not revalidated against the
|
||||||
|
runtime provider-value copier's traversal limits. Smoke inference uses a
|
||||||
|
separate JSON-only prepared plan before checking the prepared schema plan.
|
||||||
|
|
||||||
Schema preparation and execution remain synchronous. Promptkit checks
|
Schema preparation and execution remain synchronous. Promptkit checks
|
||||||
cancellation before and after source resolution, JSON decoding, compilation,
|
cancellation before and after source resolution, JSON decoding, compilation,
|
||||||
|
|||||||
@@ -26,17 +26,31 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/COPYRIGHT) |
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
|
||||||
|
|
||||||
|
## ReleaseKit
|
||||||
|
|
||||||
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (maintainer tool).
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## jsonschema
|
## jsonschema
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**Base Policy Version:** 1.8
|
||||||
|
|
||||||
**Last Updated**: 2026-09-27
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||||
|
|
||||||
@@ -221,7 +221,8 @@ Promptkit retains its LGPL-3.0-or-later library grant in
|
|||||||
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
||||||
scope identified there and in [attribution](../oss/attribution.md).
|
scope identified there and in [attribution](../oss/attribution.md).
|
||||||
|
|
||||||
Promptkit publishes source-only Go module tags on Gitea, with no binaries or
|
Promptkit publishes source-only Go module tags and Gitea release pages, with no
|
||||||
separate hosted download page. The [release procedure](../release.md) owns
|
binaries. Each new hosted page reuses the complete checked-in release note,
|
||||||
publication and concrete footer URLs. The hosted-page guidance applies if such
|
including its licensing footer. The [release procedure](../release.md) owns
|
||||||
a channel is deliberately introduced; it does not require creating one.
|
source publication, hosted delivery, and concrete footer URLs. Previously
|
||||||
|
published notes and tags retain their original records.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-10-08
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
@@ -241,9 +241,13 @@ navigation on 2026-09-26. Apply them prospectively; previously published notes
|
|||||||
and tags retain their original records.
|
and tags retain their original records.
|
||||||
|
|
||||||
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
||||||
annotated stable SemVer tags. It has no binaries, hosted download pages, or
|
annotated stable SemVer tags. The source commit is pushed through main before
|
||||||
asynchronous deliverables. The source commit is pushed through main before the
|
the tag. After source publication, maintainers use ReleaseKit locally to deliver
|
||||||
tag. The [release procedure](../release.md) owns commands and recovery.
|
a Gitea release page containing the complete checked-in note, including its
|
||||||
|
licensing footer. There are no binaries or other deliverables. Source publication
|
||||||
|
and hosted delivery have separate outcomes; delivery credentials and service
|
||||||
|
availability do not gate source-release completion. The
|
||||||
|
[release procedure](../release.md) owns commands and recovery.
|
||||||
|
|
||||||
Release preparation uses the latest Go installation available in the development
|
Release preparation uses the latest Go installation available in the development
|
||||||
environment to build the maintainer release executable and validate the
|
environment to build the maintainer release executable and validate the
|
||||||
@@ -267,9 +271,11 @@ The [publication command](../../scripts/release.sh) delegates to the released
|
|||||||
ReleaseKit tool selected in `go.mod`. The committed
|
ReleaseKit tool selected in `go.mod`. The committed
|
||||||
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
||||||
committed Go formatting, local Markdown links, workspace and filesystem
|
committed Go formatting, local Markdown links, workspace and filesystem
|
||||||
replacement inspection, and the offline semantic checks for the three
|
replacement inspection. The Go suite includes the offline semantic checks for
|
||||||
maintained consumer examples. These are the selected checks for the final
|
the three maintained consumer examples. It also rejects the configured local
|
||||||
candidate; a separate ordinary test run or standalone build is not an
|
delivery token path if committed; this narrowly scoped check is not a general credential
|
||||||
|
scan. These are the selected checks for the final candidate; a separate
|
||||||
|
ordinary test run or standalone build is not an
|
||||||
additional release gate. The upstream tool owns candidate, remote, and tag
|
additional release gate. The upstream tool owns candidate, remote, and tag
|
||||||
publication checks. It returns native status 0 for confirmed publication, 1
|
publication checks. It returns native status 0 for confirmed publication, 1
|
||||||
for a known validation or publication failure, 2 for invalid input or profile,
|
for a known validation or publication failure, 2 for invalid input or profile,
|
||||||
@@ -278,3 +284,9 @@ captured publication endpoint before retrying; the repository-specific
|
|||||||
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
||||||
instructions. Do not wait for CI or Go module availability. Licensing footer
|
instructions. Do not wait for CI or Go module availability. Licensing footer
|
||||||
URLs are preparation work, not a network gate.
|
URLs are preparation work, not a network gate.
|
||||||
|
|
||||||
|
Hosted delivery is a separate post-publication step for new releases. It reuses
|
||||||
|
the immutable tag and note without rerunning candidate validation or waiting
|
||||||
|
for CI. Retry failed delivery against that same version after resolving any
|
||||||
|
uncertain hosted write; preserve published tags and hosted content. Report
|
||||||
|
delivery status separately from the completed source release.
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ documented validation model.
|
|||||||
|
|
||||||
The selected ReleaseKit profile runs the race-enabled package suite and full
|
The selected ReleaseKit profile runs the race-enabled package suite and full
|
||||||
vet, checks committed Go formatting, local Markdown links, and module
|
vet, checks committed Go formatting, local Markdown links, and module
|
||||||
distribution hygiene, and executes semantic assertions for the three
|
distribution hygiene. The Go suite includes semantic assertions for the three
|
||||||
maintained offline consumer examples. ReleaseKit owns this shared validation
|
maintained offline consumer examples. ReleaseKit owns this shared validation
|
||||||
machinery and publication workflow. The test run builds the packages; the
|
machinery and publication workflow. The test run builds the packages and
|
||||||
examples also exercise ordinary compilation. Separate ordinary tests and a
|
exercises the examples. Separate ordinary tests and a
|
||||||
standalone build are not additional release gates. Ordinary tests remain useful
|
standalone build are not additional release gates. Ordinary tests remain useful
|
||||||
during development. The preparation example protects assembled preparation and
|
during development. The preparation example protects assembled preparation and
|
||||||
inspection behavior. The execution example separately protects assembled
|
inspection behavior. The execution example separately protects assembled
|
||||||
|
|||||||
192
docs/release.md
192
docs/release.md
@@ -1,26 +1,26 @@
|
|||||||
# Release Procedure
|
# Release Procedure
|
||||||
|
|
||||||
This runbook implements the [release policy](policy/release.md). Promptkit
|
This runbook implements the [release policy](policy/release.md). Promptkit
|
||||||
publishes source-only Go module tags from `main` to `origin`. A successful push
|
publishes source-only Go module tags from `main` to `origin`, followed by a
|
||||||
of the selected annotated tag, after its source commit is available on `main`,
|
page-only Gitea release. Successful publication of the intended tag, with its
|
||||||
completes the release. Do not wait for CI or Go module availability.
|
source commit available on `main`, completes the source release. Report hosted
|
||||||
|
delivery separately. Do not wait for CI or Go module availability.
|
||||||
|
|
||||||
## Prepare The Candidate
|
## Prepare The Candidate
|
||||||
|
|
||||||
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
Required tools are Git, Go with race-detector support, and POSIX shell. Work
|
||||||
3. Work from the repository root with push access to `origin`.
|
from the repository root with push access to `origin`.
|
||||||
|
|
||||||
Use the latest Go installation available locally for the release tool, the
|
Use the latest Go installation available locally for the release tool and its
|
||||||
candidate validation, and the maintained examples. Make it the active `go`
|
child commands. Put it first on `PATH` and keep it selected throughout:
|
||||||
command and record its version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
go version
|
go version
|
||||||
```
|
```
|
||||||
|
|
||||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
For a minor release, normally advance the consumer minimum to the initial
|
||||||
release of that Go series before committing the candidate:
|
stable patch of that Go series:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_go_version=$(go env GOVERSION)
|
release_go_version=$(go env GOVERSION)
|
||||||
@@ -29,105 +29,131 @@ release_go_series=${release_go_series%.*}
|
|||||||
go mod edit "-go=${release_go_series}.0"
|
go mod edit "-go=${release_go_series}.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, Go 1.26.8 normally results in `go 1.26.0`. Keep a higher patch
|
Retain a higher patch minimum when required by a dependency or identified fix.
|
||||||
minimum if a dependency or identified correctness fix requires it. For a patch
|
For a patch release, preserve the existing minimum. Explain minimum increases
|
||||||
release, leave the consumer minimum unchanged; using a newer validation
|
under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
||||||
toolchain does not itself raise that minimum. If the candidate needs a higher
|
Prepare dependency caches before committing the candidate:
|
||||||
minimum, prepare a minor release instead. See the
|
|
||||||
[consumer-minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
|
||||||
|
|
||||||
Prepare module caches before validation; this may update `go.sum` and belongs
|
|
||||||
before the candidate commit:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GOTOOLCHAIN=local go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
||||||
title containing `Promptkit` and that exact version, a short summary, and any
|
title containing `Promptkit` and that exact version, a short summary, and any
|
||||||
relevant compatibility impact or upgrade instructions. No fixed section
|
compatibility impact or upgrade instructions. End the note with:
|
||||||
headings are required. Describe a raised Go minimum and its compatibility
|
|
||||||
impact under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
|
||||||
End each new note with the licensing footer below, using the selected version:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the footer paths against the candidate; the future tag need not resolve
|
Substitute the selected version and check the paths against the candidate;
|
||||||
remotely during preparation. Follow the [OSS policy](policy/oss.md) for these
|
future tag URLs need not resolve during preparation. Follow the
|
||||||
links. Promptkit publishes no binaries, hosted download page, or other
|
[OSS policy](policy/oss.md). Historical notes and tags remain unchanged.
|
||||||
asynchronous deliverables.
|
|
||||||
|
|
||||||
Retire completed roadmaps while preserving deferred work and repairing incoming
|
Supply delivery credentials through `RELEASEKIT_GITEA_TOKEN` or the ignored
|
||||||
links. Review the intended changes through the ordinary development workflow,
|
local file `.releasekit/gitea-token`, with owner-only file permissions. A
|
||||||
commit the candidate on `main`, and push `main` to `origin` before publication.
|
nonblank environment value takes precedence. Source publication neither reads
|
||||||
Preparation may update module metadata; the release bootstrap and validation
|
nor requires this token. The [profile](../releasekit.json) selects the token
|
||||||
preserve the committed candidate.
|
path for committed-file protection; keep credentials out of commits and logs.
|
||||||
|
|
||||||
|
Retire completed roadmaps, preserve unfinished work, and repair affected links.
|
||||||
|
Review and commit the candidate on `main`, including any module updates, then
|
||||||
|
push `main` to `origin` before publishing its tag.
|
||||||
|
|
||||||
## Validate And Publish
|
## Validate And Publish
|
||||||
|
|
||||||
For an optional local diagnostic of a committed candidate, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/releasekit.sh check vMAJOR.MINOR.PATCH
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not a required step before publication. The upstream
|
|
||||||
[candidate-check contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/consumers/check.md)
|
|
||||||
describes its requirements.
|
|
||||||
|
|
||||||
Run the single publication command with the chosen version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release.sh vMAJOR.MINOR.PATCH
|
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The familiar wrapper delegates to the native ReleaseKit executable selected in
|
The launcher uses `go tool` to build or reuse the module-selected ReleaseKit
|
||||||
`go.mod`. The committed [profile](../releasekit.json) selects the race-enabled
|
executable in Go's cache, using the selected local toolchain. ReleaseKit runs
|
||||||
Go suite, full vet, committed Go formatting, local Markdown links, workspace
|
the [profile's checks](../releasekit.json) once, verifies publication state, and
|
||||||
and filesystem replacement inspection, and semantic checks for the three
|
publishes only the intended tag. Preserve its native exit status and diagnostic.
|
||||||
maintained offline consumer examples. ReleaseKit also verifies the committed
|
Successful publication needs no separate remote read-back or CI wait.
|
||||||
candidate and publication state, then publishes only the selected tag. Its
|
|
||||||
checks run once for this candidate; do not repeat them as a separate sign-off.
|
|
||||||
|
|
||||||
The native command returns status 0 for confirmed publication, 1 for a known
|
For optional local diagnosis, use `./scripts/releasekit.sh check VERSION`.
|
||||||
validation or publication failure, 2 for invalid input or profile, and 3 when
|
It is not a required step before publication. The upstream
|
||||||
the push outcome remains unknown. Successful publication needs no remote
|
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/check.md)
|
||||||
read-back, CI wait, or installation check. Provider probes and inference calls
|
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/cli.md)
|
||||||
are not part of validation.
|
own shared checks and exit-status definitions.
|
||||||
|
|
||||||
|
## Deliver The Hosted Release
|
||||||
|
|
||||||
|
After source publication, keep a clean checkout at the published tag's commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit.sh deliver vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile selects page-only Gitea delivery for this repository. The hosted
|
||||||
|
body is the complete checked-in note, including its licensing footer. There are
|
||||||
|
no binary assets, checksum manifests, candidate revalidation, or CI waits.
|
||||||
|
ReleaseKit checks delivery credentials during delivery, not as a publication
|
||||||
|
gate. Report the hosted URL on success; delivery failure leaves the source
|
||||||
|
release complete.
|
||||||
|
|
||||||
|
For a later retry, use a clean checkout of the original tag with its recorded
|
||||||
|
module files and supply the token there. In a separate checkout, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
release_version=vMAJOR.MINOR.PATCH
|
||||||
|
release_tag_ref="refs/tags/$release_version"
|
||||||
|
git fetch origin "$release_tag_ref:$release_tag_ref"
|
||||||
|
release_tag_commit=$(git rev-parse --verify "$release_tag_ref^{commit}")
|
||||||
|
git switch --detach "$release_tag_commit"
|
||||||
|
release_worktree_status=$(git status --porcelain --untracked-files=all)
|
||||||
|
if [ -n "$release_worktree_status" ]; then
|
||||||
|
printf '%s\n' 'Delivery retry requires a clean tagged checkout.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
./scripts/releasekit.sh deliver "$release_version"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve uncertain hosted writes before retrying the same immutable version.
|
||||||
|
Matching content is accepted; conflicts are not overwritten. Do not rerun
|
||||||
|
`publish` or move tags to repair delivery. Follow the upstream
|
||||||
|
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/delivery.md)
|
||||||
|
for result interpretation and recovery. These commands apply to tags containing
|
||||||
|
this delivery configuration; older releases retain their original workflows.
|
||||||
|
|
||||||
## Failure Recovery
|
## Failure Recovery
|
||||||
|
|
||||||
For status 1, use the diagnostic to distinguish a validation failure from a
|
For source-publication failures, use ReleaseKit's status and diagnostic to
|
||||||
known publication conflict. Correct and commit candidate defects, push `main`,
|
distinguish a candidate defect from a publication conflict. Correct and commit
|
||||||
and rerun publication with an unused version when appropriate. Status 2 means
|
candidate defects, push `main`, and rerun publication as appropriate. A failure
|
||||||
the invocation or profile must be corrected.
|
after local tag creation may leave a ref even when its object ID is unavailable.
|
||||||
|
|
||||||
For status 3, do not retry or remove the local tag until the remote outcome is
|
Status 3 means the push outcome remains unknown. Do not retry or remove the local
|
||||||
resolved. ReleaseKit reports the attempted version, tag object, and recovery
|
tag until the authoritative outcome is resolved. ReleaseKit attempts to save
|
||||||
state, including the captured publication endpoint when available. Use that
|
private publication evidence and reports a record path or a separate recording
|
||||||
endpoint—not merely the current `origin` URL—to query the exact tag; follow the
|
warning. A recording failure does not change the publication outcome.
|
||||||
upstream
|
|
||||||
[failure-recovery procedure](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/release.md#resolve-failures):
|
Use the recovery command reported for that attempt, or discover local records
|
||||||
|
and explicitly select the matching attempt:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
./scripts/releasekit.sh publication-attempts
|
||||||
releasekit_version='vX.Y.Z'
|
./scripts/releasekit.sh reconcile --record /absolute/path/to/selected-record.json
|
||||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
|
||||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the returned object ID with the reported tag object. An exact match
|
Discovery is local; reconciliation observes the original destination without
|
||||||
confirms publication. A missing ref confirms absence only when the endpoint
|
rerunning validation, changing tags, or delivering hosted content. Neither is
|
||||||
identity check and query both succeed. If either check fails, the outcome
|
a routine gate after successful publication. Follow the upstream
|
||||||
remains unknown; correct the transport configuration and resolve it before
|
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/release.md#resolve-publication-failures)
|
||||||
retrying. Only after authoritative absence is confirmed may an unpublished
|
for outcomes, incomplete evidence, and older attempts without records.
|
||||||
local tag be removed before correcting the candidate or retrying. Never move,
|
|
||||||
overwrite, or delete a published tag.
|
|
||||||
|
|
||||||
## Policy Changes
|
Records are stored under Git's common directory by default and can contain
|
||||||
|
credential-bearing endpoints. Keep them private, out of source control and
|
||||||
|
shared logs; retain them while needed for recovery and remove them when that
|
||||||
|
value expires. See the [evidence guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
for storage and retention details.
|
||||||
|
|
||||||
Update the policy and this procedure together when changing release governance.
|
Current absence does not prove a version was never published. Remove a retained
|
||||||
Consumers own their dependency-availability and release checks; their readiness
|
local tag only after establishing absence and confirming it was not already
|
||||||
does not delay completion of a Promptkit release.
|
published or used by another release channel. Never reuse a known published
|
||||||
|
version or replace a published tag or hosted page. Corrections require a new
|
||||||
|
appropriate version.
|
||||||
|
|||||||
68
docs/releases/v0.17.0.md
Normal file
68
docs/releases/v0.17.0.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Promptkit v0.17.0
|
||||||
|
|
||||||
|
This release adopts the vanity module path `go.maximumdirect.net/promptkit`
|
||||||
|
and ConfigKit v1.0.0, standardizes configuration resources as identified lists,
|
||||||
|
and removes descriptive prompt-version metadata. These are intentional breaking
|
||||||
|
changes before v1. The minimum supported Go version increases from 1.26.0 to
|
||||||
|
1.27.0 for the ConfigKit v1 migration.
|
||||||
|
|
||||||
|
## Breaking Changes And Upgrade
|
||||||
|
|
||||||
|
Consumers upgrading from v0.16.0 should follow the
|
||||||
|
[configuration and API migration guide](../consumers/configuration-migration.md):
|
||||||
|
|
||||||
|
- Change Promptkit imports from `gitea.maximumdirect.net/eric/promptkit` to
|
||||||
|
`go.maximumdirect.net/promptkit`. Update packages that exchange Promptkit
|
||||||
|
types together; old-path and vanity-path types are distinct.
|
||||||
|
- Change ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0 and
|
||||||
|
adopt its renamed public inspection types and fields where used.
|
||||||
|
- Convert root and collection `prompts`, `profiles`, and `backends` from keyed
|
||||||
|
mappings to lists whose records contain an explicit `id`. Collections remain
|
||||||
|
keyed by namespace. Sibling inheritance now uses `extends: {id: parent}`;
|
||||||
|
later same-ID records replace lower records in full.
|
||||||
|
- Change `ProfileSource` and `BackendSource` calls to pass slices of
|
||||||
|
`ProfileConfig` and `BackendConfig`, setting each record's `ID`.
|
||||||
|
- Change source-root `configkit:` controls to `config:`. Imports are optional
|
||||||
|
unless marked `required: true`. Optional Promptkit specification headers now
|
||||||
|
use `version: 2` or `version: "2"`; headerless sources remain valid.
|
||||||
|
- Remove descriptive prompt `version` values and code consuming `PromptVersion`,
|
||||||
|
`PromptIdentity.Version`, or corresponding `prompt_version` output members.
|
||||||
|
Collection `release_version` labels remain available. Prompt hashes may change
|
||||||
|
across this migration.
|
||||||
|
|
||||||
|
After adapting Go imports, configuration, and API calls, use Go 1.27.0 or newer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.17.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Earlier tags retain their original module declarations. No permanent local
|
||||||
|
replacement is needed for this cutover. See the
|
||||||
|
[framework format reference](../formats.md) for current configuration semantics
|
||||||
|
and the [maintained preparation example](../../examples/go-library/prepare/main.go)
|
||||||
|
for explicit composition, source-relative imports, and scoped discovery.
|
||||||
|
|
||||||
|
## Configuration Inspection And Execution
|
||||||
|
|
||||||
|
Configuration inspection and diffs expose ConfigKit's composed values and
|
||||||
|
provenance, including list-index paths. Promptkit applies domain normalization
|
||||||
|
and execution defaults to owned interpretations without replacing the source
|
||||||
|
origins needed for relative resources. Profile inspection and preparation expose
|
||||||
|
their interpreted or effective settings. See the
|
||||||
|
[migration guide](../consumers/configuration-migration.md#select-and-compose-the-configuration-you-need)
|
||||||
|
for the distinction and inspection-path updates.
|
||||||
|
|
||||||
|
## Maintainer Tooling
|
||||||
|
|
||||||
|
Release tooling now uses the module-selected ReleaseKit v0.8.0 through `go tool`,
|
||||||
|
with page-only Gitea delivery and saved-publication-attempt recovery. The
|
||||||
|
[release procedure](../release.md) describes publication and recovery.
|
||||||
|
|
||||||
|
The three maintained offline examples now have ordinary Go workflow tests,
|
||||||
|
included in the standard suite. Their separate Python runner and custom release
|
||||||
|
hook have been removed; Python is no longer a normal release prerequisite.
|
||||||
|
The optional release-wiring diagnostic remains Python. See the
|
||||||
|
[development guide](../development.md#maintainer-validation).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/docs/oss/attribution.md)
|
||||||
65
docs/releases/v0.18.0.md
Normal file
65
docs/releases/v0.18.0.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Promptkit v0.18.0
|
||||||
|
|
||||||
|
Configuration inspection now exposes Promptkit's recorded field normalization
|
||||||
|
and derived queue-capacity defaults through ConfigKit v1.1.0. Engines also
|
||||||
|
include a default-on OpenRouter configuration. The minimum supported Go
|
||||||
|
version remains 1.27.0.
|
||||||
|
|
||||||
|
## Bundled OpenRouter Backend And Profiles
|
||||||
|
|
||||||
|
New engines include an embedded OpenRouter backend and seventeen root profiles:
|
||||||
|
ten latest-family aliases, the auto and free routers, and five auto-router
|
||||||
|
cost-tier variants. Select a profile explicitly in a prompt or request; the
|
||||||
|
bundle does not supply an implicit execution profile or contact providers
|
||||||
|
during construction. The backend uses `OPENROUTER_API_KEY` when available under
|
||||||
|
the existing optional-credential rules.
|
||||||
|
|
||||||
|
The bundle is the lowest-priority underlay. Applications can replace its records
|
||||||
|
or derive named variants through ordinary configuration, and external collections
|
||||||
|
remain supported. Cost-tier variants inherit the effective `auto` profile, so
|
||||||
|
replacing that parent changes their inherited target while retaining plugins.
|
||||||
|
The [bundle reference](../formats.md#bundled-openrouter-configuration) owns the
|
||||||
|
inventory and composition details.
|
||||||
|
|
||||||
|
Explicit smoke diagnostics now have OpenRouter authentication metadata and an
|
||||||
|
inference fallback profile. They remain opt-in and may incur provider costs.
|
||||||
|
Latest aliases and routers can change upstream; configured slugs do not pin
|
||||||
|
the concrete model, pricing, or availability. See the
|
||||||
|
[consumer guide](../consumers/pkg-promptkit.md#construct-an-engine) for selection
|
||||||
|
and opt-out examples.
|
||||||
|
|
||||||
|
## Recorded Configuration Normalization
|
||||||
|
|
||||||
|
Inspection shows normalized backend and profile settings, prompt metadata, and
|
||||||
|
configured message roles and cache controls, with field-level resolution
|
||||||
|
history. Existing source evidence remains available, and unchanged content-file
|
||||||
|
origins continue to select their resource context.
|
||||||
|
|
||||||
|
Configuration diffs compare normalized effective values, so spelling-only
|
||||||
|
changes that normalize identically may no longer appear. Lists remain atomic in
|
||||||
|
diffs. Inspection does not include backend-to-profile completion or request
|
||||||
|
overrides; see the [configuration inspection guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration).
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
New engines install a bundled OpenRouter backend and root profiles by default.
|
||||||
|
Inventory listings and inspection sources therefore change, and references to
|
||||||
|
bundled IDs that previously failed may now resolve. Pass
|
||||||
|
`promptkit.WithBundledOpenRouter(false)` to retain application-only behavior.
|
||||||
|
Caller records can replace bundled definitions through ordinary composition;
|
||||||
|
same-ID replacements replace complete records rather than merging selected fields.
|
||||||
|
No configuration-format migration is required from v0.17.0.
|
||||||
|
Consumers that display or compare configuration inspection results should account
|
||||||
|
for normalized values and derived defaults replacing their earlier authored
|
||||||
|
representations. Request-time overrides and caller-appended messages retain
|
||||||
|
their existing processing.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.18.0
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [framework format reference](../formats.md) for current configuration
|
||||||
|
semantics. Consumers upgrading from versions before v0.17.0 should also follow
|
||||||
|
the [configuration and API migration guide](../consumers/configuration-migration.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/docs/oss/attribution.md)
|
||||||
80
docs/releases/v0.19.0.md
Normal file
80
docs/releases/v0.19.0.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Promptkit v0.19.0
|
||||||
|
|
||||||
|
This release completes two rounds of audit remediation, correcting error and
|
||||||
|
diagnostic reporting and simplifying configuration, validation, and value
|
||||||
|
ownership. It includes intentional compatibility changes for custom artifact
|
||||||
|
readers, programmatic configuration sources, and schemas. The minimum supported
|
||||||
|
Go version remains 1.27.0; ConfigKit remains at v1.1.0.
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
Consumers upgrading from v0.18.0 should review these changes:
|
||||||
|
|
||||||
|
- A successful `ArtifactReader.Read` transfers ownership of the returned
|
||||||
|
artifact and its mutable body storage to Promptkit. Custom readers must stop
|
||||||
|
mutating returned values through retained aliases. Readers that reuse scratch
|
||||||
|
buffers must return independent storage. Promptkit retains its defensive
|
||||||
|
body copy, but that copy does not make concurrent buffer reuse safe. See the
|
||||||
|
[extension guidance](../consumers/pkg-promptkit.md#extension-interfaces).
|
||||||
|
- Provider maps passed through `ProfileSource` and `BackendSource` now use
|
||||||
|
ConfigKit's native Mapping input forms directly: built-in scalars, `[]any`,
|
||||||
|
and `map[string]any` recursively. Convert `json.Number` and typed containers
|
||||||
|
explicitly to supported values, choosing numeric precision appropriate to
|
||||||
|
the application. The former recursive compatibility conversion is removed.
|
||||||
|
Runtime execution overrides retain their separate accepted-value behavior.
|
||||||
|
- Schemas used for local validation and native structured output must be
|
||||||
|
self-contained. Move externally referenced definitions into the root document
|
||||||
|
and use internal references or explicit schema IDs. References requiring
|
||||||
|
another file or network resource, or relying on the source filename as a base,
|
||||||
|
fail during preparation. Promptkit does not bundle external schemas. See the
|
||||||
|
[schema reference](../formats.md#schemas).
|
||||||
|
|
||||||
|
Prepared schemas no longer inherit the depth and node limits used when copying
|
||||||
|
runtime provider values. Schemas previously rejected solely by those incidental
|
||||||
|
limits may now be accepted; decoder/compiler checks and the self-contained
|
||||||
|
schema requirement still apply.
|
||||||
|
|
||||||
|
No configuration-format migration is required from v0.18.0. After addressing
|
||||||
|
any affected integrations:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.19.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors And Diagnostics
|
||||||
|
|
||||||
|
Public errors and terminal observations now preserve the category of the
|
||||||
|
operation that failed even when an injected collaborator returns an error from
|
||||||
|
another Promptkit operation. Underlying causes remain available through
|
||||||
|
`errors.Is` and `errors.As`, without repeatedly duplicating nested cause trees.
|
||||||
|
|
||||||
|
Built-in generation preserves response-body read failures separately from
|
||||||
|
malformed response content, and failures during request preparation are
|
||||||
|
distinguished from dispatched attempts.
|
||||||
|
|
||||||
|
OpenRouter authentication diagnostics bound response recognition to 1 MiB.
|
||||||
|
An oversized HTTP 200 response reports
|
||||||
|
`SmokeTestReasonAuthenticationResponseLimitExceeded`, leaving credential
|
||||||
|
acceptance unknown. Cancellation during the read remains an interruption.
|
||||||
|
Consumers that enumerate diagnostic reasons should handle the new reason;
|
||||||
|
see the [diagnostic integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics).
|
||||||
|
|
||||||
|
## Internal Simplification And Ownership
|
||||||
|
|
||||||
|
Prompt lookup and rendering now consistently use captured configuration, and
|
||||||
|
smoke syntax and schema checks share the prepared output-validation lifecycle.
|
||||||
|
Unused routing defaults, alternate private workflows, and duplicate wire
|
||||||
|
representations have been removed.
|
||||||
|
|
||||||
|
Provider-setting resolution, inspection, prepared details, and smoke reports
|
||||||
|
avoid redundant validation and copying of already accepted values. Prepared
|
||||||
|
execution shares one private redacted metadata snapshot while preserving
|
||||||
|
independent caller-visible values. Provider values are detached before custom
|
||||||
|
JSON encoders run, protecting retained metadata from encoder-side mutation.
|
||||||
|
|
||||||
|
Requirement analysis, namespace-filtered listings, and repair-feedback
|
||||||
|
construction also avoid unnecessary traversal and temporary allocation. These
|
||||||
|
changes preserve existing request isolation and execution behavior without
|
||||||
|
introducing new performance guarantees.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/docs/oss/attribution.md)
|
||||||
@@ -126,7 +126,7 @@ cycle-safe, and resolved afresh for ordinary operations. Prepared execution
|
|||||||
freezes the fully resolved target. The selected leaf ID remains public while
|
freezes the fully resolved target. The selected leaf ID remains public while
|
||||||
effective execution settings reflect the resolved chain.
|
effective execution settings reflect the resolved chain.
|
||||||
|
|
||||||
See the [profile inheritance format reference](../formats.md#profile-inheritance),
|
See the [profile inheritance format reference](../formats.md#resource-inheritance),
|
||||||
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
||||||
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
||||||
behavior.
|
behavior.
|
||||||
|
|||||||
@@ -38,8 +38,17 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
No ideas are currently awaiting selection. Active feature work belongs in its
|
### Schema Bundling
|
||||||
focused roadmap rather than this catalog.
|
|
||||||
|
Allow consumers to author reusable schemas across multiple files and package
|
||||||
|
them into a single self-contained schema for local validation and provider
|
||||||
|
submission. This could restore multi-file authoring convenience while retaining
|
||||||
|
the [self-contained schema requirement](../formats.md#schemas).
|
||||||
|
|
||||||
|
Bundling would need to preserve reference semantics, identifiers, anchors, and
|
||||||
|
recursion. A complete bundle would not guarantee that a provider supports every
|
||||||
|
schema feature. This is a possible future enhancement; implementation is not
|
||||||
|
currently approved.
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
37
engine.go
37
engine.go
@@ -8,11 +8,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
internalengine "go.maximumdirect.net/promptkit/internal/engine"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||||
@@ -101,8 +101,9 @@ type Engine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
||||||
// highest-priority main input. Each underlay's inputs strictly compose within
|
// highest-priority main input. By default a bundled OpenRouter underlay precedes
|
||||||
// their group; later underlays overlay earlier ones. A nil Main uses
|
// the caller underlays; [WithBundledOpenRouter] can disable it. Inputs within
|
||||||
|
// each underlay strictly compose; later underlays overlay earlier ones. A nil Main uses
|
||||||
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
|
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
|
||||||
// may fail construction if invalid.
|
// may fail construction if invalid.
|
||||||
type ConfigurationSources struct {
|
type ConfigurationSources struct {
|
||||||
@@ -154,6 +155,15 @@ type engineOptions struct {
|
|||||||
internal internalengine.Options
|
internal internalengine.Options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithBundledOpenRouter enables or disables the built-in OpenRouter underlay.
|
||||||
|
// It is enabled by default; when repeated, the last option wins.
|
||||||
|
func WithBundledOpenRouter(enabled bool) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
options.internal.SetBundledOpenRouter(enabled)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
// [Engine.RunPrepared].
|
// [Engine.RunPrepared].
|
||||||
//
|
//
|
||||||
@@ -203,7 +213,8 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
|
|||||||
//
|
//
|
||||||
// Prompt schema_path values refer to the file's base name. path must name an
|
// Prompt schema_path values refer to the file's base name. path must name an
|
||||||
// existing non-directory file when NewEngine applies the option. This option
|
// existing non-directory file when NewEngine applies the option. This option
|
||||||
// replaces Config.SchemaDir and earlier schema-source options.
|
// replaces Config.SchemaDir and earlier schema-source options. The selected
|
||||||
|
// schema must be self-contained; external resources are not loaded.
|
||||||
func WithSchemaFile(path string) Option {
|
func WithSchemaFile(path string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
return options.internal.SetSchemaFile(path)
|
return options.internal.SetSchemaFile(path)
|
||||||
@@ -213,7 +224,9 @@ func WithSchemaFile(path string) Option {
|
|||||||
// NewEngine constructs an Engine from configuration and options.
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
//
|
//
|
||||||
// ConfigKit composes caller-selected underlays in their given order, followed
|
// ConfigKit composes caller-selected underlays in their given order, followed
|
||||||
// by the optional main input. Construction validates the final effective
|
// by the optional main input, after the default-on bundled OpenRouter underlay.
|
||||||
|
// The bundle adds a root backend and profiles to inventories, but no prompts
|
||||||
|
// or implicit profile selection. Construction validates the final effective
|
||||||
// backend, profile, and prompt definitions and captures referenced prompt
|
// backend, profile, and prompt definitions and captures referenced prompt
|
||||||
// content. A missing prompt collection is allowed. Schema documents and
|
// content. A missing prompt collection is allowed. Schema documents and
|
||||||
// request artifacts remain runtime resources.
|
// request artifacts remain runtime resources.
|
||||||
@@ -251,7 +264,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
// InspectPrompt requires a nonblank promptID and performs an exact,
|
// InspectPrompt requires a nonblank promptID and performs an exact,
|
||||||
// case-sensitive lookup in the captured configuration snapshot. A
|
// case-sensitive lookup in the captured configuration snapshot. A
|
||||||
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
||||||
// qualified IDs pass through. Prompt version labels do not select definitions.
|
// qualified IDs pass through.
|
||||||
//
|
//
|
||||||
// A successful result proves that the selected definition and any referenced
|
// A successful result proves that the selected definition and any referenced
|
||||||
// message content files were structurally loaded. Inputs are returned in
|
// message content files were structurally loaded. Inputs are returned in
|
||||||
@@ -299,8 +312,8 @@ func (e *Engine) InspectPrompt(
|
|||||||
// override. BackendID is empty for an endpoint-only profile.
|
// override. BackendID is empty for an endpoint-only profile.
|
||||||
//
|
//
|
||||||
// APIKeyEnv in the returned target is an environment-variable name, never its
|
// APIKeyEnv in the returned target is an environment-variable name, never its
|
||||||
// value. APIKeyRequired instead reports a direct credential requirement and is
|
// value. APIKeyRequired reports that a credential is required; an explicit
|
||||||
// mutually exclusive with a nonblank APIKeyEnv. InspectProfile neither derives
|
// profile environment source can also be present. InspectProfile neither derives
|
||||||
// an ID from a prompt default_profile nor checks credential availability, so an
|
// an ID from a prompt default_profile nor checks credential availability, so an
|
||||||
// absent or blank named environment variable is not an error.
|
// absent or blank named environment variable is not an error.
|
||||||
//
|
//
|
||||||
|
|||||||
46
errors.go
46
errors.go
@@ -5,18 +5,52 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
"go.maximumdirect.net/promptkit/internal/generation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"go.maximumdirect.net/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// publicOperationError keeps classification separate from inspectable causes.
|
||||||
|
// Cache wording so formatting nested operations does not repeatedly rebuild it.
|
||||||
|
type publicOperationError struct {
|
||||||
|
owner error
|
||||||
|
cause error
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *publicOperationError) Error() string { return e.message }
|
||||||
|
func (e *publicOperationError) Unwrap() error { return e.cause }
|
||||||
|
func (e *publicOperationError) Is(target error) bool { return errors.Is(e.owner, target) }
|
||||||
|
|
||||||
func mapPublicError(err error) error {
|
func mapPublicError(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var operation *usecase.OperationError
|
||||||
|
if errors.As(err, &operation) && operation != nil {
|
||||||
|
owner := publicErrorFor(operation.Category)
|
||||||
|
if owner != nil {
|
||||||
|
// Project the underlying specialized error separately, retaining both
|
||||||
|
// the outer operation and the original cause for Is/As callers.
|
||||||
|
mapped := mapPublicError(operation.Cause)
|
||||||
|
if errors.Is(operation.Cause, llm.ErrTransport) {
|
||||||
|
mapped = errors.Join(ErrTransport, mapped)
|
||||||
|
}
|
||||||
|
if errors.Is(operation.Cause, llm.ErrMalformedResponse) {
|
||||||
|
mapped = errors.Join(ErrMalformedResponse, mapped)
|
||||||
|
}
|
||||||
|
// The projection already retains ordinary causes; adding the original
|
||||||
|
// again would duplicate its entire subtree at each enclosing operation.
|
||||||
|
return &publicOperationError{owner: owner, cause: mapped, message: fmt.Sprintf("%v: %v", owner, mapped)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapUnownedPublicError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapUnownedPublicError(err error) error {
|
||||||
var internalCapacityError *usecase.CapacityError
|
var internalCapacityError *usecase.CapacityError
|
||||||
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
||||||
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
||||||
@@ -80,3 +80,12 @@ func TestMapPublicErrorDoesNotTreatRequestEncodingAsTransport(t *testing.T) {
|
|||||||
t.Fatalf("request encoding category = %v", err)
|
t.Fatalf("request encoding category = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorRetainsOwnerWithSpecializedCapacity(t *testing.T) {
|
||||||
|
internal := &usecase.CapacityError{BackendID: "limited"}
|
||||||
|
err := mapPublicError(&usecase.OperationError{Category: usecase.ErrLLMGenerate, Cause: internal})
|
||||||
|
var capacity *CapacityError
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) || !errors.As(err, &capacity) || capacity.BackendID != "limited" || !errors.Is(err, ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("mapped owned capacity = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed prompts/*.yaml schemas/*.json
|
//go:embed prompts/*.yaml schemas/*.json
|
||||||
@@ -84,19 +84,21 @@ func run() (summary, error) {
|
|||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
||||||
|
backend := promptkit.LocalBackend("https://example.invalid/v1", 1)
|
||||||
|
backend.ID = backendID
|
||||||
inputs = append(inputs,
|
inputs = append(inputs,
|
||||||
configkit.Input{Source: promptkit.BackendSource("example backend", map[string]promptkit.BackendConfig{
|
configkit.Input{Source: promptkit.BackendSource("example backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("https://example.invalid/v1", 1),
|
backend,
|
||||||
})},
|
})},
|
||||||
configkit.Input{Source: promptkit.ProfileSource("example profiles", map[string]promptkit.ProfileConfig{
|
configkit.Input{Source: promptkit.ProfileSource("example profiles", []promptkit.ProfileConfig{
|
||||||
"proposal-profile": {Backend: &backendID, Model: &proposalModel},
|
{ID: "proposal-profile", Backend: &backendID, Model: &proposalModel},
|
||||||
"assessment-profile": {Backend: &backendID, Model: &assessmentModel},
|
{ID: "assessment-profile", Backend: &backendID, Model: &assessmentModel},
|
||||||
})},
|
})},
|
||||||
)
|
)
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
}, promptkit.WithSchemaFS(assets, "schemas"))
|
}, promptkit.WithBundledOpenRouter(false), promptkit.WithSchemaFS(assets, "schemas")) // Keep the example's configuration self-contained.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
@@ -156,13 +158,16 @@ func run() (summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
value, err := run()
|
if err := execute(os.Stdout); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execute(out io.Writer) error {
|
||||||
|
value, err := run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(out).Encode(value)
|
||||||
|
}
|
||||||
|
|||||||
30
examples/go-library/observe/main_test.go
Normal file
30
examples/go-library/observe/main_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestObserveExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := execute(&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
ProposalAttempts *int `json:"proposal_attempts"`
|
||||||
|
ProposalRetries *int `json:"proposal_transient_retries"`
|
||||||
|
UsageComplete *bool `json:"proposal_usage_complete"`
|
||||||
|
AssessmentAttempts *int `json:"assessment_attempts"`
|
||||||
|
AssessmentRetries *int `json:"assessment_transient_retries"`
|
||||||
|
Outcome string `json:"assessment_outcome"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
|
||||||
|
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
|
||||||
|
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
|
||||||
|
t.Fatalf("unexpected observation summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.assessment:
|
- id: example.assessment
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: assessment-profile
|
default_profile: assessment-profile
|
||||||
description: Assess a structured proposal independently of schema validity.
|
description: Assess a structured proposal independently of schema validity.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.proposal:
|
- id: example.proposal
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: proposal-profile
|
default_profile: proposal-profile
|
||||||
description: Propose a bounded improvement from a synthetic idea.
|
description: Propose a bounded improvement from a synthetic idea.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://review.example.invalid/v1
|
endpoint: https://review.example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
audit:
|
- id: audit
|
||||||
backend: review/offline
|
backend: review/offline
|
||||||
model: review-model
|
model: review-model
|
||||||
prompts:
|
prompts:
|
||||||
check:
|
- id: check
|
||||||
default_profile: review/audit
|
default_profile: review/audit
|
||||||
description: Inspect a review item without contacting a model provider.
|
description: Inspect a review item without contacting a model provider.
|
||||||
messages:
|
messages:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version: "1"
|
version: "2"
|
||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: fragments/resources.yaml
|
- path: fragments/resources.yaml
|
||||||
|
required: true
|
||||||
|
- path: fragments/prepare.yaml
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
required: true
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
id: prepare
|
||||||
|
default_profile: support/fast
|
||||||
|
description: Prepare a prompt without contacting a model provider.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content_file: ../messages/note.md
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
@@ -1,28 +1,11 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 60
|
timeout_seconds: 60
|
||||||
prompts:
|
|
||||||
prepare:
|
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: support/fast
|
|
||||||
description: Prepare a prompt without contacting a model provider.
|
|
||||||
inputs:
|
|
||||||
- name: note
|
|
||||||
required: true
|
|
||||||
content_type: text/plain
|
|
||||||
messages:
|
|
||||||
- role: system
|
|
||||||
content: Summarize the note in one sentence.
|
|
||||||
- role: user
|
|
||||||
content_file: ../messages/note.md
|
|
||||||
output:
|
|
||||||
format: text
|
|
||||||
validation_mode: basic
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# A later input intentionally replaces only the inherited profile timeout.
|
# A later input replaces the profile with an inherited variant.
|
||||||
profiles:
|
profiles:
|
||||||
fast:
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type summary struct {
|
type summary struct {
|
||||||
PromptID string `json:"prompt_id"`
|
PromptID string `json:"prompt_id"`
|
||||||
PromptVersion *string `json:"prompt_version,omitempty"`
|
|
||||||
SelectedProfile string `json:"selected_profile"`
|
SelectedProfile string `json:"selected_profile"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
@@ -29,62 +30,66 @@ type collectionSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/prepare", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
support, err := promptkit.CollectionInput(
|
support, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
review, err := promptkit.CollectionInput(
|
review, err := promptkit.CollectionInput(
|
||||||
"review",
|
"review",
|
||||||
configkit.File("examples/go-library/prepare/collections/review/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "review", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
override, err := promptkit.CollectionInput(
|
override, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/operator-overrides.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "operator-overrides.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{support, review}},
|
{Inputs: []configkit.Input{support, review}},
|
||||||
{Inputs: []configkit.Input{override}},
|
{Inputs: []configkit.Input{override}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false)) // Keep this example's inventory caller-owned.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
|
||||||
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
||||||
Prompt: "support", Profile: "support", Backend: "support",
|
Prompt: "support", Profile: "support", Backend: "support",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys(
|
if _, found := view.Explain(configkit.Keys("collections", "support", "profiles").Index(1).Key("timeout_seconds")); !found {
|
||||||
"collections", "support", "profiles", "fast", "timeout_seconds",
|
return fmt.Errorf("collection profile override was not found")
|
||||||
)); !found {
|
|
||||||
exit(fmt.Errorf("collection profile override was not found"))
|
|
||||||
}
|
}
|
||||||
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||||
PromptID: "prepare",
|
PromptID: "prepare",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
||||||
requirements.Inputs[0].Name != "note" ||
|
requirements.Inputs[0].Name != "note" ||
|
||||||
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
||||||
exit(fmt.Errorf("required note input was not discovered before preparation"))
|
return fmt.Errorf("required note input was not discovered before preparation")
|
||||||
}
|
}
|
||||||
|
|
||||||
request := promptkit.RunRequest{
|
request := promptkit.RunRequest{
|
||||||
@@ -95,39 +100,39 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
prepared, err := scoped.Prepare(ctx, request)
|
prepared, err := scoped.Prepare(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
profile, err := scoped.InspectProfile(ctx, "fast")
|
profile, err := scoped.InspectProfile(ctx, "fast")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
||||||
exit(fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"later profile override was not applied: timeout is %d seconds",
|
"later profile override was not applied: timeout is %d seconds",
|
||||||
profile.EffectiveModelParams.TimeoutSeconds,
|
profile.EffectiveModelParams.TimeoutSeconds,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
namespaces, err := scoped.ListNamespaces(ctx)
|
namespaces, err := scoped.ListNamespaces(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
collections, err := scoped.ListCollections(ctx)
|
collections, err := scoped.ListCollections(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPrompts, err := scoped.ListPrompts(ctx)
|
scopedPrompts, err := scoped.ListPrompts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
||||||
Mode: promptkit.NamespaceNamed, Name: "review",
|
Mode: promptkit.NamespaceNamed, Name: "review",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPromptIDs := make([]string, len(scopedPrompts))
|
scopedPromptIDs := make([]string, len(scopedPrompts))
|
||||||
for index := range scopedPrompts {
|
for index := range scopedPrompts {
|
||||||
@@ -144,11 +149,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: prepared.PromptVersion,
|
|
||||||
SelectedProfile: prepared.SelectedProfileID,
|
SelectedProfile: prepared.SelectedProfileID,
|
||||||
Model: prepared.EffectiveModelParams.Model,
|
Model: prepared.EffectiveModelParams.Model,
|
||||||
MessageCount: len(prepared.Messages),
|
MessageCount: len(prepared.Messages),
|
||||||
@@ -157,12 +161,5 @@ func main() {
|
|||||||
Collections: collectionSummaries,
|
Collections: collectionSummaries,
|
||||||
ScopedPrompts: scopedPromptIDs,
|
ScopedPrompts: scopedPromptIDs,
|
||||||
ReviewPrompts: reviewPromptIDs,
|
ReviewPrompts: reviewPromptIDs,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
examples/go-library/prepare/main_test.go
Normal file
45
examples/go-library/prepare/main_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
Profile string `json:"selected_profile"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
Timeout int `json:"profile_timeout_seconds"`
|
||||||
|
Namespaces []string `json:"namespaces"`
|
||||||
|
Collections []map[string]json.RawMessage `json:"collections"`
|
||||||
|
ScopedPrompts []string `json:"scoped_prompts"`
|
||||||
|
ReviewPrompts []string `json:"review_prompts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PromptID != "support/prepare" || got.Profile != "support/fast" || got.Model != "offline-model" ||
|
||||||
|
got.MessageCount <= 0 || got.Timeout != 30 || !reflect.DeepEqual(got.Namespaces, []string{"review", "support"}) ||
|
||||||
|
!reflect.DeepEqual(got.ScopedPrompts, []string{"support/prepare"}) || !reflect.DeepEqual(got.ReviewPrompts, []string{"review/check"}) || len(got.Collections) != 2 {
|
||||||
|
t.Fatalf("unexpected preparation summary: %+v", got)
|
||||||
|
}
|
||||||
|
for index, want := range []struct{ namespace, release string }{{"review", ""}, {"support", "1.4.0"}} {
|
||||||
|
var namespace string
|
||||||
|
if err := json.Unmarshal(got.Collections[index]["namespace"], &namespace); err != nil || namespace != want.namespace {
|
||||||
|
t.Fatalf("collection namespace = %q, %v", namespace, err)
|
||||||
|
}
|
||||||
|
value, present := got.Collections[index]["release_version"]
|
||||||
|
var release *string
|
||||||
|
if !present || json.Unmarshal(value, &release) != nil || (index == 0 && release != nil) || (index == 1 && (release == nil || *release != want.release)) {
|
||||||
|
t.Fatalf("collection release = %s, present %t; want %q", value, present, want.release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deterministicClient struct{}
|
type deterministicClient struct{}
|
||||||
@@ -39,41 +41,41 @@ type summary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/run", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", []promptkit.ProfileConfig{
|
||||||
"offline-example": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline-example", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.File("examples/go-library/run/prompt.yaml")},
|
Main: &configkit.Input{Source: configkit.File(filepath.Join(exampleDir, "prompt.yaml"))},
|
||||||
}}, promptkit.WithLLMClient(deterministicClient{}))
|
}}, promptkit.WithBundledOpenRouter(false), promptkit.WithLLMClient(deterministicClient{})) // Use only this example's offline configuration.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
PromptID: "example.run",
|
PromptID: "example.run",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"note": promptkit.Inline("Ada finished the migration review."),
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
Output: result.RawOutput,
|
Output: result.RawOutput,
|
||||||
ValidationStatus: result.Validation.Status,
|
ValidationStatus: result.Validation.Status,
|
||||||
IsValid: result.Validation.IsValid,
|
IsValid: result.Validation.IsValid,
|
||||||
Model: result.ModelName,
|
Model: result.ModelName,
|
||||||
TotalTokens: result.Usage.TotalTokens,
|
TotalTokens: result.Usage.TotalTokens,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
examples/go-library/run/main_test.go
Normal file
28
examples/go-library/run/main_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Valid *bool `json:"is_valid"`
|
||||||
|
Status string `json:"validation_status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Tokens *int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Output != "Ada finished the migration review." || got.Valid == nil || !*got.Valid ||
|
||||||
|
got.Status != "passed" || got.Model != "offline-model" || got.Tokens == nil || *got.Tokens != 18 {
|
||||||
|
t.Fatalf("unexpected execution summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.run:
|
- id: example.run
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: offline-example
|
default_profile: offline-example
|
||||||
description: Run a prompt with a deterministic injected model client.
|
description: Run a prompt with a deterministic injected model client.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const capturedPromptDocument = `
|
const capturedPromptDocument = `
|
||||||
prompts:
|
prompts:
|
||||||
captured:
|
- id: captured
|
||||||
version: "1"
|
|
||||||
default_profile: offline
|
default_profile: offline
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
messages:
|
messages:
|
||||||
@@ -40,8 +39,8 @@ func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: inputs},
|
{Inputs: inputs},
|
||||||
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -145,12 +144,11 @@ func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *tes
|
|||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
schemas := fstest.MapFS{}
|
schemas := fstest.MapFS{}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||||
"prompts": map[string]any{"structured": map[string]any{
|
"prompts": []any{map[string]any{"id": "structured",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuiltInGenerationError(t *testing.T) {
|
func TestBuiltInGenerationError(t *testing.T) {
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -1,18 +1,18 @@
|
|||||||
module gitea.maximumdirect.net/eric/promptkit
|
module go.maximumdirect.net/promptkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,13 +1,13 @@
|
|||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ package artifact
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var artifactFileReadBenchmarkSink *domain.Artifact
|
var artifactFileReadBenchmarkSink *domain.Artifact
|
||||||
@@ -153,9 +151,12 @@ func checkArtifactFileRead(b *testing.B, reader Reader, ref domain.ArtifactRef,
|
|||||||
if !bytes.Equal(artifact.Body, want) || artifact.Size != int64(len(want)) {
|
if !bytes.Equal(artifact.Body, want) || artifact.Size != int64(len(want)) {
|
||||||
b.Fatalf("artifact size=%d body length=%d, want %d", artifact.Size, len(artifact.Body), len(want))
|
b.Fatalf("artifact size=%d body length=%d, want %d", artifact.Size, len(artifact.Body), len(want))
|
||||||
}
|
}
|
||||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(want))
|
inline, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: string(want)})
|
||||||
if artifact.Hash != wantHash {
|
if err != nil {
|
||||||
b.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if inline.Hash == "" || artifact.Hash != inline.Hash {
|
||||||
|
b.Fatalf("file and inline hashes differ: %q/%q", artifact.Hash, inline.Hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ package artifact
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
||||||
@@ -284,9 +282,12 @@ func TestFileReaderUsesOpenedSizeOnlyAsACapacityHint(t *testing.T) {
|
|||||||
if artifact.Name != filepath.Base(filePath) || artifact.URI != filePath || artifact.ContentType == "" {
|
if artifact.Name != filepath.Base(filePath) || artifact.URI != filePath || artifact.ContentType == "" {
|
||||||
t.Fatalf("unexpected artifact metadata: name=%q URI=%q content type=%q", artifact.Name, artifact.URI, artifact.ContentType)
|
t.Fatalf("unexpected artifact metadata: name=%q URI=%q content type=%q", artifact.Name, artifact.URI, artifact.ContentType)
|
||||||
}
|
}
|
||||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(tc.body))
|
inline, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: string(tc.body)})
|
||||||
if artifact.Hash != wantHash {
|
if err != nil {
|
||||||
t.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inline.Hash == "" || artifact.Hash != inline.Hash {
|
||||||
|
t.Fatalf("file and inline hashes differ: %q/%q", artifact.Hash, inline.Hash)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,17 +27,13 @@ type Registry struct {
|
|||||||
// NewEffectiveRegistry stores already-validated effective backend definitions
|
// NewEffectiveRegistry stores already-validated effective backend definitions
|
||||||
// under their exact configuration keys. Configuration validation and
|
// under their exact configuration keys. Configuration validation and
|
||||||
// normalization precede this ownership boundary.
|
// normalization precede this ownership boundary.
|
||||||
func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) {
|
func NewEffectiveRegistry(definitions map[string]domain.Backend) *Registry {
|
||||||
registry := &Registry{backends: make(map[string]domain.Backend, len(definitions))}
|
registry := &Registry{backends: make(map[string]domain.Backend, len(definitions))}
|
||||||
for id, definition := range definitions {
|
for id, definition := range definitions {
|
||||||
definition.ID = id
|
definition.ID = id
|
||||||
cloned, err := domain.CloneBackend(definition)
|
registry.backends[id] = domain.CloneBackend(definition)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("backend %q: %w", id, err)
|
|
||||||
}
|
|
||||||
registry.backends[id] = cloned
|
|
||||||
}
|
}
|
||||||
return registry, nil
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBackend returns a defensive copy of the backend registered with id.
|
// GetBackend returns a defensive copy of the backend registered with id.
|
||||||
@@ -49,11 +45,7 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||||
}
|
}
|
||||||
cloned, err := domain.CloneBackend(definition)
|
return domain.CloneBackend(definition), nil
|
||||||
if err != nil {
|
|
||||||
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
|
||||||
}
|
|
||||||
return cloned, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CapacityPolicies returns a copy of the normalized policies for limited
|
// CapacityPolicies returns a copy of the normalized policies for limited
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
||||||
@@ -22,16 +22,13 @@ func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
|||||||
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
|
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
registry, err := backend.NewEffectiveRegistry(map[string]domain.Backend{
|
registry := backend.NewEffectiveRegistry(map[string]domain.Backend{
|
||||||
" local.v1 ": {
|
" local.v1 ": {
|
||||||
Endpoint: "https://backend.example/v1", ExtraParams: params,
|
Endpoint: "https://backend.example/v1", ExtraParams: params,
|
||||||
ConcurrencyLimit: 2, QueueCapacity: 3, QueueCapacitySet: true,
|
ConcurrencyLimit: 2, QueueCapacity: 3,
|
||||||
SmokeTests: smokeTests,
|
SmokeTests: smokeTests,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
params["nested"].(map[string]any)["enabled"] = false
|
params["nested"].(map[string]any)["enabled"] = false
|
||||||
defaultProfile = "mutated-profile"
|
defaultProfile = "mutated-profile"
|
||||||
reachabilityEndpoint = "https://mutated.example/ready"
|
reachabilityEndpoint = "https://mutated.example/ready"
|
||||||
@@ -79,10 +76,7 @@ func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectiveRegistryMissingBackend(t *testing.T) {
|
func TestEffectiveRegistryMissingBackend(t *testing.T) {
|
||||||
registry, err := backend.NewEffectiveRegistry(nil)
|
registry := backend.NewEffectiveRegistry(nil)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := registry.GetBackend("missing"); !errors.Is(err, backend.ErrBackendNotFound) {
|
if _, err := registry.GetBackend("missing"); !errors.Is(err, backend.ErrBackendNotFound) {
|
||||||
t.Fatalf("missing backend error = %v", err)
|
t.Fatalf("missing backend error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package capacity
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
manager *Manager
|
|
||||||
next llm.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient wraps next with configured active-generation limits. A nil manager
|
|
||||||
// leaves next unchanged.
|
|
||||||
func NewClient(manager *Manager, next llm.Client) llm.Client {
|
|
||||||
if manager == nil {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return &client{
|
|
||||||
manager: manager,
|
|
||||||
next: next,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) Generate(
|
|
||||||
ctx context.Context,
|
|
||||||
req domain.GenerateRequest,
|
|
||||||
) (*domain.GenerateResponse, error) {
|
|
||||||
if c.manager.getPool(req.Target.BackendID) == nil {
|
|
||||||
return c.next.Generate(ctx, req)
|
|
||||||
}
|
|
||||||
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
return c.next.Generate(ctx, req)
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,14 @@ package capacity
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type generateResult struct {
|
type generateResult struct {
|
||||||
@@ -24,6 +23,18 @@ type clientFunc func(
|
|||||||
domain.GenerateRequest,
|
domain.GenerateRequest,
|
||||||
) (*domain.GenerateResponse, error)
|
) (*domain.GenerateResponse, error)
|
||||||
|
|
||||||
|
// newTestClient exercises one acquisition around a controlled call.
|
||||||
|
func newTestClient(manager *Manager, next llm.Client) llm.Client {
|
||||||
|
return clientFunc(func(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
release, err := manager.Acquire(ctx, req.Target.BackendID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
return next.Generate(ctx, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (f clientFunc) Generate(
|
func (f clientFunc) Generate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req domain.GenerateRequest,
|
req domain.GenerateRequest,
|
||||||
@@ -167,7 +178,7 @@ func TestClientLimitsPeakConcurrencyAndServesWaitersFIFO(t *testing.T) {
|
|||||||
"second": secondRelease,
|
"second": secondRelease,
|
||||||
"third": thirdRelease,
|
"third": thirdRelease,
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
first := generateAsync(client, context.Background(), "limited", "first")
|
first := generateAsync(client, context.Background(), "limited", "first")
|
||||||
if got := receiveStarted(t, next.started); got != "first" {
|
if got := receiveStarted(t, next.started); got != "first" {
|
||||||
@@ -212,7 +223,7 @@ func TestClientPeakConcurrencyDoesNotExceedConfiguredLimit(t *testing.T) {
|
|||||||
releases[string(rune('a'+i))] = gate
|
releases[string(rune('a'+i))] = gate
|
||||||
}
|
}
|
||||||
next := newBlockingClient(releases)
|
next := newBlockingClient(releases)
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
results := make([]<-chan generateResult, 0, len(releases))
|
results := make([]<-chan generateResult, 0, len(releases))
|
||||||
for id := range releases {
|
for id := range releases {
|
||||||
@@ -257,7 +268,7 @@ func TestClientRemovesCanceledWaiters(t *testing.T) {
|
|||||||
"three": make(chan struct{}),
|
"three": make(chan struct{}),
|
||||||
}
|
}
|
||||||
next := newBlockingClient(releases)
|
next := newBlockingClient(releases)
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
holder := generateAsync(client, context.Background(), "limited", "holder")
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
if got := receiveStarted(t, next.started); got != "holder" {
|
if got := receiveStarted(t, next.started); got != "holder" {
|
||||||
@@ -321,7 +332,7 @@ func TestClientGrantCancellationRaceDoesNotLeakPermit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return &domain.GenerateResponse{Content: req.Prompt.SessionID}, nil
|
return &domain.GenerateResponse{Content: req.Prompt.SessionID}, nil
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
holder := generateAsync(client, context.Background(), "limited", "holder")
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
waitForActiveCount(t, manager, "limited", 1)
|
waitForActiveCount(t, manager, "limited", 1)
|
||||||
@@ -394,7 +405,7 @@ func TestClientUsesIndependentPoolsAndUnlimitedFastPaths(t *testing.T) {
|
|||||||
"alpha": alphaRelease,
|
"alpha": alphaRelease,
|
||||||
"beta": betaRelease,
|
"beta": betaRelease,
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
alpha := generateAsync(client, context.Background(), "alpha", "alpha")
|
alpha := generateAsync(client, context.Background(), "alpha", "alpha")
|
||||||
beta := generateAsync(client, context.Background(), "beta", "beta")
|
beta := generateAsync(client, context.Background(), "beta", "beta")
|
||||||
@@ -423,63 +434,6 @@ func TestClientUsesIndependentPoolsAndUnlimitedFastPaths(t *testing.T) {
|
|||||||
t.Fatalf("unlimited backend %q response=(%#v, %v)", backendID, response, err)
|
t.Fatalf("unlimited backend %q response=(%#v, %v)", backendID, response, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := NewClient(nil, next); got != next {
|
|
||||||
t.Fatal("nil manager did not return the wrapped client unchanged")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientPreservesRequestsResponsesAndErrors(t *testing.T) {
|
|
||||||
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
|
||||||
"limited": {ConcurrencyLimit: 1},
|
|
||||||
})
|
|
||||||
request := domain.GenerateRequest{
|
|
||||||
Prompt: domain.RenderedPrompt{
|
|
||||||
SessionID: "session",
|
|
||||||
Messages: []domain.RenderedMessage{
|
|
||||||
{Role: "user", Content: "content"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Target: domain.ExecutionTarget{
|
|
||||||
BackendID: "limited",
|
|
||||||
Model: "model",
|
|
||||||
ExtraParams: map[string]any{"key": "value"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response := &domain.GenerateResponse{
|
|
||||||
Content: "output",
|
|
||||||
Usage: domain.TokenUsage{TotalTokens: 7},
|
|
||||||
}
|
|
||||||
collaboratorErr := errors.New("collaborator failure")
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response *domain.GenerateResponse
|
|
||||||
err error
|
|
||||||
}{
|
|
||||||
{name: "successful response", response: response},
|
|
||||||
{name: "nil response"},
|
|
||||||
{name: "collaborator error", response: response, err: collaboratorErr},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var captured domain.GenerateRequest
|
|
||||||
next := clientFunc(func(
|
|
||||||
_ context.Context,
|
|
||||||
req domain.GenerateRequest,
|
|
||||||
) (*domain.GenerateResponse, error) {
|
|
||||||
captured = req
|
|
||||||
return tc.response, tc.err
|
|
||||||
})
|
|
||||||
gotResponse, gotErr := NewClient(manager, next).Generate(context.Background(), request)
|
|
||||||
if !reflect.DeepEqual(captured, request) {
|
|
||||||
t.Fatalf("request changed: %#v", captured)
|
|
||||||
}
|
|
||||||
if gotResponse != tc.response || gotErr != tc.err {
|
|
||||||
t.Fatalf("response=(%p, %v), want (%p, %v)",
|
|
||||||
gotResponse, gotErr, tc.response, tc.err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
||||||
@@ -496,7 +450,7 @@ func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return &domain.GenerateResponse{Content: "recovered"}, nil
|
return &domain.GenerateResponse{Content: "recovered"}, nil
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
request := domain.GenerateRequest{
|
request := domain.GenerateRequest{
|
||||||
Target: domain.ExecutionTarget{BackendID: "limited"},
|
Target: domain.ExecutionTarget{BackendID: "limited"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const benchmarkDefinitionCount = 128
|
const benchmarkDefinitionCount = 128
|
||||||
@@ -31,13 +31,26 @@ type configurationShape struct {
|
|||||||
|
|
||||||
func benchmarkPrompt(profileID string) map[string]any {
|
func benchmarkPrompt(profileID string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "1",
|
|
||||||
"default_profile": profileID,
|
"default_profile": profileID,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func benchmarkRecords(values map[string]any) []any {
|
||||||
|
records := make([]any, 0, len(values))
|
||||||
|
for _, id := range sortedKeys(values) {
|
||||||
|
value := values[id].(map[string]any)
|
||||||
|
record := make(map[string]any, len(value)+1)
|
||||||
|
for key, field := range value {
|
||||||
|
record[key] = field
|
||||||
|
}
|
||||||
|
record["id"] = id
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
func independentDefinitions(count int) configurationShape {
|
func independentDefinitions(count int) configurationShape {
|
||||||
profiles := make(map[string]any, count)
|
profiles := make(map[string]any, count)
|
||||||
prompts := make(map[string]any, count)
|
prompts := make(map[string]any, count)
|
||||||
@@ -48,7 +61,7 @@ func independentDefinitions(count int) configurationShape {
|
|||||||
prompts[promptID] = benchmarkPrompt(profileID)
|
prompts[promptID] = benchmarkPrompt(profileID)
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": profiles, "prompts": prompts})},
|
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)})},
|
||||||
inputEntries: count * 2,
|
inputEntries: count * 2,
|
||||||
effectiveEntries: count * 2,
|
effectiveEntries: count * 2,
|
||||||
tiers: 1,
|
tiers: 1,
|
||||||
@@ -71,7 +84,7 @@ func fileBackedDefinitions(count int) configurationShape {
|
|||||||
})
|
})
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{
|
main: configkit.Input{
|
||||||
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": profiles, "prompts": prompts}),
|
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)}),
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
||||||
},
|
},
|
||||||
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
||||||
@@ -85,12 +98,12 @@ func sharedBaseDefinitions(count int) configurationShape {
|
|||||||
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
||||||
}
|
}
|
||||||
for index := range count {
|
for index := range count {
|
||||||
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": "base", "temperature": float64(index%10) / 10}
|
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": map[string]any{"id": "base"}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count + 2,
|
inputEntries: count + 2,
|
||||||
effectiveEntries: count + 2,
|
effectiveEntries: count + 2,
|
||||||
@@ -106,19 +119,19 @@ func layeredDefinitions(count int) configurationShape {
|
|||||||
for index := range count {
|
for index := range count {
|
||||||
id := fmt.Sprintf("profile-%03d", index)
|
id := fmt.Sprintf("profile-%03d", index)
|
||||||
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
||||||
operator[id] = map[string]any{"model": "operator-model"}
|
operator[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model"}
|
||||||
programmatic[id] = map[string]any{"extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
programmatic[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
||||||
final[id] = map[string]any{"temperature": float64(index%10) / 10}
|
final[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
underlays: []configkit.Underlay{
|
underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": base})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": benchmarkRecords(base)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": operator})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": benchmarkRecords(operator)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": programmatic})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": benchmarkRecords(programmatic)})}}},
|
||||||
},
|
},
|
||||||
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||||
"profiles": final,
|
"profiles": benchmarkRecords(final),
|
||||||
"prompts": map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count*4 + 1,
|
inputEntries: count*4 + 1,
|
||||||
effectiveEntries: count + 1,
|
effectiveEntries: count + 1,
|
||||||
@@ -132,8 +145,8 @@ func BenchmarkCollectionFileBackedConstruction(b *testing.B) {
|
|||||||
collections := make(map[string]any, count)
|
collections := make(map[string]any, count)
|
||||||
for index := range count {
|
for index := range count {
|
||||||
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"example": map[string]any{
|
map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
},
|
},
|
||||||
@@ -240,8 +253,8 @@ func inspectionDefinitions(extraParams map[string]any) configurationShape {
|
|||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": prompts,
|
"prompts": benchmarkRecords(prompts),
|
||||||
})},
|
})},
|
||||||
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
||||||
}
|
}
|
||||||
@@ -262,7 +275,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
||||||
if err != nil || definition.ID != "inspection" || definition.Version == nil || *definition.Version != "1" || hash == "" ||
|
if err != nil || definition.ID != "inspection" || hash == "" ||
|
||||||
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
||||||
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +285,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
||||||
}
|
}
|
||||||
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
||||||
if err != nil || inspection.PromptID != "inspection" || inspection.PromptVersion == nil || *inspection.PromptVersion != "1" ||
|
if err != nil || inspection.PromptID != "inspection" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
||||||
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
||||||
}
|
}
|
||||||
|
|||||||
16
internal/configuration/bundled.go
Normal file
16
internal/configuration/bundled.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bundled/openrouter.yaml
|
||||||
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
|
// BundledOpenRouterUnderlay returns the built-in OpenRouter configuration source.
|
||||||
|
func BundledOpenRouterUnderlay() configkit.Underlay {
|
||||||
|
filesystem := configkit.NewFilesystem("Promptkit bundled OpenRouter", bundledFiles)
|
||||||
|
return configkit.Underlay{Inputs: []configkit.Input{{Source: filesystem.File("bundled/openrouter.yaml")}}}
|
||||||
|
}
|
||||||
60
internal/configuration/bundled/openrouter.yaml
Normal file
60
internal/configuration/bundled/openrouter.yaml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
backends:
|
||||||
|
- id: openrouter
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
smoke_tests:
|
||||||
|
default_profile: gpt-luna-latest
|
||||||
|
authentication:
|
||||||
|
kind: openrouter_key
|
||||||
|
profiles:
|
||||||
|
- id: gpt-astra-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-astra-latest
|
||||||
|
- id: gpt-sol-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-sol-latest
|
||||||
|
- id: gpt-terra-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-terra-latest
|
||||||
|
- id: gpt-luna-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-luna-latest
|
||||||
|
- id: claude-fable-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-fable-latest
|
||||||
|
- id: claude-opus-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-opus-latest
|
||||||
|
- id: claude-sonnet-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-sonnet-latest
|
||||||
|
- id: claude-haiku-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-haiku-latest
|
||||||
|
- id: gemini-pro-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~google/gemini-pro-latest
|
||||||
|
- id: gemini-flash-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~google/gemini-flash-latest
|
||||||
|
- id: auto
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/auto
|
||||||
|
- id: free
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/free
|
||||||
|
- id: auto-low
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: low}]}
|
||||||
|
- id: auto-medium
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: medium}]}
|
||||||
|
- id: auto-high
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: high}]}
|
||||||
|
- id: auto-xhigh
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: xhigh}]}
|
||||||
|
- id: auto-max
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: max}]}
|
||||||
54
internal/configuration/bundled_test.go
Normal file
54
internal/configuration/bundled_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBundledOpenRouterUnderlay(t *testing.T) {
|
||||||
|
state, err := Load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{BundledOpenRouterUnderlay()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := state.GetBackend("openrouter")
|
||||||
|
if err != nil || backend.Endpoint != "https://openrouter.ai/api/v1" || backend.APIKeyEnv != "OPENROUTER_API_KEY" || backend.SmokeTests == nil || backend.SmokeTests.DefaultProfile == nil || *backend.SmokeTests.DefaultProfile != "gpt-luna-latest" || backend.SmokeTests.Authentication == nil || backend.SmokeTests.Authentication.Kind == nil || *backend.SmokeTests.Authentication.Kind != "openrouter_key" {
|
||||||
|
t.Fatalf("backend = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(state.backendIDs, []string{"openrouter"}) || len(state.promptIDs) != 0 || len(state.collections) != 0 {
|
||||||
|
t.Fatalf("bundle inventories: backends=%v prompts=%v collections=%v", state.backendIDs, state.promptIDs, state.collections)
|
||||||
|
}
|
||||||
|
models := map[string]string{
|
||||||
|
"gpt-astra-latest": "~openai/gpt-astra-latest", "gpt-sol-latest": "~openai/gpt-sol-latest", "gpt-terra-latest": "~openai/gpt-terra-latest", "gpt-luna-latest": "~openai/gpt-luna-latest",
|
||||||
|
"claude-fable-latest": "~anthropic/claude-fable-latest", "claude-opus-latest": "~anthropic/claude-opus-latest", "claude-sonnet-latest": "~anthropic/claude-sonnet-latest", "claude-haiku-latest": "~anthropic/claude-haiku-latest",
|
||||||
|
"gemini-pro-latest": "~google/gemini-pro-latest", "gemini-flash-latest": "~google/gemini-flash-latest", "auto": "openrouter/auto", "free": "openrouter/free",
|
||||||
|
}
|
||||||
|
for _, tier := range []string{"low", "medium", "high", "xhigh", "max"} {
|
||||||
|
models["auto-"+tier] = "openrouter/auto"
|
||||||
|
}
|
||||||
|
if len(state.profileIDs) != len(models) {
|
||||||
|
t.Fatalf("profiles = %v", state.profileIDs)
|
||||||
|
}
|
||||||
|
for id, model := range models {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("profile %s = %+v, %v", id, profile, err)
|
||||||
|
}
|
||||||
|
want := defaults.ExecutionTargetDefault()
|
||||||
|
want.Model, want.BackendID, want.Endpoint, want.APIKeyEnv = model, "openrouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"
|
||||||
|
if tier, ok := strings.CutPrefix(id, "auto-"); ok {
|
||||||
|
want.ExtraParams = map[string]any{"plugins": []any{map[string]any{"id": "auto-router", "cost_tier": tier}}}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(*profile.ResolvedTarget, want) {
|
||||||
|
t.Fatalf("profile %s target = %+v, want %+v", id, *profile.ResolvedTarget, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(state.result.Sources()), "Promptkit bundled OpenRouter") {
|
||||||
|
t.Fatalf("source evidence = %+v", state.result.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inspection is the read-only subset of the retained ConfigKit result used by
|
// Inspection is the read-only subset of the retained ConfigKit result used by
|
||||||
@@ -66,11 +67,16 @@ func secretProviderPath(path configkit.Path) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func providerExtraParamsPath(parts []string) bool {
|
func providerExtraParamsPath(parts []string) bool {
|
||||||
if len(parts) >= 4 && parts[1] != "" &&
|
if len(parts) >= 4 && sequenceIndex(parts[1]) &&
|
||||||
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
||||||
(parts[2] == "backends" || parts[2] == "profiles") &&
|
(parts[2] == "backends" || parts[2] == "profiles") &&
|
||||||
parts[3] != "" && parts[4] == "extra_params"
|
sequenceIndex(parts[3]) && parts[4] == "extra_params"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceIndex(segment string) bool {
|
||||||
|
index, err := strconv.Atoi(segment)
|
||||||
|
return err == nil && index >= 0 && strconv.Itoa(index) == segment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package configuration
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||||
@@ -12,15 +12,15 @@ func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
|||||||
path configkit.Path
|
path configkit.Path
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true},
|
{name: "backend nested secret", path: configkit.Keys("backends").Index(0).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true},
|
{name: "profile nested secret", path: configkit.Keys("profiles").Index(1).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true},
|
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends").Index(2).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), want: true},
|
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles").Index(0).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
||||||
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
||||||
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
||||||
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
||||||
{name: "ordinary provider value", path: configkit.Keys("profiles", "assistant", "extra_params", "provider", "mode"), want: false},
|
{name: "ordinary provider value", path: configkit.Keys("profiles").Index(0).Key("extra_params").Key("provider").Key("mode"), want: false},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
152
internal/configuration/interpretation_test.go
Normal file
152
internal/configuration/interpretation_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTargetResolutionNormalizesNestedValues(t *testing.T) {
|
||||||
|
state, err := load(mapping("padded target", map[string]any{
|
||||||
|
"backends": []any{map[string]any{
|
||||||
|
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 1,
|
||||||
|
"smoke_tests": map[string]any{
|
||||||
|
"reachability": map[string]any{"endpoint": " https://example.invalid/health "},
|
||||||
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": " https://example.invalid/key "},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local", "service_tier": " standard ", "api_key_env": " TOKEN "}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := state.GetBackend("local")
|
||||||
|
if err != nil || backend.Endpoint != "https://example.invalid/v1" || backend.QueueCapacity != 1024 ||
|
||||||
|
backend.SmokeTests == nil || *backend.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
|
||||||
|
*backend.SmokeTests.Authentication.Kind != "none" || *backend.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" {
|
||||||
|
t.Fatalf("interpreted backend = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "partial")
|
||||||
|
if err != nil || profile.ResolvedTarget.ServiceTier != "standard" || profile.ResolvedTarget.APIKeyEnv != "TOKEN" {
|
||||||
|
t.Fatalf("interpreted profile = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Backends[0]
|
||||||
|
if *composed.Endpoint != "https://example.invalid/v1" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 ||
|
||||||
|
*composed.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
|
||||||
|
*composed.SmokeTests.Authentication.Kind != "none" || *composed.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" ||
|
||||||
|
*state.result.Config.Profiles[0].ServiceTier != "standard" || *state.result.Config.Profiles[0].APIKeyEnv != "TOKEN" {
|
||||||
|
t.Fatalf("resolved values were not normalized: %+v", state.result.Config)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint")
|
||||||
|
field, found := state.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Fatalf("nested field origin changed: %+v", field)
|
||||||
|
}
|
||||||
|
for _, path := range []configkit.Path{
|
||||||
|
configkit.Keys("backends").Index(0).Key("endpoint"),
|
||||||
|
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("reachability").Key("endpoint"),
|
||||||
|
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("kind"),
|
||||||
|
configkit.Keys("profiles").Index(0).Key("service_tier"),
|
||||||
|
configkit.Keys("profiles").Index(0).Key("api_key_env"),
|
||||||
|
} {
|
||||||
|
field, found := state.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Errorf("normalization at %v: %+v", path, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptResolutionNormalizesOutputAndRejectsDuplicateNames(t *testing.T) {
|
||||||
|
definition := map[string]any{
|
||||||
|
"id": "example", "inputs": []any{map[string]any{"name": " document "}},
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": " schema.json "},
|
||||||
|
}
|
||||||
|
state, err := load(mapping("prompt metadata", map[string]any{"prompts": []any{definition}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, inspection, err := state.prompt("example")
|
||||||
|
if err != nil || selected.Validation.SchemaPath != "schema.json" || inspection.OutputContract.SchemaPath != "schema.json" || selected.Inputs[0].Name != "document" {
|
||||||
|
t.Fatalf("interpreted prompt = %+v, %+v, %v", selected, inspection, err)
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Prompts[0]
|
||||||
|
if *composed.Output.SchemaPath != "schema.json" || composed.Inputs[0].Name != "document" {
|
||||||
|
t.Fatalf("prompt was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
definition["inputs"] = []any{map[string]any{"name": " document "}, map[string]any{"name": "document"}}
|
||||||
|
_, err = load(mapping("duplicate inputs", map[string]any{"prompts": []any{definition}}), nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/inputs/1") || !strings.Contains(err.Error(), "duplicated") {
|
||||||
|
t.Fatalf("duplicate normalized names were not rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptResolutionRecordsMessageFields(t *testing.T) {
|
||||||
|
loadPrompt := func(role, cacheType, ttl string) *loaded {
|
||||||
|
t.Helper()
|
||||||
|
state, err := load(mapping("prompt", map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "example", "inputs": []any{map[string]any{"name": " input ", "content_type": nil, "description": " text "}},
|
||||||
|
"messages": []any{map[string]any{"role": role, "content": "", "cache_control": map[string]any{"type": cacheType, "ttl": ttl}}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
|
}}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
padded := loadPrompt(" USER ", " ephemeral ", " 1h ")
|
||||||
|
canonical := loadPrompt("user", "ephemeral", "1h")
|
||||||
|
definition, inspection, err := padded.prompt("example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other, otherInspection, err := canonical.prompt("example")
|
||||||
|
if err != nil || inspection.PromptHash != otherInspection.PromptHash || definition.Templates[0].Role != other.Templates[0].Role || definition.Templates[0].Content != "" {
|
||||||
|
t.Fatalf("equivalent prompt mismatch: %+v %+v %v", definition, other, err)
|
||||||
|
}
|
||||||
|
base := configkit.Keys("prompts").Index(0)
|
||||||
|
for _, path := range []configkit.Path{base.Key("inputs").Index(0).Key("name"), base.Key("inputs").Index(0).Key("description"), base.Key("messages").Index(0).Key("role"), base.Key("messages").Index(0).Key("cache_control").Key("type"), base.Key("messages").Index(0).Key("cache_control").Key("ttl")} {
|
||||||
|
field, found := padded.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Errorf("normalization at %v: %+v", path, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if padded.promptMetadata["example"][0].contentType != fieldNull {
|
||||||
|
t.Fatal("null input metadata was not retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionRecordsTargetDependencies(t *testing.T) {
|
||||||
|
backend := func(id string) map[string]any {
|
||||||
|
return map[string]any{"id": id, "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 2, "queue_capacity": nil}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("targets", map[string]any{
|
||||||
|
"backends": []any{backend("root")},
|
||||||
|
"collections": map[string]any{"scoped": map[string]any{"backends": []any{backend("local")}}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, base := range []configkit.Path{configkit.Keys("backends").Index(0), configkit.Keys("collections", "scoped", "backends").Index(0)} {
|
||||||
|
endpoint, ok := state.result.Explain(base.Key("endpoint"))
|
||||||
|
if !ok || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") {
|
||||||
|
t.Fatalf("endpoint explanation: %+v", endpoint)
|
||||||
|
}
|
||||||
|
history := state.result.Metadata().History(base.Key("queue_capacity"))
|
||||||
|
found := false
|
||||||
|
for _, change := range history {
|
||||||
|
if change.Origin.Operation == configkit.ChangeDerivedDefault {
|
||||||
|
for _, dependency := range change.Dependencies {
|
||||||
|
if dependency.String() == base.Key("concurrency_limit").String() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing queue dependency: %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,36 +2,66 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IdentityFilter selects all identities, one namespace, or unqualified IDs.
|
||||||
|
type IdentityFilter struct {
|
||||||
|
All bool
|
||||||
|
Namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectIdentities[T any](ctx context.Context, entries []T, filter IdentityFilter, id func(T) string) ([]T, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if filter.All {
|
||||||
|
result := append([]T(nil), entries...)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if filter.Namespace != "" {
|
||||||
|
prefix := filter.Namespace + "/"
|
||||||
|
start := sort.Search(len(entries), func(i int) bool { return id(entries[i]) >= prefix })
|
||||||
|
end := start + sort.Search(len(entries)-start, func(i int) bool { return !strings.HasPrefix(id(entries[start+i]), prefix) })
|
||||||
|
result := append([]T(nil), entries[start:end]...)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
var result []T
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !strings.Contains(id(entry), "/") {
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CollectionIdentity is the captured metadata for one final collection entry.
|
// CollectionIdentity is the captured metadata for one final collection entry.
|
||||||
type CollectionIdentity struct {
|
type CollectionIdentity struct {
|
||||||
Namespace string
|
Namespace string
|
||||||
ReleaseVersion *string
|
ReleaseVersion *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListProfiles(ctx context.Context) ([]string, error) {
|
func (state *loaded) ListProfiles(ctx context.Context, filter IdentityFilter) ([]string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.profileIDs, filter, func(id string) string { return id })
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
profiles := append([]string(nil), state.profileIDs...)
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return profiles, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListBackends(ctx context.Context) ([]string, error) {
|
func (state *loaded) ListBackends(ctx context.Context, filter IdentityFilter) ([]string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.backendIDs, filter, func(id string) string { return id })
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
backends := append([]string(nil), state.backendIDs...)
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return backends, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
||||||
@@ -72,16 +102,6 @@ func (state *loaded) ListCollections(ctx context.Context) ([]CollectionIdentity,
|
|||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) {
|
func (state *loaded) ListPrompts(ctx context.Context, filter IdentityFilter) ([]domain.PromptIdentity, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.promptIDs, filter, func(entry domain.PromptIdentity) string { return entry.ID })
|
||||||
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
|
|
||||||
}
|
|
||||||
return prompts, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,24 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}},
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1"},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{
|
"collections": map[string]any{
|
||||||
"z": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "z-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"z": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "z-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
"partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local"}}},
|
"partial": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local"}}},
|
||||||
"a": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "a-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"a": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "a-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
" z": map[string]any{"profiles": map[string]any{"profile ": map[string]any{"backend": "local", "model": "spaced-model"}}, "prompts": map[string]any{"prompt ": listingPromptDefinition()}},
|
" z": map[string]any{"profiles": []any{map[string]any{"id": "profile ", "backend": "local", "model": "spaced-model"}}, "prompts": []any{listingPromptDefinition("prompt ")}},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -34,7 +32,7 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
t.Fatalf("backend IDs = %#v, want %#v", backends, wantBackends)
|
t.Fatalf("backend IDs = %#v, want %#v", backends, wantBackends)
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -43,14 +41,14 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles)
|
t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{
|
wantPrompts := []domain.PromptIdentity{
|
||||||
{ID: " z/prompt ", Version: stringPointer("v1")},
|
{ID: " z/prompt "},
|
||||||
{ID: "a/prompt", Version: stringPointer("v1")},
|
{ID: "a/prompt"},
|
||||||
{ID: "z/prompt", Version: stringPointer("v1")},
|
{ID: "z/prompt"},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
||||||
@@ -59,68 +57,113 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsAreIndependent(t *testing.T) {
|
func TestLoadedListsAreIndependent(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{"backend": map[string]any{"endpoint": "https://example.test/v1"}},
|
"backends": []any{map[string]any{"id": "backend", "endpoint": "https://example.test/v1"}},
|
||||||
"profiles": map[string]any{"profile": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "profile"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profiles = append(profiles, "caller profile")
|
profiles = append(profiles, "caller profile")
|
||||||
profiles[0] = "changed profile"
|
profiles[0] = "changed profile"
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backends[0] = "changed backend"
|
backends[0] = "changed backend"
|
||||||
|
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prompts[0].ID = "changed prompt"
|
prompts[0].ID = "changed prompt"
|
||||||
*prompts[0].Version = "changed version"
|
|
||||||
|
|
||||||
profiles, err = state.ListProfiles(context.Background())
|
profiles, err = state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(profiles, []string{"profile"}) {
|
if !reflect.DeepEqual(profiles, []string{"profile"}) {
|
||||||
t.Fatalf("caller mutation changed profile inventory: %#v", profiles)
|
t.Fatalf("caller mutation changed profile inventory: %#v", profiles)
|
||||||
}
|
}
|
||||||
backends, err = state.ListBackends(context.Background())
|
backends, err = state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || !reflect.DeepEqual(backends, []string{"backend"}) {
|
if err != nil || !reflect.DeepEqual(backends, []string{"backend"}) {
|
||||||
t.Fatalf("caller mutation changed backend inventory: %#v, %v", backends, err)
|
t.Fatalf("caller mutation changed backend inventory: %#v, %v", backends, err)
|
||||||
}
|
}
|
||||||
prompts, err = state.ListPrompts(context.Background())
|
prompts, err = state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{{ID: "example", Version: stringPointer("v1")}}
|
wantPrompts := []domain.PromptIdentity{{ID: "example"}}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadedListsSelectNamespaces(t *testing.T) {
|
||||||
|
ids := []string{"a", "a-b/one", "a/one", "a/two", "b", "z/one"}
|
||||||
|
state := &loaded{profileIDs: append([]string(nil), ids...), backendIDs: append([]string(nil), ids...)}
|
||||||
|
for _, id := range ids {
|
||||||
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filter IdentityFilter
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"all", IdentityFilter{All: true}, ids},
|
||||||
|
{"unqualified", IdentityFilter{}, []string{"a", "b"}},
|
||||||
|
{"namespace", IdentityFilter{Namespace: "a"}, []string{"a/one", "a/two"}},
|
||||||
|
{"prefix sharing", IdentityFilter{Namespace: "a-b"}, []string{"a-b/one"}},
|
||||||
|
{"missing", IdentityFilter{Namespace: "absent"}, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
profiles, err := state.ListProfiles(context.Background(), tc.filter)
|
||||||
|
if err != nil || !reflect.DeepEqual(profiles, tc.want) {
|
||||||
|
t.Fatalf("profiles = %v, %v; want %v", profiles, err, tc.want)
|
||||||
|
}
|
||||||
|
backends, err := state.ListBackends(context.Background(), tc.filter)
|
||||||
|
if err != nil || !reflect.DeepEqual(backends, tc.want) {
|
||||||
|
t.Fatalf("backends = %v, %v; want %v", backends, err, tc.want)
|
||||||
|
}
|
||||||
|
prompts, err := state.ListPrompts(context.Background(), tc.filter)
|
||||||
|
if err != nil || len(prompts) != len(tc.want) {
|
||||||
|
t.Fatalf("prompts = %v, %v; want %v", prompts, err, tc.want)
|
||||||
|
}
|
||||||
|
for i := range prompts {
|
||||||
|
if prompts[i].ID != tc.want[i] {
|
||||||
|
t.Fatalf("prompt ID = %q; want %q", prompts[i].ID, tc.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(profiles) > 0 {
|
||||||
|
profiles[0], backends[0], prompts[0].ID = "changed", "changed", "changed"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(state.profileIDs, ids) || !reflect.DeepEqual(state.backendIDs, ids) || state.promptIDs[0].ID != ids[0] {
|
||||||
|
t.Fatal("caller mutation changed captured identities")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadedListsEmptyInventory(t *testing.T) {
|
func TestLoadedListsEmptyInventory(t *testing.T) {
|
||||||
state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil)
|
state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(profiles) != 0 {
|
if err != nil || len(profiles) != 0 {
|
||||||
t.Fatalf("empty profile listing = %#v, %v", profiles, err)
|
t.Fatalf("empty profile listing = %#v, %v", profiles, err)
|
||||||
}
|
}
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(backends) != 0 {
|
if err != nil || len(backends) != 0 {
|
||||||
t.Fatalf("empty backend listing = %#v, %v", backends, err)
|
t.Fatalf("empty backend listing = %#v, %v", backends, err)
|
||||||
}
|
}
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(prompts) != 0 {
|
if err != nil || len(prompts) != 0 {
|
||||||
t.Fatalf("empty prompt listing = %#v, %v", prompts, err)
|
t.Fatalf("empty prompt listing = %#v, %v", prompts, err)
|
||||||
}
|
}
|
||||||
@@ -128,8 +171,8 @@ func TestLoadedListsEmptyInventory(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -137,23 +180,25 @@ func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(ctx)
|
for _, filter := range []IdentityFilter{{All: true}, {}, {Namespace: "example"}} {
|
||||||
if !errors.Is(err, context.Canceled) || len(profiles) != 0 {
|
profiles, err := state.ListProfiles(ctx, filter)
|
||||||
t.Fatalf("canceled profile listing = %#v, %v", profiles, err)
|
if !errors.Is(err, context.Canceled) || len(profiles) != 0 {
|
||||||
}
|
t.Fatalf("canceled profile listing = %#v, %v", profiles, err)
|
||||||
backends, err := state.ListBackends(ctx)
|
}
|
||||||
if !errors.Is(err, context.Canceled) || len(backends) != 0 {
|
backends, err := state.ListBackends(ctx, filter)
|
||||||
t.Fatalf("canceled backend listing = %#v, %v", backends, err)
|
if !errors.Is(err, context.Canceled) || len(backends) != 0 {
|
||||||
}
|
t.Fatalf("canceled backend listing = %#v, %v", backends, err)
|
||||||
prompts, err := state.ListPrompts(ctx)
|
}
|
||||||
if !errors.Is(err, context.Canceled) || len(prompts) != 0 {
|
prompts, err := state.ListPrompts(ctx, filter)
|
||||||
t.Fatalf("canceled prompt listing = %#v, %v", prompts, err)
|
if !errors.Is(err, context.Canceled) || len(prompts) != 0 {
|
||||||
|
t.Fatalf("canceled prompt listing = %#v, %v", prompts, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listingPromptDefinition() map[string]any {
|
func listingPromptDefinition(id string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "v1",
|
"id": id,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type document struct {
|
type document struct {
|
||||||
Version *string `yaml:"version"`
|
Version *string `yaml:"version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
Collections map[string]collectionConfig `yaml:"collections"`
|
Collections map[string]collectionConfig `yaml:"collections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type collectionConfig struct {
|
type collectionConfig struct {
|
||||||
ReleaseVersion *string `yaml:"release_version"`
|
ReleaseVersion *string `yaml:"release_version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type backendConfig struct {
|
type backendConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
APIKeyEnv *string `yaml:"api_key_env"`
|
APIKeyEnv *string `yaml:"api_key_env"`
|
||||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||||
@@ -56,6 +56,7 @@ type authenticationProbeConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type profileConfig struct {
|
type profileConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Backend *string `yaml:"backend"`
|
Backend *string `yaml:"backend"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
Model *string `yaml:"model"`
|
Model *string `yaml:"model"`
|
||||||
@@ -71,7 +72,7 @@ type profileConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type promptConfig struct {
|
type promptConfig struct {
|
||||||
Version *string `yaml:"version"`
|
ID string `yaml:"id"`
|
||||||
DefaultProfile *string `yaml:"default_profile"`
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
Description *string `yaml:"description"`
|
Description *string `yaml:"description"`
|
||||||
SessionID *string `yaml:"session_id"`
|
SessionID *string `yaml:"session_id"`
|
||||||
@@ -118,7 +119,6 @@ const (
|
|||||||
|
|
||||||
type loaded struct {
|
type loaded struct {
|
||||||
result *configkit.Result[document]
|
result *configkit.Result[document]
|
||||||
backends map[string]map[string]fieldState
|
|
||||||
profiles map[string]map[string]fieldState
|
profiles map[string]map[string]fieldState
|
||||||
promptMetadata map[string][]promptInputPresence
|
promptMetadata map[string][]promptInputPresence
|
||||||
prompts map[string]*promptEntry
|
prompts map[string]*promptEntry
|
||||||
@@ -141,9 +141,9 @@ type resourceSet struct {
|
|||||||
isCollection bool
|
isCollection bool
|
||||||
namespace string
|
namespace string
|
||||||
bases resourceBases
|
bases resourceBases
|
||||||
backends map[string]backendConfig
|
backends []backendConfig
|
||||||
profiles map[string]profileConfig
|
profiles []profileConfig
|
||||||
prompts map[string]promptConfig
|
prompts []promptConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootResourceBases() resourceBases {
|
func rootResourceBases() resourceBases {
|
||||||
@@ -192,8 +192,8 @@ func indexedResourceID(namespace, localKey string) string {
|
|||||||
func indexedBackends(config *document) map[string]backendConfig {
|
func indexedBackends(config *document) map[string]backendConfig {
|
||||||
backends := make(map[string]backendConfig)
|
backends := make(map[string]backendConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.backends {
|
for _, definition := range resources.backends {
|
||||||
backends[indexedResourceID(resources.namespace, localKey)] = definition
|
backends[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return backends
|
return backends
|
||||||
@@ -202,8 +202,8 @@ func indexedBackends(config *document) map[string]backendConfig {
|
|||||||
func indexedProfiles(config *document) map[string]profileConfig {
|
func indexedProfiles(config *document) map[string]profileConfig {
|
||||||
profiles := make(map[string]profileConfig)
|
profiles := make(map[string]profileConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.profiles {
|
for _, definition := range resources.profiles {
|
||||||
profiles[indexedResourceID(resources.namespace, localKey)] = definition
|
profiles[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return profiles
|
return profiles
|
||||||
@@ -211,7 +211,6 @@ func indexedProfiles(config *document) map[string]profileConfig {
|
|||||||
|
|
||||||
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"}
|
|
||||||
var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"}
|
var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"}
|
||||||
|
|
||||||
// Load captures one immutable effective configuration for internal consumers.
|
// Load captures one immutable effective configuration for internal consumers.
|
||||||
@@ -219,37 +218,49 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
return load(main, underlays)
|
return load(main, underlays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configurationDefinition(state *loaded) configkit.Definition[document] {
|
||||||
|
return configkit.Definition[document]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("backends"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("profiles"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("backends")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("profiles")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts")}, "id"),
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: checkConfigurationVersion,
|
||||||
|
},
|
||||||
|
Sensitive: secretProviderPath,
|
||||||
|
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
for _, resources := range resourceSets(config) {
|
||||||
|
captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
|
||||||
|
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
|
||||||
|
}
|
||||||
|
for _, resources := range resourceSets(config) {
|
||||||
|
resolveBackends(resources.backends, resources.bases.backends, resolution)
|
||||||
|
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
|
||||||
|
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(config *document, validation *configkit.Validation) {
|
||||||
|
validateEffective(config, validation)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// load performs one ConfigKit lifecycle over the selected source tiers.
|
// load performs one ConfigKit lifecycle over the selected source tiers.
|
||||||
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||||
state := &loaded{
|
state := &loaded{
|
||||||
backends: make(map[string]map[string]fieldState),
|
|
||||||
profiles: make(map[string]map[string]fieldState),
|
profiles: make(map[string]map[string]fieldState),
|
||||||
promptMetadata: make(map[string][]promptInputPresence),
|
promptMetadata: make(map[string][]promptInputPresence),
|
||||||
}
|
}
|
||||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
||||||
Underlays: underlays,
|
Underlays: underlays,
|
||||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
Definition: configkit.Definition[document]{
|
Definition: configurationDefinition(state),
|
||||||
Version: &configkit.VersionRule{
|
|
||||||
Path: configkit.Keys("version"),
|
|
||||||
Check: checkConfigurationVersion,
|
|
||||||
},
|
|
||||||
Secret: secretProviderPath,
|
|
||||||
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
|
||||||
for _, resources := range resourceSets(config) {
|
|
||||||
captureFields(resources.backends, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends)
|
|
||||||
captureFields(resources.profiles, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
|
|
||||||
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
|
|
||||||
resolveBackends(resources.backends, resources.bases.backends, resolution)
|
|
||||||
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
|
|
||||||
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Validate: func(config *document, validation *configkit.Validation) {
|
|
||||||
validateEffective(config, validation)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
@@ -261,10 +272,10 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
if err := state.buildExecutionProfiles(); err != nil {
|
if err := state.buildExecutionProfiles(); err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
state.backendIDs = sortedKeys(indexedBackends(&result.Config))
|
state.backendIDs = sortedKeys(indexedBackends(&state.result.Config))
|
||||||
state.profileIDs = sortedKeys(state.indexedProfiles)
|
state.profileIDs = sortedKeys(state.indexedProfiles)
|
||||||
for _, id := range sortedKeys(state.prompts) {
|
for _, id := range sortedKeys(state.prompts) {
|
||||||
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: stringPointerValue(state.prompts[id].definition.Version)})
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
|
||||||
}
|
}
|
||||||
state.namespaces = map[string][]string{
|
state.namespaces = map[string][]string{
|
||||||
"backend": resourceNamespaces(state.backendIDs),
|
"backend": resourceNamespaces(state.backendIDs),
|
||||||
@@ -302,7 +313,7 @@ func checkConfigurationVersion(version configkit.Version, validation *configkit.
|
|||||||
if !version.Present {
|
if !version.Present {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if version.Null || version.Text != "1" {
|
if version.Null || version.Text != "2" {
|
||||||
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -313,11 +324,11 @@ type promptInputPresence struct {
|
|||||||
description fieldState
|
description fieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||||
for _, id := range sortedKeys(prompts) {
|
for promptIndex, prompt := range prompts {
|
||||||
inputs := prompts[id].Inputs
|
inputs := prompt.Inputs
|
||||||
states := make([]promptInputPresence, len(inputs))
|
states := make([]promptInputPresence, len(inputs))
|
||||||
base := basePath.Key(id).Key("inputs")
|
base := basePath.Index(promptIndex).Key("inputs")
|
||||||
for index := range inputs {
|
for index := range inputs {
|
||||||
path := base.Index(index)
|
path := base.Index(index)
|
||||||
states[index] = promptInputPresence{
|
states[index] = promptInputPresence{
|
||||||
@@ -325,12 +336,12 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P
|
|||||||
description: captureFieldState(path.Key("description"), resolution),
|
description: captureFieldState(path.Key("description"), resolution),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
destination[indexedResourceID(namespace, prompt.ID)] = states
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
||||||
info, exists := resolution.InputInfo(path)
|
info, exists := resolution.EntryInfo(path)
|
||||||
switch {
|
switch {
|
||||||
case !exists || !info.Explicit:
|
case !exists || !info.Explicit:
|
||||||
return fieldAbsent
|
return fieldAbsent
|
||||||
@@ -341,11 +352,11 @@ func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFields[T any](entries map[string]T, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
func captureFields[T any](entries []T, identity func(T) string, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
||||||
for id := range entries {
|
for index, entry := range entries {
|
||||||
states := make(map[string]fieldState, len(fields))
|
states := make(map[string]fieldState, len(fields))
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
info, exists := resolution.EntryInfo(basePath.Index(index).Key(field))
|
||||||
switch {
|
switch {
|
||||||
case !exists || !info.Explicit:
|
case !exists || !info.Explicit:
|
||||||
states[field] = fieldAbsent
|
states[field] = fieldAbsent
|
||||||
@@ -355,7 +366,30 @@ func captureFields[T any](entries map[string]T, basePath configkit.Path, namespa
|
|||||||
states[field] = fieldValue
|
states[field] = fieldValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
destination[indexedResourceID(namespace, identity(entry))] = states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eligibleResourceList leaves invalid identities to the existing validation
|
||||||
|
// boundaries, without mutating a list that cannot be recorded by identity.
|
||||||
|
func eligibleResourceList[T any](entries []T, identity func(T) string) bool {
|
||||||
|
seen := make(map[string]struct{}, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
id := identity(entry)
|
||||||
|
if !domain.IsValidLocalResourceKey(id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordStringNormalization(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) {
|
||||||
|
if *value != nil && **value != normalized {
|
||||||
|
configkit.Normalize(resolution, path, value, &normalized, "Normalize configured value")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,99 +402,6 @@ func sortedKeys[T any](entries map[string]T) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
|
|
||||||
if value != nil && *value != nil && **value != normalized {
|
|
||||||
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(backends) {
|
|
||||||
backend := backends[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if backend.Endpoint != nil {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.APIKeyEnv != nil {
|
|
||||||
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
|
||||||
}
|
|
||||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
|
||||||
smokePath := base.Key("smoke_tests")
|
|
||||||
if smokeTests.Reachability != nil {
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
if smokeTests.Authentication != nil {
|
|
||||||
if smokeTests.Authentication.Kind != nil {
|
|
||||||
normalizeString(&smokeTests.Authentication.Kind,
|
|
||||||
smokePath.Key("authentication").Key("kind"), resolution,
|
|
||||||
strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind)))
|
|
||||||
}
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
|
||||||
smokePath.Key("authentication").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
|
|
||||||
capacity := 1024
|
|
||||||
if resolution.IsExplicit(base.Key("queue_capacity")) {
|
|
||||||
configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
} else {
|
|
||||||
configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
backends[id] = backend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
if value == nil || *value == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalized := strings.TrimSpace(**value)
|
|
||||||
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
|
||||||
if normalized == "" || err != nil || request.URL == nil ||
|
|
||||||
(strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") ||
|
|
||||||
request.URL.Hostname() == "" {
|
|
||||||
resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalizeString(value, path, resolution, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(profiles) {
|
|
||||||
profile := profiles[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"service_tier", &profile.ServiceTier},
|
|
||||||
{"reasoning_effort", &profile.ReasoningEffort},
|
|
||||||
{"api_key_env", &profile.APIKeyEnv},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profiles[id] = profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateEffective(config *document, validation *configkit.Validation) {
|
func validateEffective(config *document, validation *configkit.Validation) {
|
||||||
sets := resourceSets(config)
|
sets := resourceSets(config)
|
||||||
backendIDs := make(map[string]struct{})
|
backendIDs := make(map[string]struct{})
|
||||||
@@ -468,107 +409,119 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
|||||||
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
||||||
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
||||||
}
|
}
|
||||||
for localKey := range resources.backends {
|
for _, backend := range resources.backends {
|
||||||
backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{}
|
backendIDs[indexedResourceID(resources.namespace, backend.ID)] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, resources := range sets {
|
for _, resources := range sets {
|
||||||
for _, id := range sortedKeys(resources.backends) {
|
for index, backend := range resources.backends {
|
||||||
backend := resources.backends[id]
|
validateBackendRecord(backend, resources.bases.backends.Index(index), validation)
|
||||||
base := resources.bases.backends.Key(id)
|
}
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
for index, profile := range resources.profiles {
|
||||||
validation.Error(base, "backend key must be a nonblank local key without a slash")
|
validateProfileRecord(profile, resources.bases.profiles.Index(index), backendIDs, validation)
|
||||||
}
|
}
|
||||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
for index, prompt := range resources.prompts {
|
||||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
validatePromptRecord(prompt, resources.bases.prompts.Index(index), validation)
|
||||||
}
|
}
|
||||||
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
}
|
||||||
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
}
|
||||||
}
|
|
||||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
func validateBackendRecord(backend backendConfig, base configkit.Path, validation *configkit.Validation) {
|
||||||
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
validateBackendEndpoints(backend, base, validation)
|
||||||
}
|
if !domain.IsValidLocalResourceKey(backend.ID) {
|
||||||
if backend.QueueCapacity != nil {
|
validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash")
|
||||||
if *backend.QueueCapacity < 0 {
|
}
|
||||||
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||||
}
|
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||||
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
}
|
||||||
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
||||||
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
||||||
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
}
|
||||||
}
|
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
||||||
}
|
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
||||||
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
}
|
||||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
if backend.QueueCapacity != nil {
|
||||||
smokePath := base.Key("smoke_tests")
|
if *backend.QueueCapacity < 0 {
|
||||||
if smokeTests.DefaultProfile != nil {
|
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
||||||
if strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
}
|
||||||
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
||||||
} else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) {
|
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
||||||
validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed")
|
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
||||||
}
|
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
||||||
}
|
}
|
||||||
if authentication := smokeTests.Authentication; authentication != nil {
|
}
|
||||||
kindPath := smokePath.Key("authentication").Key("kind")
|
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
||||||
if authentication.Kind == nil {
|
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
||||||
validation.Error(kindPath, "authentication kind is required")
|
smokePath := base.Key("smoke_tests")
|
||||||
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
if smokeTests.DefaultProfile != nil {
|
||||||
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
if strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
||||||
}
|
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
||||||
}
|
} else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) {
|
||||||
|
validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, id := range sortedKeys(resources.profiles) {
|
if authentication := smokeTests.Authentication; authentication != nil {
|
||||||
profile := resources.profiles[id]
|
kindPath := smokePath.Key("authentication").Key("kind")
|
||||||
base := resources.bases.profiles.Key(id)
|
if authentication.Kind == nil {
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
validation.Error(kindPath, "authentication kind is required")
|
||||||
validation.Error(base, "profile key must be a nonblank local key without a slash")
|
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
||||||
}
|
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
||||||
if profile.Backend != nil && *profile.Backend != "" {
|
|
||||||
if !domain.IsValidResourceID(*profile.Backend) {
|
|
||||||
validation.Error(base.Key("backend"), "backend reference is malformed")
|
|
||||||
} else if _, exists := backendIDs[*profile.Backend]; !exists {
|
|
||||||
validation.Error(base.Key("backend"), "backend is not defined")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
|
||||||
validation.Error(base.Key("model"), "model must not be blank")
|
|
||||||
}
|
|
||||||
if profile.Temperature != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
|
||||||
validation.Error(base.Key("temperature"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.TopP != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
|
||||||
validation.Error(base.Key("top_p"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.MaxTokens != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
|
||||||
validation.Error(base.Key("max_tokens"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.TimeoutSeconds != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
|
||||||
validation.Error(base.Key("timeout_seconds"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
|
||||||
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProfileRecord(profile profileConfig, base configkit.Path, backendIDs map[string]struct{}, validation *configkit.Validation) {
|
||||||
|
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
||||||
|
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint); err != nil {
|
||||||
|
validation.Error(base.Key("endpoint"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !domain.IsValidLocalResourceKey(profile.ID) {
|
||||||
|
validation.Error(base.Key("id"), "profile ID must be a nonblank local key without a slash")
|
||||||
|
}
|
||||||
|
if profile.Backend != nil && *profile.Backend != "" {
|
||||||
|
if !domain.IsValidResourceID(*profile.Backend) {
|
||||||
|
validation.Error(base.Key("backend"), "backend reference is malformed")
|
||||||
|
} else if _, exists := backendIDs[*profile.Backend]; !exists {
|
||||||
|
validation.Error(base.Key("backend"), "backend is not defined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
||||||
|
validation.Error(base.Key("model"), "model must not be blank")
|
||||||
|
}
|
||||||
|
if profile.Temperature != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
||||||
|
validation.Error(base.Key("temperature"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.TopP != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
||||||
|
validation.Error(base.Key("top_p"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.MaxTokens != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
||||||
|
validation.Error(base.Key("max_tokens"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.TimeoutSeconds != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
||||||
|
validation.Error(base.Key("timeout_seconds"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePromptRecord(prompt promptConfig, base configkit.Path, validation *configkit.Validation) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) {
|
func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,17 +1,342 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapping(label string, values map[string]any) configkit.Input {
|
func mapping(label string, values map[string]any) configkit.Input {
|
||||||
return configkit.Input{Source: configkit.Mapping(label, values)}
|
return configkit.Input{Source: configkit.Mapping(label, values)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadIdentifiedResourceLists(t *testing.T) {
|
||||||
|
for _, header := range []bool{false, true} {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
"collections": map[string]any{"team": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if header {
|
||||||
|
values["version"] = "2"
|
||||||
|
}
|
||||||
|
loaded, err := load(mapping("identified resources", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load identified resources with header %t: %v", header, err)
|
||||||
|
}
|
||||||
|
if loaded.result.Config.Backends[0].ID != "local" || loaded.result.Config.Collections["team"].Backends[0].ID != "local" {
|
||||||
|
t.Fatal("identified root and collection resources were not retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesComposeAcrossNamespacesAndKinds(t *testing.T) {
|
||||||
|
resources := func() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"backends": []any{
|
||||||
|
map[string]any{"id": "shared", "endpoint": "https://example.invalid/v1"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "model"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"prompts": []any{
|
||||||
|
map[string]any{"id": "shared", "messages": []any{map[string]any{"role": "user", "content": "inherited"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := resources()
|
||||||
|
values["collections"] = map[string]any{"*": resources(), "123": resources()}
|
||||||
|
state, err := load(mapping("identified namespaces", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"", "*/", "123/"} {
|
||||||
|
backend, err := state.registry.GetBackend(prefix + "child")
|
||||||
|
if err != nil || backend.ID != prefix+"child" || backend.Endpoint != "https://example.invalid/v1" {
|
||||||
|
t.Fatalf("inherited backend %q = %+v, %v", prefix, backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfileIdentity(context.Background(), prefix+"child")
|
||||||
|
if err != nil || profile != prefix+"child" {
|
||||||
|
t.Fatalf("inherited profile %q = %q, %v", prefix, profile, err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt(prefix + "child")
|
||||||
|
if err != nil || definition.ID != prefix+"child" || len(definition.Templates) != 1 || definition.Templates[0].Content != "inherited" {
|
||||||
|
t.Fatalf("inherited prompt %q = %+v, %v", prefix, definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidInheritance(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for name, records := range map[string][]any{
|
||||||
|
"missing": {map[string]any{"id": "child", "extends": map[string]any{"id": "missing"}}},
|
||||||
|
"self": {map[string]any{"id": "child", "extends": map[string]any{"id": "child"}}},
|
||||||
|
"cycle": {
|
||||||
|
map[string]any{"id": "first", "extends": map[string]any{"id": "second"}},
|
||||||
|
map[string]any{"id": "second", "extends": map[string]any{"id": "first"}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid inheritance", map[string]any{kind: records}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream inheritance diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeInheritanceMissing || diagnostic.Code == configkit.CodeInheritanceCycle {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing inheritance diagnostic: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidLocalIDs(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
record map[string]any
|
||||||
|
}{
|
||||||
|
{name: "missing", record: map[string]any{}},
|
||||||
|
{name: "null", record: map[string]any{"id": nil}},
|
||||||
|
{name: "non-string", record: map[string]any{"id": 42}},
|
||||||
|
{name: "blank", record: map[string]any{"id": " "}},
|
||||||
|
{name: "slash", record: map[string]any{"id": "not/local"}},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid resource ID", map[string]any{kind: []any{test.record}}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want a path-aware error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectDuplicateStrictPeers(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, namespace := range []string{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
values := func() map[string]any {
|
||||||
|
records := []any{map[string]any{"id": "duplicate"}}
|
||||||
|
if namespace == "" {
|
||||||
|
return map[string]any{kind: records}
|
||||||
|
}
|
||||||
|
return map[string]any{"collections": map[string]any{namespace: map[string]any{kind: records}}}
|
||||||
|
}
|
||||||
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
mapping("first", values()), mapping("second", values()),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream duplicate diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing duplicate conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, id := range []string{" ", "bad/id"} {
|
||||||
|
t.Run(kind+"/"+id, func(t *testing.T) {
|
||||||
|
record := map[string]any{"id": id}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
record["endpoint"] = " HTTPS://example.invalid/v1 "
|
||||||
|
record["concurrency_limit"] = 1
|
||||||
|
case "profiles":
|
||||||
|
record["service_tier"] = " standard "
|
||||||
|
case "prompts":
|
||||||
|
record["description"] = " padded "
|
||||||
|
}
|
||||||
|
_, err := load(mapping("invalid", map[string]any{kind: []any{record}}), nil)
|
||||||
|
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
|
||||||
|
t.Fatalf("invalid identity error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
record := map[string]any{"id": "same"}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
record["endpoint"] = " HTTPS://example.invalid/v1 "
|
||||||
|
record["concurrency_limit"] = 1
|
||||||
|
case "profiles":
|
||||||
|
record["service_tier"] = " standard "
|
||||||
|
case "prompts":
|
||||||
|
record["description"] = " padded "
|
||||||
|
}
|
||||||
|
_, err := load(mapping("duplicates", map[string]any{kind: []any{record, record}}), nil)
|
||||||
|
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
|
||||||
|
t.Fatalf("duplicate identity error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalIdentitiesAreCheckedAfterApplicationResolution(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, namespace := range []string{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
definition := configurationDefinition(&loaded{})
|
||||||
|
definition.Resolve = func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
path := configkit.Keys(kind)
|
||||||
|
if namespace == "" {
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
config.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
config.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
config.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collection := collectionConfig{}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
collection.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
collection.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
collection.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
config.Collections = map[string]collectionConfig{namespace: collection}
|
||||||
|
path = configkit.Keys("collections", namespace, kind)
|
||||||
|
}
|
||||||
|
resolution.RecordNormalization(path, "supply resolved records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.NoSource()}
|
||||||
|
if namespace != "" {
|
||||||
|
input = mapping("existing collection", map[string]any{"collections": map[string]any{
|
||||||
|
namespace: map[string]any{kind: []any{}},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(input, configkit.LayeredOptions[document]{Definition: definition})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want a final identity diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeSequenceConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing final identity conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayerOperations(t *testing.T) {
|
||||||
|
base := mapping("base profiles", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "first", "model": "original", "service_tier": "standard"},
|
||||||
|
map[string]any{"id": "second", "model": "retained"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
records []any
|
||||||
|
models map[string]string
|
||||||
|
}{
|
||||||
|
{name: "whole record upsert", records: []any{map[string]any{"id": "first", "model": "updated"}}, models: map[string]string{"first": "updated", "second": "retained"}},
|
||||||
|
{name: "empty upsert", records: []any{}, models: map[string]string{"first": "original", "second": "retained"}},
|
||||||
|
{name: "append", mode: "append", records: []any{map[string]any{"id": "third", "model": "added"}}, models: map[string]string{"first": "original", "second": "retained", "third": "added"}},
|
||||||
|
{name: "replace", mode: "replace", records: []any{map[string]any{"id": "third", "model": "replacement"}}, models: map[string]string{"third": "replacement"}},
|
||||||
|
{name: "clear", mode: "replace", records: []any{}, models: map[string]string{}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
values := map[string]any{"profiles": test.records}
|
||||||
|
if test.mode != "" {
|
||||||
|
values["config"] = map[string]any{"sequences": []any{map[string]any{
|
||||||
|
"destination": []any{"profiles"}, "mode": test.mode,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("overlay profiles", values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles := indexedProfiles(&state.result.Config)
|
||||||
|
if len(profiles) != len(test.models) {
|
||||||
|
t.Fatalf("profiles = %+v, want IDs and models %+v", profiles, test.models)
|
||||||
|
}
|
||||||
|
for id, model := range test.models {
|
||||||
|
profile, ok := profiles[id]
|
||||||
|
if !ok || profile.Model == nil || *profile.Model != model {
|
||||||
|
t.Fatalf("profile %q = %+v, want model %q", id, profile, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "whole record upsert" && profiles["first"].ServiceTier != nil {
|
||||||
|
t.Fatalf("upsert retained a displaced field: %+v", profiles["first"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayersRejectDisplacedParentAndDuplicateAppend(t *testing.T) {
|
||||||
|
base := mapping("base", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "original"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
values map[string]any
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "self extension cannot recover displaced record",
|
||||||
|
values: map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "extends": map[string]any{"id": "shared"}},
|
||||||
|
}},
|
||||||
|
code: configkit.CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "append cannot duplicate an identity",
|
||||||
|
values: map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": "append"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "shared", "model": "other"}},
|
||||||
|
},
|
||||||
|
code: configkit.CodeSequenceConflict,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("overlay", test.values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want structured diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == test.code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing %s diagnostic: %+v", test.code, loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -19,11 +344,11 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
include bool
|
include bool
|
||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{name: "string one", version: "1", include: true, valid: true},
|
{name: "string two", version: "2", include: true, valid: true},
|
||||||
{name: "integer one", version: int64(1), include: true, valid: true},
|
{name: "integer two", version: int64(2), include: true, valid: true},
|
||||||
{name: "omitted", valid: true},
|
{name: "omitted", valid: true},
|
||||||
{name: "explicit null", include: true},
|
{name: "explicit null", include: true},
|
||||||
{name: "unsupported string", version: "2", include: true},
|
{name: "unsupported string", version: "1", include: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
@@ -35,8 +360,8 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load accepted version: %v", err)
|
t.Fatalf("load accepted version: %v", err)
|
||||||
}
|
}
|
||||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") {
|
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2") {
|
||||||
t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version)
|
t.Fatalf("typed version = %v, want 2", loaded.result.Config.Version)
|
||||||
}
|
}
|
||||||
if !test.include && loaded.result.Config.Version != nil {
|
if !test.include && loaded.result.Config.Version != nil {
|
||||||
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
||||||
@@ -47,9 +372,6 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err == nil || !errors.As(err, &loadErr) {
|
if err == nil || !errors.As(err, &loadErr) {
|
||||||
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "2") {
|
|
||||||
t.Fatalf("load error disclosed the unsupported value: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
found := false
|
||||||
for _, diagnostic := range loadErr.Diagnostics() {
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
||||||
@@ -63,9 +385,44 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMalformedHeadersAndLegacyResourceMappings(t *testing.T) {
|
||||||
|
for name, values := range map[string]map[string]any{
|
||||||
|
"boolean header": {"version": true},
|
||||||
|
"fractional header": {"version": 2.5},
|
||||||
|
"sequence header": {"version": []any{2}},
|
||||||
|
"backend mapping": {"backends": map[string]any{"local": map[string]any{"endpoint": "https://example.invalid/v1"}}},
|
||||||
|
"profile mapping": {"profiles": map[string]any{"local": map[string]any{}}},
|
||||||
|
"prompt mapping": {"prompts": map[string]any{"local": map[string]any{}}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping(name, values), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want structured rejection", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedConfigRemainsApplicationData(t *testing.T) {
|
||||||
|
state, err := load(mapping("nested config", map[string]any{
|
||||||
|
"config": map[string]any{},
|
||||||
|
"profiles": []any{map[string]any{"id": "example", "extra_params": map[string]any{
|
||||||
|
"config": map[string]any{"imports": "provider data"},
|
||||||
|
}}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "example")
|
||||||
|
if err != nil || profile.ResolvedTarget.ExtraParams["config"].(map[string]any)["imports"] != "provider data" {
|
||||||
|
t.Fatalf("nested application data = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
||||||
underlay := mapping("declared underlay", map[string]any{
|
underlay := mapping("declared underlay", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +433,7 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
||||||
"prompts": map[string]any{"incomplete": map[string]any{"description": "still validated"}},
|
"prompts": []any{map[string]any{"id": "incomplete", "description": "still validated"}},
|
||||||
})
|
})
|
||||||
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
||||||
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
||||||
@@ -85,30 +442,25 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
|
|
||||||
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{"base": map[string]any{
|
"collections": map[string]any{"base": map[string]any{
|
||||||
"release_version": "",
|
"release_version": "",
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 3}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3},
|
"profiles": []any{
|
||||||
},
|
map[string]any{"id": "one", "backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||||
"profiles": map[string]any{
|
|
||||||
"one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||||
"child.v1": map[string]any{"extends": "one"},
|
map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"}},
|
||||||
"namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"},
|
map[string]any{"id": "namespaced", "backend": "base/local.v1", "model": "collection-model"},
|
||||||
},
|
},
|
||||||
}, "invoice": map[string]any{
|
}, "invoice": map[string]any{
|
||||||
"release_version": "r7",
|
"release_version": "r7",
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"summary": map[string]any{
|
map[string]any{"id": "summary",
|
||||||
"version": "v1.0",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
"version": map[string]any{
|
map[string]any{"id": "version",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
@@ -116,19 +468,23 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}},
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2, "queue_capacity": 0}},
|
||||||
"collections": map[string]any{"base": map[string]any{"profiles": map[string]any{"child.v1": map[string]any{
|
"collections": map[string]any{"base": map[string]any{"profiles": []any{map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"},
|
||||||
"temperature": 0, "top_p": nil, "api_key_required": false,
|
"temperature": 0, "top_p": nil, "api_key_required": false,
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
||||||
}}}},
|
}}}},
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("main", map[string]any{"version": "1"}), []configkit.Underlay{
|
loaded, err := load(mapping("main", map[string]any{"version": "2"}), []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load: %v; diagnostics: %+v", err, loadErr.Diagnostics())
|
||||||
|
}
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profile := loaded.result.Config.Collections["base"].Profiles["child.v1"]
|
profile := loaded.result.Config.Collections["base"].Profiles[1]
|
||||||
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("effective values were not retained: %+v", profile)
|
t.Fatalf("effective values were not retained: %+v", profile)
|
||||||
}
|
}
|
||||||
@@ -139,8 +495,8 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
||||||
t.Fatalf("recursive overlay failed: %+v", provider)
|
t.Fatalf("recursive overlay failed: %+v", provider)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local.v1"]
|
backend := loaded.result.Config.Backends[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue {
|
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 {
|
||||||
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
||||||
}
|
}
|
||||||
if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil {
|
if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil {
|
||||||
@@ -150,10 +506,10 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
||||||
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Collections["invoice"].Prompts["summary"].Messages[0].Content == nil || loaded.result.Config.Collections["invoice"].Prompts["summary"].Version == nil || *loaded.result.Config.Collections["invoice"].Prompts["summary"].Version != "v1.0" {
|
if loaded.result.Config.Collections["invoice"].Prompts[0].Messages[0].Content == nil {
|
||||||
t.Fatal("literal prompt ID and version were not decoded")
|
t.Fatal("literal prompt content was not decoded")
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1" ||
|
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2" ||
|
||||||
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
||||||
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
||||||
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
||||||
@@ -176,20 +532,20 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "root resource with separator",
|
name: "root resource with separator",
|
||||||
values: map[string]any{"backends": map[string]any{
|
values: map[string]any{"backends": []any{
|
||||||
"bad/name": map[string]any{"endpoint": "https://example.test/v1"},
|
map[string]any{"id": "bad/name", "endpoint": "https://example.test/v1"},
|
||||||
}},
|
}},
|
||||||
message: "backend key must be a nonblank local key without a slash",
|
message: "backend ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collection resource with separator",
|
name: "collection resource with separator",
|
||||||
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
||||||
"prompts": map[string]any{"bad/name": map[string]any{
|
"prompts": []any{map[string]any{"id": "bad/name",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}},
|
}},
|
||||||
}}},
|
}}},
|
||||||
message: "prompt key must be a nonblank local key without a slash",
|
message: "prompt ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -208,22 +564,35 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
map[string]any{"id": "local", "endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
||||||
"concurrency_limit": 2, "queue_capacity": nil},
|
"concurrency_limit": 2, "queue_capacity": nil},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
backend, err := loaded.GetBackend("local")
|
||||||
if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" ||
|
if err != nil || backend.Endpoint != "http://localhost:8080/v1" ||
|
||||||
backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" ||
|
backend.APIKeyEnv != "TOKEN_NAME" || backend.QueueCapacity != 1024 {
|
||||||
backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 {
|
|
||||||
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
||||||
}
|
}
|
||||||
if loaded.backends["local"]["queue_capacity"] != fieldNull {
|
composed := loaded.result.Config.Backends[0]
|
||||||
t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"])
|
if composed.Endpoint == nil || *composed.Endpoint != "http://localhost:8080/v1" ||
|
||||||
|
composed.APIKeyEnv == nil || *composed.APIKeyEnv != "TOKEN_NAME" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 {
|
||||||
|
t.Fatalf("resolved backend was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint"))
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Fatalf("endpoint origin was not preserved: %+v", field)
|
||||||
|
}
|
||||||
|
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("queue_capacity"))
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeDerivedDefault {
|
||||||
|
t.Fatalf("queue capacity default was not recorded: %+v", field)
|
||||||
|
}
|
||||||
|
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("concurrency_limit"))
|
||||||
|
if !found || field.Origin.Operation == configkit.ChangeNormalization || field.Origin.Operation == configkit.ChangeDerivedDefault {
|
||||||
|
t.Fatalf("unchanged concurrency acquired a resolution record: %+v", field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,17 +607,17 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
{name: "null", smokeTests: nil, include: true, inherited: true},
|
{name: "null", smokeTests: nil, include: true, inherited: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
backend := map[string]any{"endpoint": "https://backend.example/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://backend.example/v1"}
|
||||||
if test.include {
|
if test.include {
|
||||||
backend["smoke_tests"] = test.smokeTests
|
backend["smoke_tests"] = test.smokeTests
|
||||||
}
|
}
|
||||||
main := mapping("backend", map[string]any{
|
main := mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
})
|
})
|
||||||
var underlays []configkit.Underlay
|
var underlays []configkit.Underlay
|
||||||
if test.inherited {
|
if test.inherited {
|
||||||
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
||||||
}},
|
}},
|
||||||
@@ -258,7 +627,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := loaded.result.Config.Backends["local"].SmokeTests; got != nil {
|
if got := loaded.result.Config.Backends[0].SmokeTests; got != nil {
|
||||||
t.Fatalf("smoke_tests = %+v, want nil", got)
|
t.Fatalf("smoke_tests = %+v, want nil", got)
|
||||||
}
|
}
|
||||||
selected, err := loaded.registry.GetBackend("local")
|
selected, err := loaded.registry.GetBackend("local")
|
||||||
@@ -274,7 +643,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "catalog",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "catalog-profile",
|
"default_profile": "catalog-profile",
|
||||||
@@ -287,7 +656,7 @@ func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "extends": map[string]any{"id": "catalog"},
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "not-yet-listed",
|
"default_profile": "not-yet-listed",
|
||||||
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
||||||
@@ -331,37 +700,37 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "blank default profile",
|
name: "blank default profile",
|
||||||
data: map[string]any{"default_profile": " \t "},
|
data: map[string]any{"default_profile": " \t "},
|
||||||
path: "/backends/local/smoke_tests/default_profile",
|
path: "/backends/0/smoke_tests/default_profile",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "authentication kind required",
|
name: "authentication kind required",
|
||||||
data: map[string]any{"authentication": map[string]any{}},
|
data: map[string]any{"authentication": map[string]any{}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown authentication kind",
|
name: "unknown authentication kind",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-http reachability URL",
|
name: "non-http reachability URL",
|
||||||
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
||||||
path: "/backends/local/smoke_tests/reachability/endpoint",
|
path: "/backends/0/smoke_tests/reachability/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank authentication URL",
|
name: "blank authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "relative authentication URL",
|
name: "relative authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, err := load(mapping("backend", map[string]any{
|
_, err := load(mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": test.data,
|
"smoke_tests": test.data,
|
||||||
}},
|
}},
|
||||||
@@ -381,22 +750,42 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
underlay := mapping("defaults", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "api_key_env": "OLD_KEY"},
|
||||||
|
}})
|
||||||
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
||||||
|
}}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := loaded.GetBackend("local")
|
||||||
|
if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity == nil || *loaded.result.Config.Backends[0].QueueCapacity != 1024 {
|
||||||
|
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCapturesProfilePresence(t *testing.T) {
|
||||||
|
loaded, err := load(mapping("profile", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "local", "temperature": 0, "top_p": nil, "api_key_required": false},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
profile := loaded.result.Config.Profiles[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
t.Fatalf("profile values were not preserved: %+v", profile)
|
||||||
|
}
|
||||||
|
fields := loaded.profiles["local"]
|
||||||
|
if fields["temperature"] != fieldValue || fields["top_p"] != fieldNull || fields["api_key_required"] != fieldValue || fields["model"] != fieldAbsent {
|
||||||
|
t.Fatalf("profile presence was not preserved: %+v", fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
||||||
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("first", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("first", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
mapping("second", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("second", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
@@ -406,33 +795,33 @@ func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": "not a URL"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": "not a URL"}},
|
||||||
"profiles": map[string]any{"partial": map[string]any{"backend": "local"}},
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080/v1"}}})
|
main := mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}}})
|
||||||
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Profiles["partial"].Model != nil {
|
if loaded.result.Config.Profiles[0].Model != nil {
|
||||||
t.Fatal("unused partial profile unexpectedly gained a model")
|
t.Fatal("unused partial profile unexpectedly gained a model")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("invalid", map[string]any{"backends": map[string]any{"unused": map[string]any{"endpoint": "not a URL"}}}),
|
mapping("invalid", map[string]any{"backends": []any{map[string]any{"id": "unused", "endpoint": "not a URL"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
||||||
}
|
}
|
||||||
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/unused/endpoint" {
|
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/0/endpoint" {
|
||||||
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": " ",
|
"default_profile": " ",
|
||||||
@@ -442,7 +831,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{
|
main := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "dangling-profile",
|
"default_profile": "dangling-profile",
|
||||||
"reachability": map[string]any{"endpoint": nil},
|
"reachability": map[string]any{"endpoint": nil},
|
||||||
@@ -465,7 +854,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
||||||
_, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}),
|
_, err := load(mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080"}}}),
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -28,59 +28,6 @@ type promptEntry struct {
|
|||||||
messages []*template.Template
|
messages []*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(prompts) {
|
|
||||||
definition := prompts[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"description", &definition.Description},
|
|
||||||
{"session_id", &definition.SessionID},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inputsChanged := false
|
|
||||||
for index := range definition.Inputs {
|
|
||||||
input := &definition.Inputs[index]
|
|
||||||
inputPath := base.Key("inputs").Index(index)
|
|
||||||
normalizedName := strings.TrimSpace(input.Name)
|
|
||||||
if normalizedName != input.Name {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"content_type", &input.ContentType},
|
|
||||||
{"description", &input.Description},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalized := strings.TrimSpace(**field.value)
|
|
||||||
if normalized != **field.value {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inputsChanged {
|
|
||||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
|
||||||
}
|
|
||||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
|
||||||
// records sequence mutations as whole-value replacements, which would
|
|
||||||
// discard the effective source contexts of unchanged content_file fields.
|
|
||||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
|
||||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
|
||||||
}
|
|
||||||
prompts[id] = definition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *loaded) materializePrompts() error {
|
func (state *loaded) materializePrompts() error {
|
||||||
state.prompts = make(map[string]*promptEntry)
|
state.prompts = make(map[string]*promptEntry)
|
||||||
var metadata *configkit.Metadata
|
var metadata *configkit.Metadata
|
||||||
@@ -92,14 +39,13 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return metadata.Lookup(path)
|
return metadata.Lookup(path)
|
||||||
}
|
}
|
||||||
for _, resources := range resourceSets(&state.result.Config) {
|
for _, resources := range resourceSets(&state.result.Config) {
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
for index, raw := range resources.prompts {
|
||||||
promptBase := resources.bases.prompts.Key(id)
|
promptBase := resources.bases.prompts.Index(index)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||||
}
|
}
|
||||||
fullID := indexedResourceID(resources.namespace, id)
|
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||||
raw := resources.prompts[id]
|
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,11 +55,11 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
func (state *loaded) materializePrompt(id string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||||
if len(raw.Messages) == 0 {
|
if len(raw.Messages) == 0 {
|
||||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
||||||
}
|
}
|
||||||
definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)}
|
definition := domain.PromptDefinition{ID: id}
|
||||||
if raw.DefaultProfile != nil {
|
if raw.DefaultProfile != nil {
|
||||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||||
@@ -126,107 +72,21 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
if raw.SessionID != nil {
|
if raw.SessionID != nil {
|
||||||
definition.SessionID = *raw.SessionID
|
definition.SessionID = *raw.SessionID
|
||||||
}
|
}
|
||||||
seenInputs := make(map[string]bool, len(raw.Inputs))
|
inputs, inputMetadata, err := state.capturePromptInputs(id, raw.Inputs, base)
|
||||||
definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs))
|
if err != nil {
|
||||||
entry := &promptEntry{
|
return nil, err
|
||||||
messages: make([]*template.Template, 0, len(raw.Messages)),
|
|
||||||
requirementInputs: make([]prompt.InputMetadata, 0, len(raw.Inputs)),
|
|
||||||
}
|
}
|
||||||
for index, input := range raw.Inputs {
|
messages, compiledMessages, err := state.capturePromptMessages(raw.Messages, base, lookupOrigin)
|
||||||
path := base.Key("inputs").Index(index)
|
if err != nil {
|
||||||
if input.Name == "" {
|
return nil, err
|
||||||
return nil, fmt.Errorf("%s: input name is required", path.Key("name"))
|
|
||||||
}
|
|
||||||
if seenInputs[input.Name] {
|
|
||||||
return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
|
|
||||||
}
|
|
||||||
seenInputs[input.Name] = true
|
|
||||||
contentType := ""
|
|
||||||
if input.ContentType != nil {
|
|
||||||
contentType = *input.ContentType
|
|
||||||
}
|
|
||||||
description := ""
|
|
||||||
if input.Description != nil {
|
|
||||||
description = *input.Description
|
|
||||||
}
|
|
||||||
definition.Inputs = append(definition.Inputs, domain.PromptInput{
|
|
||||||
Name: input.Name, Required: input.Required, ContentType: contentType, Description: description,
|
|
||||||
})
|
|
||||||
presence := state.promptMetadata[id][index]
|
|
||||||
metadata := prompt.InputMetadata{Name: input.Name, Required: input.Required}
|
|
||||||
if presence.contentType == fieldValue {
|
|
||||||
metadata.ContentType = stringPointer(contentType)
|
|
||||||
}
|
|
||||||
if presence.description == fieldValue {
|
|
||||||
metadata.Description = stringPointer(description)
|
|
||||||
}
|
|
||||||
entry.requirementInputs = append(entry.requirementInputs, metadata)
|
|
||||||
}
|
}
|
||||||
definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
contract, err := capturePromptOutput(raw.Output, base)
|
||||||
for index, message := range raw.Messages {
|
if err != nil {
|
||||||
path := base.Key("messages").Index(index)
|
return nil, err
|
||||||
if message.Role == nil {
|
|
||||||
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
|
||||||
}
|
|
||||||
role, err := domain.NormalizeMessageRole(*message.Role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
|
||||||
}
|
|
||||||
if (message.Content != nil) == (message.ContentFile != nil) {
|
|
||||||
return nil, fmt.Errorf("%s: exactly one of content or content_file is required", path)
|
|
||||||
}
|
|
||||||
content := ""
|
|
||||||
if message.Content != nil {
|
|
||||||
content = *message.Content
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var control *domain.CacheControl
|
|
||||||
if message.CacheControl != nil {
|
|
||||||
if message.CacheControl.Type == nil {
|
|
||||||
return nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type"))
|
|
||||||
}
|
|
||||||
control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)}
|
|
||||||
if message.CacheControl.TTL != nil {
|
|
||||||
control.TTL = *message.CacheControl.TTL
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
control, err = domain.NormalizeCacheControl(control)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: invalid template syntax: %w", path, err)
|
|
||||||
}
|
|
||||||
entry.messages = append(entry.messages, compiled)
|
|
||||||
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
|
|
||||||
Role: role, Content: content, CacheControl: control,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if raw.Output == nil {
|
definition.Inputs = inputs
|
||||||
return nil, fmt.Errorf("%s: output is required", base.Key("output"))
|
definition.Templates = messages
|
||||||
}
|
entry := &promptEntry{requirementInputs: inputMetadata, messages: compiledMessages}
|
||||||
if raw.Output.Format == nil || raw.Output.ValidationMode == nil {
|
|
||||||
return nil, fmt.Errorf("%s: format and validation_mode are required", base.Key("output"))
|
|
||||||
}
|
|
||||||
contract := domain.OutputContract{
|
|
||||||
Format: domain.OutputFormat(*raw.Output.Format),
|
|
||||||
ValidationMode: domain.ValidationMode(*raw.Output.ValidationMode),
|
|
||||||
RepairAttempts: raw.Output.RepairAttempts,
|
|
||||||
}
|
|
||||||
if raw.Output.SchemaPath != nil {
|
|
||||||
contract.SchemaPath = *raw.Output.SchemaPath
|
|
||||||
}
|
|
||||||
if err := domain.ValidateOutputContract(contract); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err)
|
|
||||||
}
|
|
||||||
definition.OutputFormat = contract.Format
|
|
||||||
definition.Validation = contract
|
definition.Validation = contract
|
||||||
if raw.SessionID != nil {
|
if raw.SessionID != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -242,13 +102,120 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
entry.definition = definition
|
entry.definition = definition
|
||||||
entry.hash = hash
|
entry.hash = hash
|
||||||
entry.inspection = domain.PromptInspection{
|
entry.inspection = domain.PromptInspection{
|
||||||
PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash,
|
PromptID: id, PromptHash: hash,
|
||||||
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||||
OutputContract: contract,
|
OutputContract: contract,
|
||||||
}
|
}
|
||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (state *loaded) capturePromptInputs(id string, rawInputs []promptInputConfig, base configkit.Path) ([]domain.PromptInput, []prompt.InputMetadata, error) {
|
||||||
|
seenInputs := make(map[string]bool, len(rawInputs))
|
||||||
|
inputs := make([]domain.PromptInput, 0, len(rawInputs))
|
||||||
|
metadata := make([]prompt.InputMetadata, 0, len(rawInputs))
|
||||||
|
for index, input := range rawInputs {
|
||||||
|
path := base.Key("inputs").Index(index)
|
||||||
|
if input.Name == "" {
|
||||||
|
return nil, nil, fmt.Errorf("%s: input name is required", path.Key("name"))
|
||||||
|
}
|
||||||
|
if seenInputs[input.Name] {
|
||||||
|
return nil, nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
|
||||||
|
}
|
||||||
|
seenInputs[input.Name] = true
|
||||||
|
contentType := ""
|
||||||
|
if input.ContentType != nil {
|
||||||
|
contentType = *input.ContentType
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
if input.Description != nil {
|
||||||
|
description = *input.Description
|
||||||
|
}
|
||||||
|
inputs = append(inputs, domain.PromptInput{
|
||||||
|
Name: input.Name, Required: input.Required, ContentType: contentType, Description: description,
|
||||||
|
})
|
||||||
|
presence := state.promptMetadata[id][index]
|
||||||
|
item := prompt.InputMetadata{Name: input.Name, Required: input.Required}
|
||||||
|
if presence.contentType == fieldValue {
|
||||||
|
item.ContentType = stringPointer(contentType)
|
||||||
|
}
|
||||||
|
if presence.description == fieldValue {
|
||||||
|
item.Description = stringPointer(description)
|
||||||
|
}
|
||||||
|
metadata = append(metadata, item)
|
||||||
|
}
|
||||||
|
return inputs, metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *loaded) capturePromptMessages(rawMessages []promptMessageConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) ([]domain.PromptMessageTemplate, []*template.Template, error) {
|
||||||
|
templates := make([]domain.PromptMessageTemplate, 0, len(rawMessages))
|
||||||
|
compiledMessages := make([]*template.Template, 0, len(rawMessages))
|
||||||
|
for index, message := range rawMessages {
|
||||||
|
path := base.Key("messages").Index(index)
|
||||||
|
if message.Role == nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
||||||
|
}
|
||||||
|
if _, err := domain.NormalizeMessageRole(*message.Role); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
||||||
|
}
|
||||||
|
if (message.Content != nil) == (message.ContentFile != nil) {
|
||||||
|
return nil, nil, fmt.Errorf("%s: exactly one of content or content_file is required", path)
|
||||||
|
}
|
||||||
|
content := ""
|
||||||
|
if message.Content != nil {
|
||||||
|
content = *message.Content
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var control *domain.CacheControl
|
||||||
|
if message.CacheControl != nil {
|
||||||
|
if message.CacheControl.Type == nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type"))
|
||||||
|
}
|
||||||
|
control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)}
|
||||||
|
if message.CacheControl.TTL != nil {
|
||||||
|
control.TTL = *message.CacheControl.TTL
|
||||||
|
}
|
||||||
|
if _, err := domain.NormalizeCacheControl(control); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: invalid template syntax: %w", path, err)
|
||||||
|
}
|
||||||
|
compiledMessages = append(compiledMessages, compiled)
|
||||||
|
templates = append(templates, domain.PromptMessageTemplate{
|
||||||
|
Role: *message.Role, Content: content, CacheControl: control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return templates, compiledMessages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePromptOutput(output *promptOutputConfig, base configkit.Path) (domain.OutputContract, error) {
|
||||||
|
if output == nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: output is required", base.Key("output"))
|
||||||
|
}
|
||||||
|
if output.Format == nil || output.ValidationMode == nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: format and validation_mode are required", base.Key("output"))
|
||||||
|
}
|
||||||
|
contract := domain.OutputContract{
|
||||||
|
Format: domain.OutputFormat(*output.Format),
|
||||||
|
ValidationMode: domain.ValidationMode(*output.ValidationMode),
|
||||||
|
RepairAttempts: output.RepairAttempts,
|
||||||
|
}
|
||||||
|
if output.SchemaPath != nil {
|
||||||
|
contract.SchemaPath = *output.SchemaPath
|
||||||
|
}
|
||||||
|
if err := domain.ValidateOutputContract(contract); err != nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err)
|
||||||
|
}
|
||||||
|
return contract, nil
|
||||||
|
}
|
||||||
|
|
||||||
func stringPointer(value string) *string {
|
func stringPointer(value string) *string {
|
||||||
copy := value
|
copy := value
|
||||||
return ©
|
return ©
|
||||||
@@ -305,7 +272,6 @@ func (state *loaded) lookupPromptEntry(id string) (*promptEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
||||||
definition.Version = stringPointerValue(definition.Version)
|
|
||||||
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
||||||
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
||||||
for index := range definition.Templates {
|
for index := range definition.Templates {
|
||||||
@@ -318,7 +284,6 @@ func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
||||||
inspection.PromptVersion = stringPointerValue(inspection.PromptVersion)
|
|
||||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||||
return &inspection
|
return &inspection
|
||||||
}
|
}
|
||||||
@@ -364,12 +329,6 @@ func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domai
|
|||||||
return ownPromptInspection(entry.inspection), nil
|
return ownPromptInspection(entry.inspection), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPromptDefinition implements the runner's prompt lookup boundary.
|
|
||||||
func (state *loaded) GetPromptDefinition(ctx context.Context, id string) (*domain.PromptDefinition, error) {
|
|
||||||
definition, _, err := state.GetPromptSnapshot(ctx, id)
|
|
||||||
return definition, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCompiledTemplates returns construction-time parses for one prompt ID.
|
// GetCompiledTemplates returns construction-time parses for one prompt ID.
|
||||||
func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) {
|
func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) {
|
||||||
if state == nil {
|
if state == nil {
|
||||||
|
|||||||
@@ -10,23 +10,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\n - path: ../other/definition.yaml\ncollections:\n docs:\n prompts:\n child:\n extends: base\n")},
|
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n description: ' description '\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||||
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
"app/body.md": {Data: []byte("Replacement {{.name}}")},
|
||||||
|
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n description: ' description '\n session_id: ' session '\n inputs:\n - name: ' document '\n content_type: ' text/plain '\n description: ' document source '\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
||||||
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
||||||
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n child:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||||
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||||
}
|
}
|
||||||
namespace := configkit.NewFilesystem("embedded", files)
|
namespace := configkit.NewFilesystem("embedded", files)
|
||||||
state, err := load(configkit.Input{
|
state, err := load(configkit.Input{
|
||||||
Source: namespace.File("app/main.yaml"), Imports: true,
|
Source: namespace.File("app/main.yaml"), Imports: true,
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
||||||
}, nil)
|
}, []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("other/definition.yaml"), Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) {
|
if errors.As(err, &loadError) {
|
||||||
@@ -41,11 +42,29 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
||||||
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
||||||
}
|
}
|
||||||
|
if definition.Description != "description" || definition.SessionID != "session" || len(definition.Inputs) != 1 ||
|
||||||
|
definition.Inputs[0].Name != "document" || definition.Inputs[0].ContentType != "text/plain" || definition.Inputs[0].Description != "document source" {
|
||||||
|
t.Fatalf("inherited metadata was not interpreted: %+v", definition)
|
||||||
|
}
|
||||||
message := definition.Templates[0]
|
message := definition.Templates[0]
|
||||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||||
}
|
}
|
||||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file"))
|
childIndex := -1
|
||||||
|
for index, item := range state.result.Config.Collections["docs"].Prompts {
|
||||||
|
if item.ID == "child" {
|
||||||
|
childIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if childIndex < 0 {
|
||||||
|
t.Fatal("inherited prompt missing from effective list")
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Collections["docs"].Prompts[childIndex]
|
||||||
|
if *composed.Description != "description" || *composed.SessionID != "session" || composed.Inputs[0].Name != "document" ||
|
||||||
|
*composed.Inputs[0].ContentType != "text/plain" || *composed.Inputs[0].Description != "document source" {
|
||||||
|
t.Fatalf("resolved metadata was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts").Index(childIndex).Key("messages").Index(0).Key("content_file"))
|
||||||
if !ok || len(field.Origin.Locations) != 1 {
|
if !ok || len(field.Origin.Locations) != 1 {
|
||||||
t.Fatalf("content origin was lost during normalization: %+v", field)
|
t.Fatalf("content origin was lost during normalization: %+v", field)
|
||||||
}
|
}
|
||||||
@@ -59,10 +78,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||||
}
|
}
|
||||||
other, otherInspection, err := state.prompt("other/child")
|
other, otherInspection, err := state.prompt("other/child")
|
||||||
if err != nil || other.Templates[0].Content != "Other collection {{.name}}" {
|
if err != nil || other.Templates[0].Content != "Replacement {{.name}}" {
|
||||||
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||||
}
|
}
|
||||||
files["catalog/body.md"].Data = []byte("Changed")
|
files["catalog/body.md"].Data = []byte("Changed")
|
||||||
|
files["app/body.md"].Data = []byte("Changed replacement")
|
||||||
files["other/body.md"].Data = []byte("Changed other collection")
|
files["other/body.md"].Data = []byte("Changed other collection")
|
||||||
again, againInspection, err := state.prompt("docs/child")
|
again, againInspection, err := state.prompt("docs/child")
|
||||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
||||||
@@ -74,11 +94,28 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||||
}
|
}
|
||||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
||||||
if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
if err != nil || otherAgain.Templates[0].Content != "Replacement {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||||
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadHeaderlessImportedItem(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yaml": {Data: []byte("version: 2\nconfig:\n imports:\n - path: item.yaml\n destination: [prompts]\n placement: sequence-item\n required: true\n")},
|
||||||
|
"item.yaml": {Data: []byte("id: example\nmessages:\n - role: user\n content: hello\noutput:\n format: text\n validation_mode: basic\n")},
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("items", files)
|
||||||
|
state, err := load(configkit.Input{Source: namespace.File("main.yaml"), Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt("example")
|
||||||
|
if err != nil || definition.Templates[0].Content != "hello" {
|
||||||
|
t.Fatalf("headerless item = %+v, %v", definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||||
filesystem := &countingFS{FS: fstest.MapFS{
|
filesystem := &countingFS{FS: fstest.MapFS{
|
||||||
"assets/body.md": {Data: []byte("From mapping")},
|
"assets/body.md": {Data: []byte("From mapping")},
|
||||||
@@ -97,7 +134,7 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
|||||||
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") {
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/messages/0/content_file") {
|
||||||
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +143,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
definitionPath := filepath.Join(root, "definition.yaml")
|
definitionPath := filepath.Join(root, "definition.yaml")
|
||||||
contentPath := filepath.Join(root, "body.md")
|
contentPath := filepath.Join(root, "body.md")
|
||||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
if err := os.WriteFile(definitionPath, []byte("prompts:\n - id: example\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
||||||
@@ -125,127 +162,14 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsMalformedStaticSyntaxAndCapturesOptionalVersion(t *testing.T) {
|
func TestLoadRejectsMalformedStaticSyntax(t *testing.T) {
|
||||||
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err := load(mapping("bad-template", map[string]any{"prompts": []any{map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}), nil)
|
}}}), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
||||||
t.Fatalf("expected construction-time syntax error, got %v", err)
|
t.Fatalf("expected construction-time syntax error, got %v", err)
|
||||||
}
|
}
|
||||||
state, err := load(mapping("version metadata", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
|
||||||
"version": "release label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "one"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}}}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version == nil || *definition.Version != "release label" ||
|
|
||||||
inspection.PromptVersion == nil || *inspection.PromptVersion != "release label" {
|
|
||||||
t.Fatalf("optional version metadata was not retained: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionLabelPresenceAndOwnership(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
version any
|
|
||||||
include bool
|
|
||||||
wantPresent bool
|
|
||||||
wantValue string
|
|
||||||
}{
|
|
||||||
{name: "absent"},
|
|
||||||
{name: "null", include: true},
|
|
||||||
{name: "empty", version: "", include: true, wantPresent: true},
|
|
||||||
{name: "exact text", version: " Release/v1 ", include: true, wantPresent: true, wantValue: " Release/v1 "},
|
|
||||||
}
|
|
||||||
|
|
||||||
hashes := make(map[string]string, len(tests))
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
definition := map[string]any{
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
if test.include {
|
|
||||||
definition["version"] = test.version
|
|
||||||
}
|
|
||||||
state, err := load(mapping("prompt version label", map[string]any{
|
|
||||||
"prompts": map[string]any{"example": definition},
|
|
||||||
}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, inspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
identities, err := state.ListPrompts(context.Background())
|
|
||||||
if err != nil || len(identities) != 1 {
|
|
||||||
t.Fatalf("list prompt labels: %#v, %v", identities, err)
|
|
||||||
}
|
|
||||||
check := func(name string, value *string) {
|
|
||||||
t.Helper()
|
|
||||||
if !test.wantPresent {
|
|
||||||
if value != nil {
|
|
||||||
t.Errorf("%s label = %q, want absent", name, *value)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if value == nil || *value != test.wantValue {
|
|
||||||
t.Errorf("%s label = %v, want %q", name, value, test.wantValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check("definition", captured.Version)
|
|
||||||
check("inspection", inspection.PromptVersion)
|
|
||||||
check("listing", identities[0].Version)
|
|
||||||
if captured.Version != nil && (captured.Version == inspection.PromptVersion || captured.Version == identities[0].Version || inspection.PromptVersion == identities[0].Version) {
|
|
||||||
t.Fatal("returned prompt labels share mutable pointers")
|
|
||||||
}
|
|
||||||
hashes[test.name] = inspection.PromptHash
|
|
||||||
|
|
||||||
if captured.Version != nil {
|
|
||||||
*captured.Version = "mutated definition"
|
|
||||||
}
|
|
||||||
if inspection.PromptVersion != nil {
|
|
||||||
*inspection.PromptVersion = "mutated inspection"
|
|
||||||
}
|
|
||||||
if identities[0].Version != nil {
|
|
||||||
*identities[0].Version = "mutated listing"
|
|
||||||
}
|
|
||||||
again, againInspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
check("captured definition after mutation", again.Version)
|
|
||||||
check("captured inspection after mutation", againInspection.PromptVersion)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if hashes["absent"] == hashes["empty"] {
|
|
||||||
t.Fatal("absent and declared-empty labels produced the same definition hash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionNullClearsInheritedLabel(t *testing.T) {
|
|
||||||
prompt := map[string]any{
|
|
||||||
"version": "base-label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
state, err := load(
|
|
||||||
mapping("clear inherited label", map[string]any{"prompts": map[string]any{"example": map[string]any{"version": nil}}}),
|
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{mapping("base prompt", map[string]any{"prompts": map[string]any{"example": prompt}})}}},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version != nil || inspection.PromptVersion != nil {
|
|
||||||
t.Fatalf("null overlay retained inherited label: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
||||||
@@ -261,12 +185,10 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedPrompt := makePrompt("selected")
|
selectedPrompt := makePrompt("selected")
|
||||||
selectedPrompt["version"] = "v2"
|
selectedPrompt["id"] = "selected"
|
||||||
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{
|
"prompts": []any{selectedPrompt},
|
||||||
"selected": selectedPrompt,
|
|
||||||
},
|
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -281,8 +203,7 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("snapshot prompt ID: %v", err)
|
t.Fatalf("snapshot prompt ID: %v", err)
|
||||||
}
|
}
|
||||||
if inspection.PromptID != definition.ID || inspection.PromptVersion == nil || definition.Version == nil ||
|
if inspection.PromptID != definition.ID ||
|
||||||
*inspection.PromptVersion != *definition.Version ||
|
|
||||||
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
||||||
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
||||||
@@ -322,9 +243,8 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
||||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{
|
state, err := load(mapping("empty-content", map[string]any{"prompts": []any{map[string]any{"id": " exact.id ",
|
||||||
"version": "v1",
|
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
|
||||||
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
||||||
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -344,7 +264,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
||||||
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err = load(mapping("obsolete-field", map[string]any{"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -353,7 +273,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("null-prompt", map[string]any{"prompts": map[string]any{"example": nil}}), nil)
|
_, err = load(mapping("null-prompt", map[string]any{"prompts": []any{nil}}), nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("null prompt collection was accepted")
|
t.Fatal("null prompt collection was accepted")
|
||||||
}
|
}
|
||||||
@@ -361,8 +281,8 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
|
|
||||||
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
||||||
state, err := load(mapping("requirement-metadata", map[string]any{
|
state, err := load(mapping("requirement-metadata", map[string]any{
|
||||||
"prompts": map[string]any{"example": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "example",
|
||||||
"session_id": "{{.session}}",
|
"session_id": "{{.session}}",
|
||||||
"inputs": []any{
|
"inputs": []any{
|
||||||
map[string]any{"name": "absent"},
|
map[string]any{"name": "absent"},
|
||||||
@@ -435,8 +355,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
input[name] = value
|
input[name] = value
|
||||||
}
|
}
|
||||||
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
||||||
"prompts": map[string]any{"same": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "same",
|
||||||
"inputs": []any{input},
|
"inputs": []any{input},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -466,7 +386,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func promptWithFile(name string) map[string]any {
|
func promptWithFile(name string) map[string]any {
|
||||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{
|
return map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}
|
}}}
|
||||||
|
|||||||
68
internal/configuration/resolve_prompts.go
Normal file
68
internal/configuration/resolve_prompts.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolvePrompts(entries []promptConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v promptConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&entry.Description, "description"}, {&entry.SessionID, "session_id"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j := range entry.Inputs {
|
||||||
|
input := &entry.Inputs[j]
|
||||||
|
inputPath := path.Key("inputs").Index(j)
|
||||||
|
if normalized := strings.TrimSpace(input.Name); normalized != input.Name {
|
||||||
|
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalized, "Normalize input name")
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&input.ContentType, "content_type"}, {&input.Description, "description"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, inputPath.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Output != nil && entry.Output.SchemaPath != nil {
|
||||||
|
recordStringNormalization(&entry.Output.SchemaPath, path.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*entry.Output.SchemaPath))
|
||||||
|
}
|
||||||
|
for j := range entry.Messages {
|
||||||
|
message := &entry.Messages[j]
|
||||||
|
messagePath := path.Key("messages").Index(j)
|
||||||
|
if message.Role != nil {
|
||||||
|
if value, err := domain.NormalizeMessageRole(*message.Role); err == nil {
|
||||||
|
recordStringNormalization(&message.Role, messagePath.Key("role"), resolution, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cache := message.CacheControl
|
||||||
|
control := &domain.CacheControl{Type: domain.CacheControlType(*cache.Type)}
|
||||||
|
if cache.TTL != nil {
|
||||||
|
control.TTL = *cache.TTL
|
||||||
|
}
|
||||||
|
if normalized, err := domain.NormalizeCacheControl(control); err == nil {
|
||||||
|
cachePath := messagePath.Key("cache_control")
|
||||||
|
recordStringNormalization(&cache.Type, cachePath.Key("type"), resolution, string(normalized.Type))
|
||||||
|
if cache.TTL != nil {
|
||||||
|
recordStringNormalization(&cache.TTL, cachePath.Key("ttl"), resolution, normalized.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/configuration/resolve_targets.go
Normal file
102
internal/configuration/resolve_targets.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveBackends(entries []backendConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v backendConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
if entry.Endpoint != nil {
|
||||||
|
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
|
||||||
|
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.APIKeyEnv != nil {
|
||||||
|
recordStringNormalization(&entry.APIKeyEnv, path.Key("api_key_env"), resolution, strings.TrimSpace(*entry.APIKeyEnv))
|
||||||
|
}
|
||||||
|
if entry.ConcurrencyLimit != nil && *entry.ConcurrencyLimit > 0 && entry.QueueCapacity == nil {
|
||||||
|
capacity := 1024
|
||||||
|
entry.QueueCapacity = &capacity
|
||||||
|
resolution.RecordDerivedDefault(path.Key("queue_capacity"), "Derive queue capacity", path.Key("concurrency_limit"))
|
||||||
|
}
|
||||||
|
if entry.SmokeTests == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
smoke := entry.SmokeTests
|
||||||
|
smokePath := path.Key("smoke_tests")
|
||||||
|
if smoke.Reachability != nil {
|
||||||
|
resolveDiagnosticEndpoint(&smoke.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
||||||
|
}
|
||||||
|
if smoke.Authentication != nil {
|
||||||
|
auth := smoke.Authentication
|
||||||
|
authPath := smokePath.Key("authentication")
|
||||||
|
if auth.Kind != nil {
|
||||||
|
recordStringNormalization(&auth.Kind, authPath.Key("kind"), resolution, strings.ToLower(strings.TrimSpace(*auth.Kind)))
|
||||||
|
}
|
||||||
|
resolveDiagnosticEndpoint(&auth.Endpoint, authPath.Key("endpoint"), resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticEndpointValid(value string) bool {
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
||||||
|
return normalized != "" && err == nil && request.URL != nil && (strings.EqualFold(request.URL.Scheme, "http") || strings.EqualFold(request.URL.Scheme, "https")) && request.URL.Hostname() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBackendEndpoints(entry backendConfig, path configkit.Path, validation *configkit.Validation) {
|
||||||
|
if entry.Endpoint != nil {
|
||||||
|
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err != nil {
|
||||||
|
validation.Error(path.Key("endpoint"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.SmokeTests == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
smoke := entry.SmokeTests
|
||||||
|
path = path.Key("smoke_tests")
|
||||||
|
if smoke.Reachability != nil && smoke.Reachability.Endpoint != nil && !diagnosticEndpointValid(*smoke.Reachability.Endpoint) {
|
||||||
|
validation.Error(path.Key("reachability").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||||
|
}
|
||||||
|
if smoke.Authentication != nil && smoke.Authentication.Endpoint != nil && !diagnosticEndpointValid(*smoke.Authentication.Endpoint) {
|
||||||
|
validation.Error(path.Key("authentication").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if *value != nil && diagnosticEndpointValid(**value) {
|
||||||
|
recordStringNormalization(value, path, resolution, strings.TrimSpace(**value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProfiles(entries []profileConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v profileConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
if entry.Endpoint != nil && *entry.Endpoint != "" {
|
||||||
|
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
|
||||||
|
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&entry.ServiceTier, "service_tier"}, {&entry.ReasoningEffort, "reasoning_effort"}, {&entry.APIKeyEnv, "api_key_env"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (state *loaded) buildExecutionProfiles() error {
|
func (state *loaded) buildExecutionProfiles() error {
|
||||||
@@ -17,109 +17,115 @@ func (state *loaded) buildExecutionProfiles() error {
|
|||||||
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
||||||
for _, id := range sortedKeys(backendConfigs) {
|
for _, id := range sortedKeys(backendConfigs) {
|
||||||
config := backendConfigs[id]
|
config := backendConfigs[id]
|
||||||
definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams}
|
definitions[id] = captureBackend(id, config)
|
||||||
if config.SmokeTests != nil {
|
|
||||||
smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile}
|
|
||||||
if config.SmokeTests.Reachability != nil {
|
|
||||||
smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint}
|
|
||||||
}
|
|
||||||
if config.SmokeTests.Authentication != nil {
|
|
||||||
smokeTests.Authentication = &domain.AuthenticationProbeConfig{
|
|
||||||
Kind: config.SmokeTests.Authentication.Kind,
|
|
||||||
Endpoint: config.SmokeTests.Authentication.Endpoint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
definition.SmokeTests = smokeTests
|
|
||||||
}
|
|
||||||
if config.Endpoint != nil {
|
|
||||||
definition.Endpoint = *config.Endpoint
|
|
||||||
}
|
|
||||||
if config.APIKeyEnv != nil {
|
|
||||||
definition.APIKeyEnv = *config.APIKeyEnv
|
|
||||||
}
|
|
||||||
if config.ConcurrencyLimit != nil {
|
|
||||||
definition.ConcurrencyLimit = *config.ConcurrencyLimit
|
|
||||||
}
|
|
||||||
if config.QueueCapacity != nil {
|
|
||||||
definition.QueueCapacity = *config.QueueCapacity
|
|
||||||
definition.QueueCapacitySet = definition.ConcurrencyLimit > 0
|
|
||||||
}
|
|
||||||
definitions[id] = definition
|
|
||||||
}
|
}
|
||||||
registry, err := backend.NewEffectiveRegistry(definitions)
|
state.registry = backend.NewEffectiveRegistry(definitions)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.registry = registry
|
|
||||||
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
|
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
|
||||||
for _, id := range sortedKeys(profileConfigs) {
|
for _, id := range sortedKeys(profileConfigs) {
|
||||||
config := profileConfigs[id]
|
config := profileConfigs[id]
|
||||||
fields := state.profiles[id]
|
profile, err := captureExecutionProfile(id, config, state.profiles[id], state.registry)
|
||||||
target := defaults.ExecutionTargetDefault()
|
if err != nil {
|
||||||
if config.Backend != nil && *config.Backend != "" {
|
return err
|
||||||
selected, err := registry.GetBackend(*config.Backend)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("profile %q backend: %w", id, err)
|
|
||||||
}
|
|
||||||
target.BackendID = selected.ID
|
|
||||||
target.Endpoint = selected.Endpoint
|
|
||||||
target.APIKeyEnv = selected.APIKeyEnv
|
|
||||||
target.ExtraParams = selected.ExtraParams
|
|
||||||
}
|
}
|
||||||
presence := domain.ExecutionTargetPresence{}
|
|
||||||
if fields["endpoint"] != fieldAbsent {
|
|
||||||
target.Endpoint = valueOrZero(config.Endpoint)
|
|
||||||
}
|
|
||||||
if fields["model"] != fieldAbsent {
|
|
||||||
target.Model = valueOrZero(config.Model)
|
|
||||||
}
|
|
||||||
if fields["temperature"] != fieldAbsent {
|
|
||||||
target.Temperature = valueOrZero(config.Temperature)
|
|
||||||
presence.Temperature = fields["temperature"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["top_p"] != fieldAbsent {
|
|
||||||
target.TopP = valueOrZero(config.TopP)
|
|
||||||
presence.TopP = fields["top_p"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["max_tokens"] != fieldAbsent {
|
|
||||||
target.MaxTokens = valueOrZero(config.MaxTokens)
|
|
||||||
presence.MaxTokens = fields["max_tokens"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["timeout_seconds"] == fieldValue {
|
|
||||||
target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds)
|
|
||||||
presence.TimeoutSeconds = true
|
|
||||||
}
|
|
||||||
if fields["service_tier"] != fieldAbsent {
|
|
||||||
target.ServiceTier = valueOrZero(config.ServiceTier)
|
|
||||||
}
|
|
||||||
if fields["reasoning_effort"] != fieldAbsent {
|
|
||||||
target.ReasoningEffort = valueOrZero(config.ReasoningEffort)
|
|
||||||
}
|
|
||||||
if fields["api_key_env"] != fieldAbsent {
|
|
||||||
target.APIKeyEnv = valueOrZero(config.APIKeyEnv)
|
|
||||||
}
|
|
||||||
if fields["api_key_required"] != fieldAbsent {
|
|
||||||
target.APIKeyRequired = valueOrZero(config.APIKeyRequired)
|
|
||||||
}
|
|
||||||
if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent {
|
|
||||||
target.APIKeyEnv = ""
|
|
||||||
}
|
|
||||||
switch fields["extra_params"] {
|
|
||||||
case fieldNull:
|
|
||||||
target.ExtraParams = nil
|
|
||||||
case fieldValue:
|
|
||||||
merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("profile %q extra parameters: %w", id, err)
|
|
||||||
}
|
|
||||||
target.ExtraParams = merged
|
|
||||||
}
|
|
||||||
profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence}
|
|
||||||
state.indexedProfiles[id] = profile
|
state.indexedProfiles[id] = profile
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func captureBackend(id string, config backendConfig) domain.Backend {
|
||||||
|
definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams}
|
||||||
|
if config.SmokeTests != nil {
|
||||||
|
smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile}
|
||||||
|
if config.SmokeTests.Reachability != nil {
|
||||||
|
smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint}
|
||||||
|
}
|
||||||
|
if config.SmokeTests.Authentication != nil {
|
||||||
|
smokeTests.Authentication = &domain.AuthenticationProbeConfig{
|
||||||
|
Kind: config.SmokeTests.Authentication.Kind,
|
||||||
|
Endpoint: config.SmokeTests.Authentication.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
definition.SmokeTests = smokeTests
|
||||||
|
}
|
||||||
|
if config.Endpoint != nil {
|
||||||
|
definition.Endpoint = *config.Endpoint
|
||||||
|
}
|
||||||
|
if config.APIKeyEnv != nil {
|
||||||
|
definition.APIKeyEnv = *config.APIKeyEnv
|
||||||
|
}
|
||||||
|
if config.ConcurrencyLimit != nil {
|
||||||
|
definition.ConcurrencyLimit = *config.ConcurrencyLimit
|
||||||
|
}
|
||||||
|
if config.QueueCapacity != nil {
|
||||||
|
definition.QueueCapacity = *config.QueueCapacity
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureExecutionProfile(id string, config profileConfig, fields map[string]fieldState, registry *backend.Registry) (domain.ExecutionProfile, error) {
|
||||||
|
target := defaults.ExecutionTargetDefault()
|
||||||
|
if config.Backend != nil && *config.Backend != "" {
|
||||||
|
selected, err := registry.GetBackend(*config.Backend)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionProfile{}, fmt.Errorf("profile %q backend: %w", id, err)
|
||||||
|
}
|
||||||
|
target.BackendID = selected.ID
|
||||||
|
target.Endpoint = selected.Endpoint
|
||||||
|
target.APIKeyEnv = selected.APIKeyEnv
|
||||||
|
target.ExtraParams = selected.ExtraParams
|
||||||
|
}
|
||||||
|
presence := domain.ExecutionTargetPresence{}
|
||||||
|
if fields["endpoint"] != fieldAbsent {
|
||||||
|
target.Endpoint = valueOrZero(config.Endpoint)
|
||||||
|
}
|
||||||
|
if fields["model"] != fieldAbsent {
|
||||||
|
target.Model = valueOrZero(config.Model)
|
||||||
|
}
|
||||||
|
if fields["temperature"] != fieldAbsent {
|
||||||
|
target.Temperature = valueOrZero(config.Temperature)
|
||||||
|
presence.Temperature = fields["temperature"] == fieldValue
|
||||||
|
}
|
||||||
|
if fields["top_p"] != fieldAbsent {
|
||||||
|
target.TopP = valueOrZero(config.TopP)
|
||||||
|
presence.TopP = fields["top_p"] == fieldValue
|
||||||
|
}
|
||||||
|
if fields["max_tokens"] != fieldAbsent {
|
||||||
|
target.MaxTokens = valueOrZero(config.MaxTokens)
|
||||||
|
presence.MaxTokens = fields["max_tokens"] == fieldValue
|
||||||
|
}
|
||||||
|
if fields["timeout_seconds"] == fieldValue {
|
||||||
|
target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds)
|
||||||
|
presence.TimeoutSeconds = true
|
||||||
|
}
|
||||||
|
if fields["service_tier"] != fieldAbsent {
|
||||||
|
target.ServiceTier = valueOrZero(config.ServiceTier)
|
||||||
|
}
|
||||||
|
if fields["reasoning_effort"] != fieldAbsent {
|
||||||
|
target.ReasoningEffort = valueOrZero(config.ReasoningEffort)
|
||||||
|
}
|
||||||
|
if fields["api_key_env"] != fieldAbsent {
|
||||||
|
target.APIKeyEnv = valueOrZero(config.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if fields["api_key_required"] != fieldAbsent {
|
||||||
|
target.APIKeyRequired = valueOrZero(config.APIKeyRequired)
|
||||||
|
}
|
||||||
|
if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent {
|
||||||
|
target.APIKeyEnv = ""
|
||||||
|
}
|
||||||
|
switch fields["extra_params"] {
|
||||||
|
case fieldNull:
|
||||||
|
target.ExtraParams = nil
|
||||||
|
case fieldValue:
|
||||||
|
merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionProfile{}, fmt.Errorf("profile %q extra parameters: %w", id, err)
|
||||||
|
}
|
||||||
|
target.ExtraParams = merged
|
||||||
|
}
|
||||||
|
profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
func valueOrZero[T any](value *T) T {
|
func valueOrZero[T any](value *T) T {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
var zero T
|
var zero T
|
||||||
@@ -138,24 +144,33 @@ func overlayProviderMap(lower, upper map[string]any) (map[string]any, error) {
|
|||||||
if merged == nil {
|
if merged == nil {
|
||||||
merged = make(map[string]any)
|
merged = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
if err := overlayOwnedProviderMap(merged, upper); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayOwnedProviderMap(merged, upper map[string]any) error {
|
||||||
for key, value := range upper {
|
for key, value := range upper {
|
||||||
if child, ok := value.(map[string]any); ok {
|
if child, ok := value.(map[string]any); ok {
|
||||||
if existing, ok := merged[key].(map[string]any); ok {
|
if existing, ok := merged[key].(map[string]any); ok {
|
||||||
combined, err := overlayProviderMap(existing, child)
|
if existing == nil {
|
||||||
if err != nil {
|
existing = make(map[string]any)
|
||||||
return nil, err
|
merged[key] = existing
|
||||||
|
}
|
||||||
|
if err := overlayOwnedProviderMap(existing, child); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
merged[key] = combined
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
copied, err := jsonvalue.Copy(value)
|
copied, err := jsonvalue.Copy(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
merged[key] = copied
|
merged[key] = copied
|
||||||
}
|
}
|
||||||
return merged, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) lookupProfile(ctx context.Context, id string) (domain.ExecutionProfile, error) {
|
func (state *loaded) lookupProfile(ctx context.Context, id string) (domain.ExecutionProfile, error) {
|
||||||
@@ -180,11 +195,7 @@ func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.Executi
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
target := *profile.ResolvedTarget
|
target := *profile.ResolvedTarget
|
||||||
params, err := jsonvalue.CopyMap(target.ExtraParams)
|
target.ExtraParams = jsonvalue.CloneTrusted(target.ExtraParams).(map[string]any)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
target.ExtraParams = params
|
|
||||||
profile.ResolvedTarget = &target
|
profile.ResolvedTarget = &target
|
||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,43 +9,117 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestLoadIndexesIdentifiedTargets(t *testing.T) {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://root.example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
|
"collections": map[string]any{},
|
||||||
|
}
|
||||||
|
collections := values["collections"].(map[string]any)
|
||||||
|
for _, namespace := range []string{"north", "south"} {
|
||||||
|
collections[namespace] = map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://" + namespace + ".example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": namespace + "/local"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("identified targets", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for id, backendID := range map[string]string{"partial": "local", "north/partial": "north/local", "south/partial": "south/local"} {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil || profile.ID != id || profile.ResolvedTarget == nil || profile.ResolvedTarget.Model != "" || profile.ResolvedTarget.BackendID != backendID {
|
||||||
|
t.Fatalf("partial profile %q = %+v, %v", id, profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayProviderMapOwnsInputsAndReplacesValues(t *testing.T) {
|
||||||
|
lower := map[string]any{"nested": map[string]any{"keep": "lower", "replace": []any{"old"}, "null": "old"}, "disjoint": []any{"original"}}
|
||||||
|
upper := map[string]any{"nested": map[string]any{"replace": []any{"new"}, "null": nil, "added": map[string]any{"value": "upper"}}}
|
||||||
|
got, err := overlayProviderMap(lower, upper)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{"nested": map[string]any{"keep": "lower", "replace": []any{"new"}, "null": nil, "added": map[string]any{"value": "upper"}}, "disjoint": []any{"original"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("overlay = %#v", got)
|
||||||
|
}
|
||||||
|
lower["disjoint"].([]any)[0] = "changed"
|
||||||
|
upper["nested"].(map[string]any)["replace"].([]any)[0] = "changed"
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("overlay shares input storage: %#v", got)
|
||||||
|
}
|
||||||
|
got["nested"].(map[string]any)["added"].(map[string]any)["value"] = "changed"
|
||||||
|
if upper["nested"].(map[string]any)["added"].(map[string]any)["value"] != "upper" {
|
||||||
|
t.Fatal("result mutation reached upper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.NewCompiledRenderer(state), nil, nil, nil)
|
||||||
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "example", ProfileID: "partial",
|
||||||
|
Execution: &domain.ExecutionTargetOverride{Endpoint: "https://example.invalid/v1", Model: "request-model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prepared.SelectedProfileID != "partial" || prepared.EffectiveModelParams.Endpoint != "https://example.invalid/v1" ||
|
||||||
|
prepared.EffectiveModelParams.Model != "request-model" {
|
||||||
|
t.Fatalf("request did not complete the loaded profile: %+v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
||||||
state, err := load(mapping("effective-targets", map[string]any{
|
state, err := load(mapping("effective-targets", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{
|
||||||
"first": map[string]any{"endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
map[string]any{"id": "first", "endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
||||||
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
||||||
"second": map[string]any{"endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
map[string]any{"id": "second", "endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
||||||
"concurrency_limit": 2, "queue_capacity": 0,
|
"concurrency_limit": 2, "queue_capacity": 0,
|
||||||
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
||||||
},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base": map[string]any{"backend": "first", "model": "model", "temperature": 0.8,
|
map[string]any{"id": "base", "backend": "first", "model": "model", "temperature": 0.8,
|
||||||
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
||||||
"child": map[string]any{"extends": "base", "backend": "second", "temperature": 0.0,
|
map[string]any{"id": "child", "extends": map[string]any{"id": "base"}, "backend": "second", "temperature": 0.0,
|
||||||
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
||||||
"api_key_required": false,
|
"api_key_required": false,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
||||||
"direct-key": map[string]any{"backend": "second", "api_key_required": true},
|
map[string]any{"id": "direct-key", "backend": "second", "api_key_required": true},
|
||||||
"named-key": map[string]any{"backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
map[string]any{"id": "named-key", "backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
||||||
"no-provider-map": map[string]any{"backend": "second", "extra_params": nil},
|
map[string]any{"id": "no-provider-map", "backend": "second", "extra_params": nil},
|
||||||
"no-timeout": map[string]any{"backend": "second", "timeout_seconds": 0},
|
map[string]any{"id": "no-timeout", "backend": "second", "timeout_seconds": 0},
|
||||||
"partial": map[string]any{},
|
map[string]any{"id": "partial"},
|
||||||
"endpoint-only": map[string]any{"endpoint": "https://standalone.example/v1"},
|
map[string]any{"id": "endpoint-only", "endpoint": "https://standalone.example/v1"},
|
||||||
"cleared-endpoint": map[string]any{"extends": "base", "endpoint": nil},
|
map[string]any{"id": "cleared-endpoint", "extends": map[string]any{"id": "base"}, "endpoint": nil},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
selected, err := state.registry.GetBackend("second")
|
selected, err := state.registry.GetBackend("second")
|
||||||
if err != nil || selected.QueueCapacity != 0 || !selected.QueueCapacitySet {
|
if err != nil || selected.QueueCapacity != 0 || selected.ConcurrencyLimit != 2 {
|
||||||
t.Fatalf("backend capacity was not preserved: %+v, %v", selected, err)
|
t.Fatalf("backend capacity was not preserved: %+v, %v", selected, err)
|
||||||
}
|
}
|
||||||
child, err := state.GetProfile(context.Background(), "child")
|
child, err := state.GetProfile(context.Background(), "child")
|
||||||
@@ -133,9 +207,9 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
state, err := load(mapping("wire-controls", map[string]any{
|
state, err := load(mapping("wire-controls", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": server.URL + "/v1"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": server.URL + "/v1"}},
|
||||||
"profiles": map[string]any{"direct": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
"id": "direct", "backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
||||||
}},
|
}},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,10 +219,7 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
client, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
client := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
|
||||||
Target: *profile.ResolvedTarget, TargetPresence: profile.TargetPresence,
|
Target: *profile.ResolvedTarget, TargetPresence: profile.TargetPresence,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package defaults
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ type RunResult struct {
|
|||||||
RawOutput string
|
RawOutput string
|
||||||
Validation ValidationResult
|
Validation ValidationResult
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SessionID string
|
SessionID string
|
||||||
RenderedPromptHash string
|
RenderedPromptHash string
|
||||||
@@ -100,7 +99,6 @@ type RunResult struct {
|
|||||||
// It must never include resolved API key values, model output, or validation data.
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
type PreparedRun struct {
|
type PreparedRun struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SelectedProfileID string
|
SelectedProfileID string
|
||||||
SelectedBackendID string
|
SelectedBackendID string
|
||||||
@@ -134,29 +132,25 @@ type Artifact struct {
|
|||||||
Hash string
|
Hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptIdentity identifies one configured prompt and its descriptive version label.
|
// PromptIdentity identifies one configured prompt.
|
||||||
type PromptIdentity struct {
|
type PromptIdentity struct {
|
||||||
ID string
|
ID string
|
||||||
Version *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptDefinition represents a configured prompt execution definition.
|
// PromptDefinition represents a configured prompt execution definition.
|
||||||
type PromptDefinition struct {
|
type PromptDefinition struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Version *string `yaml:"version"`
|
|
||||||
DefaultProfile string `yaml:"default_profile"`
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
Inputs []PromptInput `yaml:"inputs"`
|
Inputs []PromptInput `yaml:"inputs"`
|
||||||
Templates []PromptMessageTemplate `yaml:"templates"`
|
Templates []PromptMessageTemplate `yaml:"templates"`
|
||||||
OutputFormat OutputFormat `yaml:"output_format"`
|
|
||||||
Validation OutputContract `yaml:"validation"`
|
Validation OutputContract `yaml:"validation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptInspection is the resolved result of exact prompt inspection.
|
// PromptInspection is the resolved result of exact prompt inspection.
|
||||||
type PromptInspection struct {
|
type PromptInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
DefaultProfileID string
|
DefaultProfileID string
|
||||||
Inputs []PromptInput
|
Inputs []PromptInput
|
||||||
@@ -175,7 +169,6 @@ type PromptInput struct {
|
|||||||
type PromptMessageTemplate struct {
|
type PromptMessageTemplate struct {
|
||||||
Role string `yaml:"role"`
|
Role string `yaml:"role"`
|
||||||
Content string `yaml:"content"`
|
Content string `yaml:"content"`
|
||||||
ContentFile string `yaml:"content_file"`
|
|
||||||
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +182,6 @@ type Backend struct {
|
|||||||
SmokeTests *BackendSmokeTestConfig
|
SmokeTests *BackendSmokeTestConfig
|
||||||
ConcurrencyLimit int
|
ConcurrencyLimit int
|
||||||
QueueCapacity int
|
QueueCapacity int
|
||||||
QueueCapacitySet bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendCapacityPolicy describes normalized run and generation capacity for
|
// BackendCapacityPolicy describes normalized run and generation capacity for
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ type ExecutionEvent struct {
|
|||||||
RunID string
|
RunID string
|
||||||
Time time.Time
|
Time time.Time
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
ProfileID string
|
ProfileID string
|
||||||
BackendID string
|
BackendID string
|
||||||
Model string
|
Model string
|
||||||
|
|||||||
@@ -48,11 +48,10 @@ type RequirementIssue struct {
|
|||||||
|
|
||||||
// RequirementInspection contains the inventories discovered for one prompt.
|
// RequirementInspection contains the inventories discovered for one prompt.
|
||||||
type RequirementInspection struct {
|
type RequirementInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
ProfileID string
|
||||||
ProfileID string
|
Inputs []InputRequirement
|
||||||
Inputs []InputRequirement
|
Variables []VariableRequirement
|
||||||
Variables []VariableRequirement
|
Complete bool
|
||||||
Complete bool
|
Issues []RequirementIssue
|
||||||
Issues []RequirementIssue
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,6 @@ func qualifyResourceID(id, namespace string) (string, bool) {
|
|||||||
return namespace + "/" + id, true
|
return namespace + "/" + id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourceNamespaceFromID(id string) (string, bool) {
|
|
||||||
if !validResourceID(id) || !strings.Contains(id, "/") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
namespace, _, _ := strings.Cut(id, "/")
|
|
||||||
return namespace, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidResourceID reports whether id is an unqualified resource ID or one
|
// IsValidResourceID reports whether id is an unqualified resource ID or one
|
||||||
// valid namespace/local-ID pair.
|
// valid namespace/local-ID pair.
|
||||||
func IsValidResourceID(id string) bool { return validResourceID(id) }
|
func IsValidResourceID(id string) bool { return validResourceID(id) }
|
||||||
|
|||||||
@@ -86,25 +86,3 @@ func TestQualifyResourceID(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResourceNamespaceFromID(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
id string
|
|
||||||
namespace string
|
|
||||||
wantOK bool
|
|
||||||
}{
|
|
||||||
{name: "qualified", id: "team/item", namespace: "team", wantOK: true},
|
|
||||||
{name: "same-prefix namespace stays distinct", id: "team-extra/item", namespace: "team-extra", wantOK: true},
|
|
||||||
{name: "unqualified", id: "item", wantOK: false},
|
|
||||||
{name: "malformed", id: "team/sub/item", wantOK: false},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
got, ok := resourceNamespaceFromID(test.id)
|
|
||||||
if got != test.namespace || ok != test.wantOK {
|
|
||||||
t.Fatalf("resourceNamespaceFromID(%q) = (%q, %t), want (%q, %t)", test.id, got, ok, test.namespace, test.wantOK)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ func NormalizeSessionID(raw string) (string, error) {
|
|||||||
if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength {
|
if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength {
|
||||||
return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength)
|
return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength)
|
||||||
}
|
}
|
||||||
return normalized, nil
|
return strings.Clone(normalized), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package domain
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
||||||
@@ -65,7 +65,6 @@ type SmokeTestTarget struct {
|
|||||||
BackendID string
|
BackendID string
|
||||||
ProfileID string
|
ProfileID string
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
DefaultSourceBackendID string
|
DefaultSourceBackendID string
|
||||||
SelectionSource string
|
SelectionSource string
|
||||||
Endpoint string
|
Endpoint string
|
||||||
@@ -113,24 +112,21 @@ type SmokeTestAttempt struct {
|
|||||||
type SmokeTestReason string
|
type SmokeTestReason string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SmokeTestReasonNone SmokeTestReason = ""
|
SmokeTestReasonNone SmokeTestReason = ""
|
||||||
SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable"
|
SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable"
|
||||||
SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured"
|
SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured"
|
||||||
SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable"
|
SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable"
|
||||||
SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing"
|
SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing"
|
||||||
SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized"
|
SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized"
|
||||||
|
SmokeTestReasonAuthenticationResponseLimitExceeded SmokeTestReason = "authentication_response_limit_exceeded"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CloneBackend returns a copy of a backend, including its mutable settings.
|
// CloneBackend returns a copy of a backend, including its mutable settings.
|
||||||
func CloneBackend(backend Backend) (Backend, error) {
|
func CloneBackend(backend Backend) Backend {
|
||||||
cloned := backend
|
cloned := backend
|
||||||
var err error
|
cloned.ExtraParams = jsonvalue.CloneTrusted(backend.ExtraParams).(map[string]any)
|
||||||
cloned.ExtraParams, err = jsonvalue.CopyMap(backend.ExtraParams)
|
|
||||||
if err != nil {
|
|
||||||
return Backend{}, err
|
|
||||||
}
|
|
||||||
cloned.SmokeTests = CloneBackendSmokeTestConfig(backend.SmokeTests)
|
cloned.SmokeTests = CloneBackendSmokeTestConfig(backend.SmokeTests)
|
||||||
return cloned, nil
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneBackendSmokeTestConfig returns an independent copy of optional metadata.
|
// CloneBackendSmokeTestConfig returns an independent copy of optional metadata.
|
||||||
@@ -154,106 +150,6 @@ func CloneBackendSmokeTestConfig(config *BackendSmokeTestConfig) *BackendSmokeTe
|
|||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneSmokeTestRequest returns a copy that owns its optional settings.
|
|
||||||
func CloneSmokeTestRequest(request SmokeTestRequest) (SmokeTestRequest, error) {
|
|
||||||
cloned := request
|
|
||||||
cloned.TransientRetries = cloneInt(request.TransientRetries)
|
|
||||||
if request.Execution == nil {
|
|
||||||
return cloned, nil
|
|
||||||
}
|
|
||||||
execution := *request.Execution
|
|
||||||
execution.Temperature = cloneFloat64(request.Execution.Temperature)
|
|
||||||
execution.MaxTokens = cloneInt(request.Execution.MaxTokens)
|
|
||||||
execution.TopP = cloneFloat64(request.Execution.TopP)
|
|
||||||
execution.TimeoutSeconds = cloneInt(request.Execution.TimeoutSeconds)
|
|
||||||
execution.ReasoningEffort = cloneString(request.Execution.ReasoningEffort)
|
|
||||||
var err error
|
|
||||||
execution.ExtraParams, err = jsonvalue.CopyMap(request.Execution.ExtraParams)
|
|
||||||
if err != nil {
|
|
||||||
return SmokeTestRequest{}, err
|
|
||||||
}
|
|
||||||
cloned.Execution = &execution
|
|
||||||
return cloned, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloneSmokeTestReport returns a copy with independent optional facts and maps.
|
|
||||||
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
|
|
||||||
cloned.Target.EffectiveSettings.ExtraParams, err = jsonvalue.CopyMap(report.Target.EffectiveSettings.ExtraParams)
|
|
||||||
if err != nil {
|
|
||||||
return SmokeTestReport{}, err
|
|
||||||
}
|
|
||||||
if report.Attempts != nil {
|
|
||||||
cloned.Attempts = make([]SmokeTestAttempt, len(report.Attempts))
|
|
||||||
for index, attempt := range report.Attempts {
|
|
||||||
cloned.Attempts[index] = attempt
|
|
||||||
cloned.Attempts[index].HTTPStatus = cloneInt(attempt.HTTPStatus)
|
|
||||||
cloned.Attempts[index].Facts = cloneSmokeTestFacts(attempt.Facts)
|
|
||||||
cloned.Attempts[index].Timing = cloneSmokeTestTiming(attempt.Timing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSmokeTestFacts(facts SmokeTestFacts) SmokeTestFacts {
|
|
||||||
facts.CredentialsSupplied = cloneBool(facts.CredentialsSupplied)
|
|
||||||
facts.EndpointReachable = cloneBool(facts.EndpointReachable)
|
|
||||||
facts.CredentialsAccepted = cloneBool(facts.CredentialsAccepted)
|
|
||||||
facts.ResponseReceived = cloneBool(facts.ResponseReceived)
|
|
||||||
facts.GenerationCompleted = cloneBool(facts.GenerationCompleted)
|
|
||||||
facts.TextReturned = cloneBool(facts.TextReturned)
|
|
||||||
facts.ExpectedTextMatched = cloneBool(facts.ExpectedTextMatched)
|
|
||||||
facts.StructuredOutputRequested = cloneBool(facts.StructuredOutputRequested)
|
|
||||||
facts.ValidJSON = cloneBool(facts.ValidJSON)
|
|
||||||
facts.SchemaMatched = cloneBool(facts.SchemaMatched)
|
|
||||||
return facts
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSmokeTestTiming(timing SmokeTestTiming) SmokeTestTiming {
|
|
||||||
timing.CapacityWait = cloneDuration(timing.CapacityWait)
|
|
||||||
timing.RequestDuration = cloneDuration(timing.RequestDuration)
|
|
||||||
timing.RetryDelay = cloneDuration(timing.RetryDelay)
|
|
||||||
timing.EvaluationDuration = cloneDuration(timing.EvaluationDuration)
|
|
||||||
return timing
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneBool(value *bool) *bool {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneDuration(value *time.Duration) *time.Duration {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneFloat64(value *float64) *float64 {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneInt(value *int) *int {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneString(value *string) *string {
|
func cloneString(value *string) *string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) {
|
func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) {
|
||||||
profile := "profile"
|
profile := "profile"
|
||||||
@@ -22,10 +19,7 @@ func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cloned, err := CloneBackend(backend)
|
cloned := CloneBackend(backend)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
*cloned.SmokeTests.DefaultProfile = "changed"
|
*cloned.SmokeTests.DefaultProfile = "changed"
|
||||||
*cloned.SmokeTests.Reachability.Endpoint = "changed"
|
*cloned.SmokeTests.Reachability.Endpoint = "changed"
|
||||||
*cloned.SmokeTests.Authentication.Kind = "changed"
|
*cloned.SmokeTests.Authentication.Kind = "changed"
|
||||||
@@ -41,75 +35,12 @@ func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloneSmokeTestRequestOwnsOptionalSettings(t *testing.T) {
|
func TestCloneBackendPreservesProviderMapPresence(t *testing.T) {
|
||||||
temperature := 0.25
|
if cloned := CloneBackend(Backend{}); cloned.ExtraParams != nil {
|
||||||
retries := 2
|
t.Fatalf("nil provider map became %#v", cloned.ExtraParams)
|
||||||
reasoning := "low"
|
|
||||||
request := SmokeTestRequest{
|
|
||||||
APIKey: "secret",
|
|
||||||
TransientRetries: &retries,
|
|
||||||
Execution: &ExecutionTargetOverride{
|
|
||||||
Temperature: &temperature,
|
|
||||||
ReasoningEffort: &reasoning,
|
|
||||||
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
cloned := CloneBackend(Backend{ExtraParams: map[string]any{"empty": map[string]any{}, "null": nil}})
|
||||||
cloned, err := CloneSmokeTestRequest(request)
|
if cloned.ExtraParams == nil || cloned.ExtraParams["empty"].(map[string]any) == nil || cloned.ExtraParams["null"] != nil {
|
||||||
if err != nil {
|
t.Fatalf("provider map presence changed: %#v", cloned.ExtraParams)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
*cloned.TransientRetries = 3
|
|
||||||
*cloned.Execution.Temperature = 0.5
|
|
||||||
*cloned.Execution.ReasoningEffort = "high"
|
|
||||||
cloned.Execution.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
|
||||||
|
|
||||||
if retries != 2 || temperature != 0.25 || reasoning != "low" {
|
|
||||||
t.Fatal("cloned request shared optional scalar pointers")
|
|
||||||
}
|
|
||||||
if got := request.Execution.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
|
||||||
t.Fatalf("source provider parameter changed through clone: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) {
|
|
||||||
applicable := true
|
|
||||||
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"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Facts: SmokeTestFacts{EndpointReachable: &reachable},
|
|
||||||
Attempts: []SmokeTestAttempt{{
|
|
||||||
HTTPStatus: &status,
|
|
||||||
Facts: SmokeTestFacts{EndpointReachable: &reachable},
|
|
||||||
Timing: SmokeTestTiming{CapacityWait: &wait},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cloned, err := CloneSmokeTestReport(report)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
*cloned.Applicable = false
|
|
||||||
*cloned.Facts.EndpointReachable = false
|
|
||||||
*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" {
|
|
||||||
t.Fatal("cloned report shared optional scalar pointers")
|
|
||||||
}
|
|
||||||
if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
|
||||||
t.Fatalf("source provider parameter changed through clone: %v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user