164 Commits

Author SHA1 Message Date
4f3212bfb6 Write release notes for v0.19.0 2026-11-06 01:20:09 +00:00
4faf43cc29 Remove completed audit and implementation roadmaps 2026-11-06 01:19:33 +00:00
c9663ec63c Protect retained metadata from custom JSON encoders 2026-11-06 01:15:55 +00:00
d8c98ac575 Record the responsibility simplification review 2026-11-06 01:01:14 +00:00
5ee602cc4b Record final validation and repair audit links 2026-11-05 20:19:06 +00:00
96c619f7d4 Update documentation for simplified responsibilities 2026-11-05 20:15:51 +00:00
b3b5ec3820 Verify ownership across execution and repair 2026-11-05 20:14:37 +00:00
5dd1418953 Simplify smoke report ownership 2026-11-05 20:12:40 +00:00
f5c900cb0a Share immutable prepared execution metadata 2026-11-05 20:11:26 +00:00
c5f3de13f3 Transfer owned provider settings during resolution 2026-11-05 20:09:17 +00:00
88725e79ab Detach captured backend and profile values 2026-11-05 20:07:55 +00:00
6c015a17fa Detach schemas without provider value limits 2026-11-05 20:06:37 +00:00
9463c9463a Migrate validator tests to prepared plans 2026-11-05 20:04:55 +00:00
1fbd175e53 Remove direct output validation 2026-11-05 20:01:28 +00:00
4b4ce43796 Prepare both smoke output validators 2026-11-05 20:00:33 +00:00
d78474a3bf Move renderer tests to captured templates 2026-11-05 19:59:34 +00:00
acaf2ccd8b Render only captured prompt templates 2026-11-05 19:57:55 +00:00
97e5739bfb Update tests for captured repository lookups 2026-11-05 19:57:13 +00:00
90f796aef7 Require captured repository lookups 2026-11-05 19:55:10 +00:00
0dec3dcec0 Build provider requests directly as payloads 2026-11-05 19:53:41 +00:00
448e64dc40 Use resolved targets for client routing 2026-11-05 19:52:48 +00:00
de46935a0e Plan the responsibility simplification sprint 2026-11-05 19:24:09 +00:00
c69703b2b6 Define the responsibility simplification roadmap 2026-11-05 19:14:48 +00:00
f660b6e0fc Record prepared validation ownership decision 2026-11-05 19:07:52 +00:00
5f56c65427 Record responsibility audit decisions and ownership guidance 2026-11-05 18:47:51 +00:00
cfa3a85308 Record the architectural responsibility audit 2026-11-05 18:10:01 +00:00
1ff6d778af Preserve operation ownership in terminal observations 2026-11-05 16:03:26 +00:00
3f93763c9c Record the completed audit remediation sprint 2026-11-05 16:00:39 +00:00
4aeb58f913 Avoid duplicating nested error causes 2026-11-05 16:00:39 +00:00
ac1e150168 Validate schemas without relying on source filenames 2026-11-05 15:59:30 +00:00
ff57dbf8b3 Preserve authentication cancellation at the response limit 2026-11-05 15:58:32 +00:00
beb18cc9d6 Clarify algorithmic cost of nested error projection 2026-11-05 15:49:32 +00:00
7203e5d6a1 Record observer integration audit finding 2026-11-05 15:40:18 +00:00
356563a6e5 Record audit remediation review findings 2026-11-05 14:59:41 +00:00
6673284312 Complete documentation reconciliation and validation 2026-11-05 14:37:12 +00:00
6f42e1e007 Retire the legacy capacity client wrapper 2026-11-05 14:35:10 +00:00
cee9bbc7b2 Remove redundant captured prompt fields 2026-11-05 14:34:05 +00:00
7db5572b62 Remove unused captured state and orphan helpers 2026-11-05 14:33:04 +00:00
a91bc1608b Test artifact hashes as opaque values 2026-11-05 14:31:14 +00:00
e7a3665443 Consolidate bundled catalog test expectations 2026-11-05 14:30:14 +00:00
59e8010623 Clarify credential inspection and protect request privacy 2026-11-05 14:28:45 +00:00
ada5377f32 Share generation attempt accounting 2026-11-05 14:27:28 +00:00
df208e132b Return generation attempt results as one value 2026-11-05 14:25:07 +00:00
8c0fb15727 Separate prompt and target capture 2026-11-05 14:24:19 +00:00
2e2fba4993 Separate final configuration record validation 2026-11-05 14:22:34 +00:00
68668cee56 Simplify diagnostic report ownership 2026-11-05 14:21:33 +00:00
f745b14b71 Move authentication probes into the outbound adapter 2026-11-05 14:20:04 +00:00
dbba1485a7 Select inventory namespaces before copying 2026-11-05 14:11:41 +00:00
7c435032b6 Bound repair feedback construction 2026-11-05 14:07:34 +00:00
e834e7d806 Add bounded diagnostic prefix encoding 2026-11-05 14:06:09 +00:00
a4901cbb0e Share requirement branch traversal 2026-11-05 14:04:58 +00:00
5dcfc2e953 Merge condition evidence without repeated copies 2026-11-05 14:03:45 +00:00
a02561d40f Centralize trusted JSON value cloning 2026-11-05 14:02:20 +00:00
6112ee8fb4 Merge provider defaults into one owned copy 2026-11-05 14:00:57 +00:00
0aceff8c08 Verify self-contained schema preparation and submission 2026-11-05 13:59:40 +00:00
cddb2b389c Check declared schema locations for external resources 2026-11-05 13:57:21 +00:00
e056cb1607 Share self-contained schema compilation 2026-11-05 13:56:03 +00:00
1e122f0473 Require validation preparation internally 2026-11-05 13:54:06 +00:00
2f0f5fa607 Detach prepared execution ownership from runners 2026-11-05 13:52:36 +00:00
7d500ee62f Clarify artifact reader ownership transfer 2026-11-05 13:51:29 +00:00
7be677c80b Bound authentication response recognition 2026-11-05 13:49:51 +00:00
1c075bd510 Identify built-in generation preparation failures 2026-11-05 13:48:16 +00:00
b8dc8a49a9 Retain successful response read failures 2026-11-05 13:46:56 +00:00
cd373ee46b Preserve owning categories in public errors 2026-11-05 13:45:00 +00:00
e881bbdcf4 Track the operation owning internal errors 2026-11-05 13:42:56 +00:00
106c808442 Use ConfigKit mapping values directly 2026-11-05 13:41:01 +00:00
4ec35f5ca6 Clarify local Go toolchain selection 2026-11-05 13:39:31 +00:00
6a1a32ae49 Plan the audit remediation sprint 2026-11-05 13:36:46 +00:00
e85ea2db35 Consolidate assessment recommendations into remediation roadmap 2026-11-05 13:15:09 +00:00
3ef2a9e18e Complete the remediation planning assessment 2026-11-05 12:48:29 +00:00
a85a6788b4 Record the first six remediation assessments 2026-11-05 12:38:10 +00:00
21fea46fb3 Plan the audit remediation assessment 2026-11-05 12:32:34 +00:00
62bf82a56b Add audit remediation roadmap 2026-11-05 04:37:26 +00:00
bd3dfa992c Record legacy capacity wrapper retirement decision 2026-11-05 04:31:14 +00:00
425f53a62e Record independent Go toolchain and consumer minimum policy 2026-11-05 04:28:41 +00:00
fcc94eb9e9 Record direct ConfigKit value handoff decision 2026-11-05 04:21:13 +00:00
95730a3c11 Record authentication response budget decision 2026-11-05 04:06:23 +00:00
c542b81666 Record self-contained schema decision and future bundling idea 2026-11-05 04:03:38 +00:00
e8e112186e Record ArtifactReader ownership decision 2026-11-05 03:48:04 +00:00
aa08d46ef6 Clarify audit remediation implementation cautions 2026-11-04 18:08:53 +00:00
668d09997a Consolidate audit findings and record open decisions 2026-11-04 18:05:43 +00:00
69203dcb12 Record dead code audit findings 2026-11-04 16:30:56 +00:00
3b760310a6 Record package boundary audit findings 2026-11-04 16:17:03 +00:00
b39460a507 Record behavioral equivalence audit findings 2026-11-04 16:06:17 +00:00
5177db0611 Record API and interface contract audit findings 2026-11-04 15:29:58 +00:00
31f1e6a89d Record test coverage and effectiveness audit findings 2026-11-04 15:11:58 +00:00
f7085d76f6 Record security audit findings 2026-11-04 14:50:24 +00:00
cf0bfd4d76 Record error handling audit findings 2026-11-04 14:12:57 +00:00
6b1323d1fe Record resource management audit findings 2026-11-04 14:02:50 +00:00
fb87fabc2e Record complexity and maintainability audit findings 2026-11-04 13:27:01 +00:00
5adc1c1e64 Record algorithmic efficiency audit findings 2026-11-04 13:18:32 +00:00
0d27252999 Record functional correctness audit findings 2026-11-04 13:12:28 +00:00
d9f6aad305 Adopt ReleaseKit v0.9.0 and refresh recovery guidance 2026-11-04 02:10:48 +00:00
fda0338134 Finalize v0.18.0 notes and retire completed plans 2026-11-04 00:33:44 +00:00
2b3d165ce2 Document the bundled OpenRouter configuration 2026-11-03 15:39:04 +00:00
43f55edb98 Test bundled OpenRouter smoke diagnostics 2026-11-03 15:36:26 +00:00
4950e1dee2 Test bundled profile request construction 2026-11-03 15:34:10 +00:00
d240364776 Test bundled configuration composition 2026-11-03 15:32:25 +00:00
6f57f2f30a Enable bundled OpenRouter configuration by default 2026-11-03 15:30:47 +00:00
2c50eca033 Add embedded OpenRouter configuration 2026-11-03 15:27:26 +00:00
ac44a4f325 Plan bundled OpenRouter configuration 2026-11-03 15:24:06 +00:00
9e05c3ac8b Clarify bundled profile composition and compatibility 2026-11-03 15:11:45 +00:00
2c83925c88 Plan inherited auto-router cost-tier profiles 2026-11-03 14:48:19 +00:00
e0cd63923d Set bundled OpenRouter smoke-test defaults 2026-11-03 14:40:58 +00:00
75204d6134 Set root identities and expand bundled profile plan 2026-11-03 14:26:50 +00:00
5ce6e01f27 Plan bundled OpenRouter backend and profiles 2026-11-03 13:11:54 +00:00
5cec25a40c Prepare v0.18.0 release documentation 2026-11-03 12:50:19 +00:00
855aae6daa Document recorded configuration normalization 2026-11-03 03:32:59 +00:00
43abbcb414 Test normalized configuration inspection and provenance 2026-11-03 03:31:37 +00:00
9f33fed823 Use resolved configuration for construction 2026-11-03 03:29:41 +00:00
7a98feb399 Record prompt field normalization 2026-11-03 03:28:31 +00:00
626134e5e1 Record backend and profile normalization 2026-11-03 03:27:17 +00:00
e7ed315671 Update ConfigKit to version 1.1.0 2026-11-03 03:24:54 +00:00
d7d9a7dd65 Target normalization release notes at v0.18.0 2026-11-03 03:23:28 +00:00
18f6a474e0 Plan recorded field normalization 2026-11-03 03:17:55 +00:00
fb7ebe6a6c Plan field-level configuration normalization 2026-11-03 03:10:04 +00:00
a0441fb2d1 Prepare v0.17.0 notes and retire completed roadmaps 2026-11-03 02:56:48 +00:00
32547e1e29 Fix the historical profile inheritance link 2026-11-03 02:52:16 +00:00
bcc9ebd2e0 Record the release validation link issue 2026-11-03 02:49:12 +00:00
c7d4318122 Update guidance for Go example tests 2026-11-03 02:31:35 +00:00
175140ae5a Remove the custom example release check 2026-11-03 01:38:30 +00:00
66b98831d3 Test the observation example in Go 2026-11-03 01:38:02 +00:00
3e3cc677fb Test the preparation example in Go 2026-11-03 01:37:44 +00:00
8896d8733b Test the execution example in Go 2026-11-03 01:37:16 +00:00
7bbdb55888 Plan the Go example testing migration 2026-11-03 01:30:30 +00:00
79dfd97afa Plan Go-based testing for maintained examples 2026-11-03 01:00:45 +00:00
995c309560 Adopt ReleaseKit v0.8.0 and saved-attempt recovery 2026-11-03 00:49:47 +00:00
ea4fec9b1b Move Promptkit to its vanity module path 2026-11-02 14:38:00 +00:00
1de3e1fe38 Keep the release runbook focused on Promptkit steps 2026-11-02 14:24:04 +00:00
061f01c081 Run the selected ReleaseKit tool through Go 2026-11-02 14:21:52 +00:00
937b9723d4 Focus release tests on Promptkit wiring 2026-11-02 14:20:28 +00:00
367af5feca Add hosted Gitea release delivery 2026-11-02 14:10:37 +00:00
fd73cd0662 Adopt ReleaseKit v0.7.0 and update recovery guidance 2026-11-02 14:04:42 +00:00
81a55bc62c Adopt ReleaseKit v0.6.0 and its new module path 2026-11-02 13:35:00 +00:00
2bc976de10 Clarify future message normalization opportunities 2026-11-02 13:22:36 +00:00
fc568aa0b2 Complete the ConfigKit resource migration 2026-11-02 13:00:27 +00:00
b0bfe857fe Document the consumer migration to identified resources 2026-11-02 12:58:39 +00:00
2ccbee3758 Document identified resource formats and interpretation 2026-11-02 12:57:20 +00:00
0eee3ec962 Update examples and benchmarks for identified resources 2026-11-02 12:56:05 +00:00
130c4cc447 Update diagnostic and observation contracts 2026-11-02 12:54:10 +00:00
f661176b06 Update public preparation and execution fixtures 2026-11-02 12:53:00 +00:00
3e67743fbf Update internal execution fixtures for identified resources 2026-11-02 12:51:26 +00:00
6b8eefd18b Update public configuration and inventory contracts 2026-11-02 12:50:58 +00:00
682cb84c82 Update configuration and engine fixtures for identified resources 2026-11-02 12:48:47 +00:00
dbc345e566 Verify identified resource composition and inheritance 2026-11-02 12:47:25 +00:00
afcfae2aea Preserve prompt origins while interpreting metadata 2026-11-02 12:47:10 +00:00
eaca50763b Interpret backend and profile settings without changing source values 2026-11-02 12:45:53 +00:00
cb83b8aca6 Include message normalization in future provenance review 2026-11-02 12:43:35 +00:00
0b026861a5 Revise configuration plans to preserve source provenance 2026-11-02 12:20:44 +00:00
da8496b45b Record future provenance-preserving normalization support 2026-11-02 12:11:44 +00:00
e01b23259a Remove public prompt version metadata 2026-11-02 04:04:35 +00:00
500b0aa1c8 Remove internal prompt version metadata 2026-11-02 04:03:39 +00:00
fd6e9742a1 Use explicit IDs in configuration source builders 2026-11-02 04:01:01 +00:00
a6f4d67e15 Preserve inspection redaction for resource lists 2026-11-02 03:58:49 +00:00
c3ce15a4ab Capture prompts from identified lists 2026-11-02 03:56:23 +00:00
8e8f61f53f Index and validate identified resources 2026-11-02 03:55:28 +00:00
b7565affe5 Preserve resource field presence in lists 2026-11-02 03:54:14 +00:00
aba890aa86 Define identified configuration resources 2026-11-02 03:53:09 +00:00
0b271267a0 Update configuration source controls 2026-11-02 03:52:25 +00:00
a4e97bf102 Adopt ConfigKit v1 and Go 1.27 2026-11-02 03:51:39 +00:00
6ae005ed3a Plan the ConfigKit v1 resource migration 2026-11-02 03:46:24 +00:00
07a55e45f2 Settle source builders on explicit-ID slices 2026-11-02 03:33:29 +00:00
2b3875008b Plan ConfigKit v1 and identified resource migration 2026-11-02 03:18:21 +00:00
a51d12d898 Correct collection profile inspection example 2026-10-18 14:42:10 +00:00
216 changed files with 6906 additions and 5045 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,31 +6,28 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,68 @@
# Promptkit v0.17.0
This release adopts the vanity module path `go.maximumdirect.net/promptkit`
and ConfigKit v1.0.0, standardizes configuration resources as identified lists,
and removes descriptive prompt-version metadata. These are intentional breaking
changes before v1. The minimum supported Go version increases from 1.26.0 to
1.27.0 for the ConfigKit v1 migration.
## Breaking Changes And Upgrade
Consumers upgrading from v0.16.0 should follow the
[configuration and API migration guide](../consumers/configuration-migration.md):
- Change Promptkit imports from `gitea.maximumdirect.net/eric/promptkit` to
`go.maximumdirect.net/promptkit`. Update packages that exchange Promptkit
types together; old-path and vanity-path types are distinct.
- Change ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0 and
adopt its renamed public inspection types and fields where used.
- Convert root and collection `prompts`, `profiles`, and `backends` from keyed
mappings to lists whose records contain an explicit `id`. Collections remain
keyed by namespace. Sibling inheritance now uses `extends: {id: parent}`;
later same-ID records replace lower records in full.
- Change `ProfileSource` and `BackendSource` calls to pass slices of
`ProfileConfig` and `BackendConfig`, setting each record's `ID`.
- Change source-root `configkit:` controls to `config:`. Imports are optional
unless marked `required: true`. Optional Promptkit specification headers now
use `version: 2` or `version: "2"`; headerless sources remain valid.
- Remove descriptive prompt `version` values and code consuming `PromptVersion`,
`PromptIdentity.Version`, or corresponding `prompt_version` output members.
Collection `release_version` labels remain available. Prompt hashes may change
across this migration.
After adapting Go imports, configuration, and API calls, use Go 1.27.0 or newer:
```sh
go get go.maximumdirect.net/promptkit@v0.17.0
go mod tidy
```
Earlier tags retain their original module declarations. No permanent local
replacement is needed for this cutover. See the
[framework format reference](../formats.md) for current configuration semantics
and the [maintained preparation example](../../examples/go-library/prepare/main.go)
for explicit composition, source-relative imports, and scoped discovery.
## Configuration Inspection And Execution
Configuration inspection and diffs expose ConfigKit's composed values and
provenance, including list-index paths. Promptkit applies domain normalization
and execution defaults to owned interpretations without replacing the source
origins needed for relative resources. Profile inspection and preparation expose
their interpreted or effective settings. See the
[migration guide](../consumers/configuration-migration.md#select-and-compose-the-configuration-you-need)
for the distinction and inspection-path updates.
## Maintainer Tooling
Release tooling now uses the module-selected ReleaseKit v0.8.0 through `go tool`,
with page-only Gitea delivery and saved-publication-attempt recovery. The
[release procedure](../release.md) describes publication and recovery.
The three maintained offline examples now have ordinary Go workflow tests,
included in the standard suite. Their separate Python runner and custom release
hook have been removed; Python is no longer a normal release prerequisite.
The optional release-wiring diagnostic remains Python. See the
[development guide](../development.md#maintainer-validation).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/docs/oss/attribution.md)

65
docs/releases/v0.18.0.md Normal file
View 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
View 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)

View File

@@ -126,7 +126,7 @@ cycle-safe, and resolved afresh for ordinary operations. Prepared execution
freezes the fully resolved target. The selected leaf ID remains public while 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.

View File

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

View File

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

View File

@@ -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) != "" {

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestObserveExample(t *testing.T) {
var output bytes.Buffer
if err := execute(&output); err != nil {
t.Fatal(err)
}
var got struct {
ProposalAttempts *int `json:"proposal_attempts"`
ProposalRetries *int `json:"proposal_transient_retries"`
UsageComplete *bool `json:"proposal_usage_complete"`
AssessmentAttempts *int `json:"assessment_attempts"`
AssessmentRetries *int `json:"assessment_transient_retries"`
Outcome string `json:"assessment_outcome"`
}
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
t.Fatalf("unexpected observation summary: %+v", got)
}
}

View File

@@ -1,6 +1,5 @@
prompts: 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:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
id: prepare
default_profile: support/fast
description: Prepare a prompt without contacting a model provider.
inputs:
- name: note
required: true
content_type: text/plain
messages:
- role: system
content: Summarize the note in one sentence.
- role: user
content_file: ../messages/note.md
output:
format: text
validation_mode: basic

View File

@@ -1,28 +1,11 @@
backends: 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

View File

@@ -1,4 +1,5 @@
# A later input intentionally replaces only the inherited profile timeout. # A later input replaces the profile with an inherited variant.
profiles: profiles:
fast: - id: fast
extends: {id: base}
timeout_seconds: 30 timeout_seconds: 30

View File

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

View File

@@ -0,0 +1,45 @@
package main
import (
"bytes"
"encoding/json"
"reflect"
"testing"
)
func TestPrepareExample(t *testing.T) {
var output bytes.Buffer
if err := run(t.Context(), ".", &output); err != nil {
t.Fatal(err)
}
var got struct {
PromptID string `json:"prompt_id"`
Profile string `json:"selected_profile"`
Model string `json:"model"`
MessageCount int `json:"message_count"`
Timeout int `json:"profile_timeout_seconds"`
Namespaces []string `json:"namespaces"`
Collections []map[string]json.RawMessage `json:"collections"`
ScopedPrompts []string `json:"scoped_prompts"`
ReviewPrompts []string `json:"review_prompts"`
}
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.PromptID != "support/prepare" || got.Profile != "support/fast" || got.Model != "offline-model" ||
got.MessageCount <= 0 || got.Timeout != 30 || !reflect.DeepEqual(got.Namespaces, []string{"review", "support"}) ||
!reflect.DeepEqual(got.ScopedPrompts, []string{"support/prepare"}) || !reflect.DeepEqual(got.ReviewPrompts, []string{"review/check"}) || len(got.Collections) != 2 {
t.Fatalf("unexpected preparation summary: %+v", got)
}
for index, want := range []struct{ namespace, release string }{{"review", ""}, {"support", "1.4.0"}} {
var namespace string
if err := json.Unmarshal(got.Collections[index]["namespace"], &namespace); err != nil || namespace != want.namespace {
t.Fatalf("collection namespace = %q, %v", namespace, err)
}
value, present := got.Collections[index]["release_version"]
var release *string
if !present || json.Unmarshal(value, &release) != nil || (index == 0 && release != nil) || (index == 1 && (release == nil || *release != want.release)) {
t.Fatalf("collection release = %s, present %t; want %q", value, present, want.release)
}
}
}

View File

@@ -4,10 +4,12 @@ import (
"context" "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)
} }

View File

@@ -0,0 +1,28 @@
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestRunExample(t *testing.T) {
var output bytes.Buffer
if err := run(t.Context(), ".", &output); err != nil {
t.Fatal(err)
}
var got struct {
Output string `json:"output"`
Valid *bool `json:"is_valid"`
Status string `json:"validation_status"`
Model string `json:"model"`
Tokens *int `json:"total_tokens"`
}
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Output != "Ada finished the migration review." || got.Valid == nil || !*got.Valid ||
got.Status != "passed" || got.Model != "offline-model" || got.Tokens == nil || *got.Tokens != 18 {
t.Fatalf("unexpected execution summary: %+v", got)
}
}

View File

@@ -1,6 +1,5 @@
prompts: 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:

View File

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

View File

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

@@ -1,18 +1,18 @@
module gitea.maximumdirect.net/eric/promptkit module go.maximumdirect.net/promptkit
go 1.26.0 go 1.27.0
require ( 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
View File

@@ -1,13 +1,13 @@
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0 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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &copy return &copy
@@ -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 {

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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