165 Commits

Author SHA1 Message Date
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
9178f3eb91 Repair stale documentation anchors for release 2026-10-17 14:35:52 +00:00
a70fd7bb65 Document the v0.16.0 release and migration 2026-10-17 14:33:22 +00:00
7091b51c87 Retire completed implementation and review roadmaps 2026-10-17 14:31:29 +00:00
8627c751a4 Clarify shared ownership of namespace views 2026-10-17 13:52:50 +00:00
6fff3d822c Detach version labels in prepared execution snapshots 2026-10-17 13:51:30 +00:00
a1f12f3185 Reuse origin metadata across configuration collections 2026-10-17 13:46:58 +00:00
165cef7aec Record configuration collections review findings 2026-10-17 13:37:52 +00:00
0221cd4610 Verify scoped collection behavior and complete roadmap 2026-10-17 06:39:24 +00:00
dfd8da843e Clarify collection contracts and migration docs 2026-10-17 06:35:56 +00:00
29f104fbc0 Add collection migration example and guide 2026-10-17 06:29:57 +00:00
8669294cf6 Add namespace and collection inventories 2026-10-17 06:25:14 +00:00
c9b623b374 Add scoped identity listings and backend inventory 2026-10-17 06:22:22 +00:00
15f79ce7b1 Apply namespace scopes to inspections and diagnostics 2026-10-17 06:17:16 +00:00
aa54b8d1c4 Add immutable namespace views 2026-10-17 06:14:09 +00:00
60c58260ec Use caller profile in inspection test 2026-10-17 06:13:07 +00:00
8dfe35a1f0 Add collection input helper 2026-10-17 06:09:52 +00:00
13b60a72ac Remove bundled catalog integration 2026-10-17 06:08:35 +00:00
ee40a1bdd2 Decouple application tests from bundled profiles 2026-10-17 06:05:27 +00:00
473fc1f6d7 Assemble collection resources by qualified ID 2026-10-17 06:03:28 +00:00
a10a9053b7 Enable shared configuration version checks 2026-10-17 05:56:54 +00:00
ec3245c97e Carry optional prompt labels through results 2026-10-17 05:54:32 +00:00
8cbb7c0daf Remove prompt version selectors 2026-10-17 05:47:14 +00:00
d40a010538 Store one prompt definition per ID 2026-10-17 05:41:21 +00:00
0cd833513c Pass effective paths through configuration loading 2026-10-17 05:33:03 +00:00
5e58b6944b Add shared resource naming primitives 2026-10-17 05:30:26 +00:00
0a85ac8cd1 Adopt ConfigKit v0.8.0 2026-10-17 05:29:24 +00:00
89af2a1281 Plan configuration collections implementation 2026-10-17 05:26:42 +00:00
683689a4f8 Consolidate collection and namespace roadmaps 2026-10-17 05:13:16 +00:00
ddda2f8767 Settle collection configuration decisions around simplicity 2026-10-16 13:50:40 +00:00
190ec69345 Retire unselected collection configuration proposals 2026-10-16 11:59:34 +00:00
b5866dd749 Align collection proposal with implemented ConfigKit capabilities 2026-10-16 11:51:18 +00:00
c7490b783b Propose collections built from mounted configuration fragments 2026-10-14 02:24:50 +00:00
c7d1b0dca7 Propose identity-based collection records for ConfigKit 2026-10-14 01:32:10 +00:00
9e5ed08635 Propose collection configuration using current ConfigKit 2026-10-14 01:15:13 +00:00
4bddbbe8f4 Record collection ownership and ConfigKit boundary decisions 2026-10-14 00:19:05 +00:00
1516631a30 Record accepted namespace scope and filtering decisions 2026-10-13 22:47:21 +00:00
cf997c2034 Explain namespace design recommendations and alternatives 2026-10-13 21:33:21 +00:00
a0ec67c256 Reserve slash for namespace-qualified identifiers 2026-10-13 21:29:36 +00:00
75891b6561 Document namespace and scoped lookup roadmap 2026-10-13 21:25:57 +00:00
5dc6370a97 Avoid redundant smoke report copying 2026-10-13 21:12:17 +00:00
4cda3674c4 Record redundant smoke report copying 2026-10-13 20:50:54 +00:00
5ea6f06a6d Report known zero usage when smoke tests do not dispatch 2026-10-13 20:32:24 +00:00
116bd0e39b Report actual inference smoke test destinations 2026-10-13 20:27:46 +00:00
208ff42915 Preserve caller cancellation in HTTP smoke probes 2026-10-13 20:22:06 +00:00
972a103a97 Record smoke test review findings 2026-10-13 19:59:18 +00:00
2f735aedf9 Verify smoke-test release candidate 2026-10-13 18:38:45 +00:00
f4c721d934 Document smoke-test diagnostics 2026-10-13 18:32:41 +00:00
b7a951685b Verify smoke-test integration boundaries 2026-10-13 18:24:57 +00:00
abb2461244 Expose the public smoke-test API 2026-10-13 18:16:34 +00:00
b268c55783 Integrate inference smoke diagnostics 2026-10-13 18:09:20 +00:00
f0182fb2c9 Assemble HTTP smoke-test reports 2026-10-13 17:48:21 +00:00
8d05b594e2 Add private inference smoke cases 2026-10-13 17:30:45 +00:00
edaaecc982 Add OpenRouter authentication diagnostics 2026-10-13 17:23:47 +00:00
2e81c9e61b Add the unauthenticated reachability probe 2026-10-13 17:12:31 +00:00
2b723760d1 Retain injected client diagnostic evidence 2026-10-13 17:05:40 +00:00
c5aed7a451 Carry evidence through built-in generation retries 2026-10-13 16:56:56 +00:00
d91afc61a4 Retain generation response evidence 2026-10-13 16:47:26 +00:00
6eeb0ee9c0 Capture destination-specific HTTP evidence 2026-10-13 16:38:29 +00:00
b9c232ee63 Adopt tagged diagnostic catalogs 2026-10-13 16:28:41 +00:00
7291d23587 Record RakestrawHome diagnostic catalog candidate 2026-10-13 16:21:20 +00:00
da1d196dc5 Record OpenRouter diagnostic catalog candidate 2026-10-13 16:19:32 +00:00
b0de49a87a Preserve exact smoke-test selections 2026-10-13 16:16:40 +00:00
27ff580dae Resolve smoke-test targets 2026-10-13 16:14:30 +00:00
ece764f19e Load backend smoke-test metadata 2026-10-13 15:53:27 +00:00
f91a02548e Add smoke diagnostic domain values 2026-10-13 15:36:10 +00:00
8fbd058b90 Plan smoke test diagnostics implementation 2026-10-13 15:21:58 +00:00
c8f9a4def9 Plan smoke test integration with locally tagged catalogs 2026-10-13 14:53:55 +00:00
d8b6c0b471 Make smoke test delivery self-contained in one sprint 2026-10-13 14:48:47 +00:00
52cd94fcd1 Finalize smoke test roadmap decisions and evidence contracts 2026-10-13 14:42:24 +00:00
f1f72dfe8f Define factual smoke test diagnostics roadmap 2026-10-13 14:22:35 +00:00
d4676bc870 Record smoke test design decisions and profile precedence 2026-10-13 13:33:12 +00:00
64769883ae Draft smoke test capability proposal 2026-10-13 00:33:21 +00:00
635674c066 Prepare v0.15.0 release documentation 2026-10-12 20:17:52 +00:00
50bc35516a Consolidate request validation parity assertions 2026-10-12 19:31:54 +00:00
18309f50db Record request validation review finding 2026-10-12 19:05:38 +00:00
60784e7875 Document request validation workflow 2026-10-12 17:47:04 +00:00
f3148bfe52 Verify request validation parity 2026-10-12 17:45:18 +00:00
f315620bf7 Add request validation API 2026-10-12 17:43:11 +00:00
97357d62b9 Plan request validation in focused stages 2026-10-12 17:35:25 +00:00
0c3651fceb Clarify request validation roadmap and CLI guidance 2026-10-12 17:26:50 +00:00
5a5d47d36e Define request preflight validation roadmap 2026-10-12 16:56:33 +00:00
221 changed files with 15776 additions and 2785 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
@@ -21,15 +21,23 @@ Run the maintained offline preparation example from the repository root:
go run ./examples/go-library/prepare go run ./examples/go-library/prepare
``` ```
It loads a repository-local prompt, supplies a programmatic profile and inline It composes two explicitly selected synthetic collections, imports
input, inspects the captured configuration, and prints deterministic source-relative prompt content, supplies an inline input, and prepares through
preparation metadata without contacting a provider or requiring credentials. a scoped view. The example also demonstrates collection inventories and
namespace-filtered discovery, without contacting a provider or requiring
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. Read the engine when source files change. Applications adapting the prior keyed
[example source](examples/go-library/prepare/main.go), the configuration or versioned-prompt format can consult the
[configuration migration guide](docs/consumers/configuration-migration.md).
See the [example source](examples/go-library/prepare/main.go), the
[Go package consumer guide](docs/consumers/pkg-promptkit.md), and the [Go package consumer guide](docs/consumers/pkg-promptkit.md), and the
[framework format reference](docs/formats.md) to build a consumer workflow. [framework format reference](docs/formats.md) to build a consumer workflow.
The consumer guide also covers explicit, live smoke diagnostics and interpreting
their factual reports; they are separate from the offline quickstart.
Contributors should start with the [development guide](docs/development.md). Contributors should start with the [development guide](docs/development.md).
The [architecture policy](docs/policy/architecture.md) defines the library The [architecture policy](docs/policy/architecture.md) defines the library
@@ -37,6 +45,26 @@ 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
diagnostics, configuration collections, scoped engine views, and discovery.
Upgrading consumers should also review the current identified-resource and
ConfigKit v1 changes in the [migration guide](docs/consumers/configuration-migration.md).
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
validation of concrete requests with `Engine.ValidateRequest`.
Consumers upgrading to `v0.12.0` should read the Consumers upgrading to `v0.12.0` should read the
[v0.12.0 release notes](docs/releases/v0.12.0.md) for the intentional ConfigKit [v0.12.0 release notes](docs/releases/v0.12.0.md) for the intentional ConfigKit
configuration and public construction API migration. configuration and public construction API migration.

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

@@ -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,12 +99,12 @@ 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{"v1": map[string]any{ "prompts": []any{map[string]any{"id": "workflow",
"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,
}}}, }},
} }
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("assembled workflow benchmark", config), Source: configkit.Mapping("assembled workflow benchmark", config),
@@ -113,7 +113,7 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
b.Fatal(err) b.Fatal(err)
} }
request := promptkit.RunRequest{ request := promptkit.RunRequest{
PromptID: "workflow", PromptVersion: "v1", Vars: map[string]string{"name": "Ada"}, PromptID: "workflow", Vars: map[string]string{"name": "Ada"},
} }
return assembledWorkflowFixture{ return assembledWorkflowFixture{
engine: engine, request: request, expectedMessage: expectedMessage, response: response, engine: engine, request: request, expectedMessage: expectedMessage, response: response,
@@ -127,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 != "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,25 +1,28 @@
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 identifies the maintained OpenRouter backend. // BackendOpenRouter is the conventional ID for an OpenRouter backend.
const BackendOpenRouter = backend.OpenRouterID const BackendOpenRouter = backend.OpenRouterID
// BackendRakestrawHome identifies the maintained Rakestrawhome backend. // BackendRakestrawHome is the conventional ID for a Rakestrawhome backend.
const BackendRakestrawHome = backend.RakestrawHomeID 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. Use raw ConfigKit Mapping inputs for // empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
// explicit null. BackendConfig has no stable JSON representation. // non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
// inputs for explicit null. BackendConfig has no stable JSON representation.
type BackendConfig struct { 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.
@@ -31,17 +34,55 @@ type BackendConfig struct {
QueueCapacity *int QueueCapacity *int
// ExtraParams supplies JSON-compatible provider request defaults. // ExtraParams supplies JSON-compatible provider request defaults.
ExtraParams map[string]any ExtraParams map[string]any
// SmokeTests supplies optional diagnostic defaults and HTTP probe settings.
SmokeTests *BackendSmokeTestConfig
} }
// BackendSource creates a labeled ConfigKit mapping from keyed backend values. // BackendSmokeTestConfig contains optional diagnostic settings associated with
// a backend. Nil nested fields are omitted by BackendSource.
type BackendSmokeTestConfig struct {
// DefaultProfile is the exact profile ID selected as the backend's
// diagnostic inference default. A supplied value must not be blank; the ID
// is resolved only when that default is selected.
DefaultProfile *string
// Reachability optionally selects a full HTTP destination for reachability
// diagnostics. A nil endpoint uses the effective backend endpoint.
Reachability *ReachabilityProbeConfig
// Authentication optionally declares a named authentication diagnostic.
// A supplied object requires Kind.
Authentication *AuthenticationProbeConfig
}
// ReachabilityProbeConfig configures an optional reachability destination.
type ReachabilityProbeConfig struct {
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
// Nil selects the effective backend endpoint; an explicit blank value is
// invalid.
Endpoint *string
}
// AuthenticationProbeConfig configures an optional named authentication
// diagnostic.
type AuthenticationProbeConfig struct {
// Kind selects the authentication adapter. Supported values are "none" and
// "openrouter_key"; surrounding whitespace and case are normalized.
Kind *string
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
// Nil selects the adapter's backend-relative destination; an explicit blank
// value is invalid.
Endpoint *string
}
// BackendSource creates a labeled ConfigKit mapping containing backend records
// in caller order. IDs are explicit and duplicates are left for loading to reject.
// It encodes fields without validating them; NewEngine validates the final // It encodes fields without validating them; NewEngine validates the final
// effective configuration after all selected source layers are combined. // effective configuration after all selected source layers are combined.
// Mapping captures a copy of supported input values at creation; unsupported // Mapping captures a copy of supported input values at creation; unsupported
// values fail when the source is loaded. // values fail when the source is loaded.
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source { func BackendSource(label string, backends []BackendConfig) configkit.Source {
definitions := make(map[string]any, len(backends)) definitions := make([]any, 0, len(backends))
for id, config := range backends { for _, config := range backends {
fields := make(map[string]any) fields := map[string]any{"id": config.ID}
if config.Endpoint != nil { if config.Endpoint != nil {
fields["endpoint"] = *config.Endpoint fields["endpoint"] = *config.Endpoint
} }
@@ -57,7 +98,31 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
if config.ExtraParams != nil { if config.ExtraParams != nil {
fields["extra_params"] = providerMapForMapping(config.ExtraParams) fields["extra_params"] = providerMapForMapping(config.ExtraParams)
} }
definitions[id] = fields if config.SmokeTests != nil {
smokeTests := make(map[string]any)
if config.SmokeTests.DefaultProfile != nil {
smokeTests["default_profile"] = *config.SmokeTests.DefaultProfile
}
if config.SmokeTests.Reachability != nil {
reachability := make(map[string]any)
if config.SmokeTests.Reachability.Endpoint != nil {
reachability["endpoint"] = *config.SmokeTests.Reachability.Endpoint
}
smokeTests["reachability"] = reachability
}
if config.SmokeTests.Authentication != nil {
authentication := make(map[string]any)
if config.SmokeTests.Authentication.Kind != nil {
authentication["kind"] = *config.SmokeTests.Authentication.Kind
}
if config.SmokeTests.Authentication.Endpoint != nil {
authentication["endpoint"] = *config.SmokeTests.Authentication.Endpoint
}
smokeTests["authentication"] = authentication
}
fields["smoke_tests"] = smokeTests
}
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,115 @@
package promptkit_test
import (
"context"
"fmt"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
underlay := configkit.Input{Source: configkit.Mapping("caller underlay", map[string]any{
"profiles": []any{map[string]any{"id": "gpt-sol-latest", "model": "underlay-model"}},
})}
underlayEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}}})
if err != nil {
t.Fatal(err)
}
underlayProfile, err := underlayEngine.InspectProfile(context.Background(), "gpt-sol-latest")
if err != nil || underlayProfile.EffectiveModelParams.Model != "underlay-model" || underlayProfile.EffectiveModelParams.BackendID != "" {
t.Fatalf("underlay replacement = %+v, %v", underlayProfile, err)
}
main := configkit.Input{Source: configkit.Mapping("caller main", map[string]any{
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}, map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
"profiles": []any{
map[string]any{"id": "gpt-sol-latest", "model": "main-model"},
map[string]any{"id": "custom", "extends": map[string]any{"id": "gpt-luna-latest"}, "model": "custom-model"},
map[string]any{"id": "auto", "backend": "other", "model": "other-model"},
},
"collections": map[string]any{"openrouter": map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": "https://collection.invalid/v1"}},
"profiles": []any{map[string]any{"id": "profile", "backend": "openrouter/local", "model": "collection-model"}},
}},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Main: &main}})
if err != nil {
t.Fatal(err)
}
inspect := func(id string) promptkit.ExecutionTarget {
t.Helper()
profile, err := engine.InspectProfile(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return profile.EffectiveModelParams
}
if target := inspect("gpt-sol-latest"); target.Model != "main-model" || target.BackendID != "" {
t.Fatalf("replacement = %+v", target)
}
if target := inspect("custom"); target.Model != "custom-model" || target.BackendID != "openrouter" || target.Endpoint != "https://local.invalid/v1" {
t.Fatalf("inherited = %+v", target)
}
if target := inspect("gpt-luna-latest"); target.Endpoint != "https://local.invalid/v1" || target.APIKeyEnv != "" {
t.Fatalf("backend replacement = %+v", target)
}
if target := inspect("openrouter/profile"); target.BackendID != "openrouter/local" || target.Model != "collection-model" {
t.Fatalf("collection = %+v", target)
}
for _, tier := range []string{"low", "medium", "high", "xhigh", "max"} {
target := inspect("auto-" + tier)
if target.BackendID != "other" || target.Model != "other-model" || !strings.Contains(fmt.Sprint(target.ExtraParams), "cost_tier:"+tier) || !strings.Contains(fmt.Sprint(target.ExtraParams), "id:auto-router") {
t.Errorf("tier %s = %+v", tier, target)
}
}
}
func TestBundledCompositionSequenceModes(t *testing.T) {
for _, test := range []struct {
name, mode, id string
wantErr bool
count int
}{
{"duplicate append", "append", "auto", true, 0},
{"distinct append", "append", "custom", false, 18},
{"replace", "replace", "custom", false, 1},
} {
t.Run(test.name, func(t *testing.T) {
input := configkit.Input{Source: configkit.Mapping("sequence", map[string]any{
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": test.mode}}},
"profiles": []any{map[string]any{"id": test.id, "model": "custom-model"}},
})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
if test.wantErr {
if err == nil {
t.Fatal("expected duplicate rejection")
}
return
}
if err != nil {
t.Fatal(err)
}
profiles, err := engine.ListProfiles(context.Background())
if err != nil || len(profiles) != test.count {
t.Fatalf("profiles = %v, %v", profiles, err)
}
})
}
}
func TestBundledCompositionRejectsStrictConflictsAndMissingBackends(t *testing.T) {
profile := configkit.Input{Source: configkit.Mapping("profile", map[string]any{"profiles": []any{map[string]any{"id": "auto", "model": "caller"}}})}
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile, profile}}}}})
if err == nil {
t.Fatal("strict group accepted duplicate records")
}
input := configkit.Input{Source: configkit.Mapping("replace backends", map[string]any{
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"backends"}, "mode": "replace"}}},
"backends": []any{map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
})}
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}); err == nil {
t.Fatal("missing bundled backend was accepted")
}
}

View File

@@ -0,0 +1,90 @@
package promptkit_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func bundledPromptConfig(client *http.Client) promptkit.Config {
input := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{"prompts": []any{map[string]any{
"id": "prompt", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "none"},
}}})}
return promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}
}
func TestBundledProfilePreparationAndExecution(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
engine, err := promptkit.NewEngine(bundledPromptConfig(nil), promptkit.WithLLMClient(client))
if err != nil {
t.Fatal(err)
}
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrProfileRequired) {
t.Fatalf("missing profile = %v", err)
}
temperature := 0.3
request := promptkit.RunRequest{PromptID: "prompt", ProfileID: "gpt-luna-latest", Execution: &promptkit.ExecutionTargetOverride{Temperature: &temperature}}
prepared, err := engine.PrepareExecution(context.Background(), request)
if err != nil {
t.Fatal(err)
}
details := prepared.Details()
if details.SelectedProfileID != "gpt-luna-latest" || details.EffectiveModelParams.Model != "~openai/gpt-luna-latest" || details.EffectiveModelParams.BackendID != "openrouter" || details.EffectiveModelParams.Temperature != temperature {
t.Fatalf("prepared target = %+v", details.EffectiveModelParams)
}
if _, err := engine.Run(context.Background(), request); err != nil {
t.Fatal(err)
}
if len(client.requests) != 1 || client.requests[0].Target.Model != "~openai/gpt-luna-latest" || client.requests[0].Target.BackendID != "openrouter" {
t.Fatalf("requests = %+v", client.requests)
}
}
func TestBundledProfileHTTPPayloads(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
for _, test := range []struct{ id, model, tier string }{
{"auto-low", "openrouter/auto", "low"}, {"auto-medium", "openrouter/auto", "medium"}, {"auto-high", "openrouter/auto", "high"}, {"auto-xhigh", "openrouter/auto", "xhigh"}, {"auto-max", "openrouter/auto", "max"},
{"auto", "openrouter/auto", ""}, {"free", "openrouter/free", ""}, {"gpt-luna-latest", "~openai/gpt-luna-latest", ""},
} {
t.Run(test.id, func(t *testing.T) {
var body map[string]any
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Header.Get("Authorization") != "Bearer synthetic-key" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
})
engine, err := promptkit.NewEngine(bundledPromptConfig(&http.Client{Transport: transport}))
if err != nil {
t.Fatal(err)
}
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ProfileID: test.id}); err != nil {
t.Fatal(err)
}
if body["model"] != test.model || body["extra_params"] != nil {
t.Fatalf("request = %+v", body)
}
if test.tier == "" {
if _, exists := body["plugins"]; exists {
t.Fatalf("unexpected plugins = %+v", body)
}
return
}
want := []any{map[string]any{"id": "auto-router", "cost_tier": test.tier}}
if !reflect.DeepEqual(body["plugins"], want) {
t.Fatalf("plugins = %#v, want %#v", body["plugins"], want)
}
})
}
}

View File

@@ -0,0 +1,124 @@
package promptkit_test
import (
"context"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
type rejectBundleTransport struct{}
func (rejectBundleTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("unexpected HTTP request")
}
func TestBundledOpenRouterOptionsAndInventory(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
for _, test := range []struct {
name string
options []promptkit.Option
enabled bool
}{
{"default", nil, true}, {"enabled", []promptkit.Option{promptkit.WithBundledOpenRouter(true)}, true},
{"disabled", []promptkit.Option{promptkit.WithBundledOpenRouter(false)}, false},
{"last disables", []promptkit.Option{promptkit.WithBundledOpenRouter(true), promptkit.WithBundledOpenRouter(false)}, false},
{"last enables", []promptkit.Option{promptkit.WithBundledOpenRouter(false), promptkit.WithBundledOpenRouter(true)}, true},
} {
t.Run(test.name, func(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: rejectBundleTransport{}}}, test.options...)
if err != nil {
t.Fatal(err)
}
profiles, err := engine.ListProfiles(context.Background())
if err != nil {
t.Fatal(err)
}
backends, err := engine.ListBackends(context.Background())
if err != nil {
t.Fatal(err)
}
prompts, err := engine.ListPrompts(context.Background())
if err != nil {
t.Fatal(err)
}
collections, err := engine.ListCollections(context.Background())
if err != nil {
t.Fatal(err)
}
want := 0
if test.enabled {
want = 17
}
if len(profiles) != want || len(backends) != boolCount(test.enabled) || len(prompts) != 0 || len(collections) != 0 {
t.Fatalf("inventories: %v %v %v %v", profiles, backends, prompts, collections)
}
if test.enabled {
wantProfiles := []string{"auto", "auto-high", "auto-low", "auto-max", "auto-medium", "auto-xhigh", "claude-fable-latest", "claude-haiku-latest", "claude-opus-latest", "claude-sonnet-latest", "free", "gemini-flash-latest", "gemini-pro-latest", "gpt-astra-latest", "gpt-luna-latest", "gpt-sol-latest", "gpt-terra-latest"}
if !reflect.DeepEqual(profiles, wantProfiles) || !reflect.DeepEqual(backends, []string{"openrouter"}) {
t.Fatalf("bundle inventory: %v %v", profiles, backends)
}
for id, model := range map[string]string{"auto": "openrouter/auto", "auto-max": "openrouter/auto", "gpt-luna-latest": "~openai/gpt-luna-latest", "free": "openrouter/free"} {
profile, err := engine.InspectProfile(context.Background(), id)
if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.BackendID != "openrouter" {
t.Fatalf("profile %s = %+v, %v", id, profile, err)
}
}
view, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(fmtSources(view.Sources()), "Promptkit bundled OpenRouter") {
t.Fatal("bundle source missing")
}
if explanation, found := view.Explain(configkit.Keys("backends").Index(0).Key("endpoint")); !found || !strings.Contains(explanation.Effective.YAML, "https://openrouter.ai/api/v1") || len(explanation.History) == 0 {
t.Fatalf("bundle explanation = %+v", explanation)
}
}
})
}
}
func boolCount(value bool) int {
if value {
return 1
}
return 0
}
func fmtSources(sources []configkit.SourceInfo) string {
var b strings.Builder
for _, source := range sources {
b.WriteString(source.Label)
}
return b.String()
}
func TestBundlePreservesCallerUnderlayBackingArray(t *testing.T) {
source := configkit.Mapping("caller", map[string]any{"profiles": []any{map[string]any{"id": "caller", "model": "model"}}})
groups := make([]configkit.Underlay, 1, 2)
groups[0] = configkit.Underlay{Inputs: []configkit.Input{{Source: source}}}
before := append([]configkit.Underlay(nil), groups...)
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: groups}}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(groups, before) || !reflect.DeepEqual(groups[:2][1], configkit.Underlay{}) {
t.Fatal("caller underlays were modified")
}
}
func TestDisabledBundleKeepsCallerOpenRouter(t *testing.T) {
input := configkit.Input{Source: configkit.Mapping("caller", map[string]any{"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}}})}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}
backends, err := engine.ListBackends(context.Background())
if err != nil || !reflect.DeepEqual(backends, []string{"openrouter"}) {
t.Fatalf("backends = %v, %v", backends, err)
}
}

View File

@@ -0,0 +1,127 @@
package promptkit_test
import (
"context"
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestBundledSmokeHTTPDefaults(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
var urls []string
var models []string
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
urls = append(urls, request.URL.String())
body := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
if request.Method == http.MethodPost {
var payload map[string]any
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
models = append(models, payload["model"].(string))
content := "PROMPTKIT_OK"
if _, structured := payload["response_format"]; structured {
content = `{"status":"ok"}`
}
encoded, err := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": content}}}})
if err != nil {
t.Fatal(err)
}
body = string(encoded)
} else if strings.HasSuffix(request.URL.Path, "/key") && request.Header.Get("Authorization") != "Bearer synthetic-key" {
t.Errorf("authentication header missing")
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
})
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatal(err)
}
before, _ := engine.ListProfiles(context.Background())
inspectionBefore, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
for _, kind := range []promptkit.SmokeTestKind{promptkit.SmokeTestReachability, promptkit.SmokeTestAuthentication, promptkit.SmokeTestTextInference, promptkit.SmokeTestStructuredOutput} {
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: kind, BackendID: "openrouter"})
if err != nil || report == nil || !report.Completed || report.Failure != "" {
t.Fatalf("%s report = %+v, %v", kind, report, err)
}
if kind == promptkit.SmokeTestAuthentication && (report.Facts.CredentialsAccepted == nil || !*report.Facts.CredentialsAccepted) {
t.Fatalf("authentication facts = %+v", report.Facts)
}
if kind == promptkit.SmokeTestTextInference || kind == promptkit.SmokeTestStructuredOutput {
if report.Target.ProfileID != "gpt-luna-latest" || report.Target.EffectiveSettings.Model != "~openai/gpt-luna-latest" {
t.Fatalf("target = %+v", report.Target)
}
}
}
wantURLs := []string{"https://openrouter.ai/api/v1", "https://openrouter.ai/api/v1/key", "https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions"}
if !reflect.DeepEqual(urls, wantURLs) || !reflect.DeepEqual(models, []string{"~openai/gpt-luna-latest", "~openai/gpt-luna-latest"}) {
t.Fatalf("urls %v, models %v", urls, models)
}
after, _ := engine.ListProfiles(context.Background())
if !reflect.DeepEqual(before, after) {
t.Fatalf("inventory changed: %v %v", before, after)
}
inspectionAfter, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
diff, err := inspectionBefore.Diff(inspectionAfter)
if err != nil || len(diff) != 0 {
t.Fatalf("inspection changed: %+v, %v", diff, err)
}
}
func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) {
replacement := configkit.Input{Source: configkit.Mapping("replacement", map[string]any{"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}}})}
replacementClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
replacementEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &replacement}}, promptkit.WithLLMClient(replacementClient))
if err != nil {
t.Fatal(err)
}
replacementReport, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
if err != nil || replacementReport.Target.ProfileID != "gpt-luna-latest" || replacementReport.Target.EffectiveSettings.Model != "replacement" {
t.Fatalf("replaced default = %+v, %v", replacementReport, err)
}
replacementClient.response.Content = "unexpected"
mismatch, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
if err != nil || mismatch == nil || !mismatch.Completed {
t.Fatalf("concluded mismatch = %+v, %v", mismatch, err)
}
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://example.invalid/v1", "smoke_tests": map[string]any{"default_profile": "custom"}}},
"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}, map[string]any{"id": "custom", "backend": "openrouter", "model": "custom-model"}},
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "free", "messages": []any{map[string]any{"role": "user", "content": "private application marker"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
})}
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithLLMClient(client))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
request promptkit.SmokeTestRequest
profile, model string
}{
{promptkit.SmokeTestRequest{BackendID: "openrouter"}, "custom", "custom-model"},
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt"}, "free", "openrouter/free"},
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt", ProfileID: "gpt-luna-latest"}, "gpt-luna-latest", "replacement"},
} {
test.request.Kind = promptkit.SmokeTestTextInference
report, err := engine.SmokeTest(context.Background(), test.request)
if err != nil || report.Target.ProfileID != test.profile || report.Target.EffectiveSettings.Model != test.model {
t.Fatalf("report = %+v, %v", report, err)
}
if strings.Contains(client.requests[len(client.requests)-1].Prompt.Messages[0].Content, "private application marker") {
t.Fatal("application content used by diagnostic")
}
}
}

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: map[string]any{"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,28 +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"},
}}, }},
"profiles": map[string]any{ "collections": map[string]any{"literal": map[string]any{
"base.v1": map[string]any{"backend": "local", "model": "default-model"}, "backends": []any{
"literal/id": map[string]any{"extends": "base.v1"}, map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "namespaced-private-sentinel"}},
}, map[string]any{"id": "child", "extends": map[string]any{"id": "local"}},
},
"profiles": []any{
map[string]any{"id": "base.v1", "backend": "local", "model": "default-model"},
map[string]any{"id": "id", "extends": map[string]any{"id": "base.v1"}},
},
}},
})}}}, })}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{ {Inputs: []configkit.Input{{Source: configkit.Mapping("operator", 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)
} }
@@ -40,24 +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("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("profiles", "literal/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() != "/profiles/literal~1id/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").Index(0).Key("extra_params").Key("token")
namespacedSecret, found := view.Explain(namespacedSecretPath)
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
}
inheritedSecretPath := configkit.Keys("collections", "literal", "backends").Index(1).Key("extra_params").Key("token")
inheritedSecret, found := view.Explain(inheritedSecretPath)
if !found || !inheritedSecret.Effective.Redacted || strings.Contains(inheritedSecret.Effective.YAML, "replacement-private-sentinel") {
t.Fatalf("inherited provider value was not redacted: (%+v, %t)", inheritedSecret, found)
}
metadata := fmt.Sprintf("%+v", view.Metadata().History(inheritedSecretPath))
if strings.Contains(metadata, "replacement-private-sentinel") || strings.Contains(metadata, "namespaced-private-sentinel") {
t.Fatal("metadata disclosed a sensitive provider value")
}
var effective bytes.Buffer 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)
@@ -66,7 +95,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
t.Fatalf("unexpected effective report: %s", effective.String()) t.Fatalf("unexpected effective report: %s", effective.String())
} }
var explanation bytes.Buffer var explanation bytes.Buffer
if err := view.WriteExplanation(&explanation, secretPath); err != nil || strings.Contains(explanation.String(), "private-sentinel") { if err := view.WriteExplanation(&explanation, namespacedSecretPath); err != nil || strings.Contains(explanation.String(), "namespaced-private-sentinel") {
t.Fatalf("secret explanation = (%q, %v)", explanation.String(), err) t.Fatalf("secret explanation = (%q, %v)", explanation.String(), err)
} }
@@ -93,6 +122,45 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
} }
} }
func TestConfigurationInspectionFailureDoesNotDiscloseSensitiveValues(t *testing.T) {
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
Source: configkit.Mapping("invalid backend", map[string]any{
"collections": map[string]any{"private": map[string]any{"backends": []any{
map[string]any{"id": "backend", "endpoint": "invalid endpoint", "extra_params": map[string]any{"token": "failure-private-sentinel"}},
}}},
}),
}}})
var loadErr *configkit.LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("invalid backend error = %v, want load error", err)
}
if text := fmt.Sprintf("%v %+v", err, loadErr.Diagnostics()); strings.Contains(text, "failure-private-sentinel") {
t.Fatalf("failure diagnostics disclosed a sensitive value: %s", text)
}
}
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
Source: configkit.Mapping("main", map[string]any{"version": "2"}),
}}})
if err != nil {
t.Fatalf("construct engine with accepted declaration: %v", err)
}
inspection, err := engine.InspectConfiguration()
if err != nil {
t.Fatalf("inspect configuration: %v", err)
}
path := configkit.Keys("version")
field, found := inspection.Explain(path)
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "2") {
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
}
var effective bytes.Buffer
if err := inspection.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "version") {
t.Fatalf("effective inspection = (%q, %v), want retained version", effective.String(), err)
}
}
type failingInspectionWriter struct{ err error } type failingInspectionWriter struct{ err error }
func (writer failingInspectionWriter) Write([]byte) (int, error) { return 0, writer.err } func (writer failingInspectionWriter) Write([]byte) (int, error) { return 0, writer.err }
@@ -117,7 +185,12 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
t.Fatalf("zero explanation writer = %v", err) t.Fatalf("zero explanation writer = %v", err)
} }
valid, err := promptkit.NewEngine(promptkit.Config{}) profile := configkit.Input{Source: configkit.Mapping("inspection test profile", map[string]any{
"profiles": []any{map[string]any{"id": "inspection-profile", "endpoint": "https://configured.example/v1", "model": "configured-model"}},
})}
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
}})
if err != nil { if err != nil {
t.Fatalf("construct engine: %v", err) t.Fatalf("construct engine: %v", err)
} }
@@ -130,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", "mistral-small-3", "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")
@@ -145,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")
@@ -156,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 {
@@ -187,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])
@@ -199,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", "1", "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,10 +7,27 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"gitea.maximumdirect.net/eric/ConfigKit" "go.maximumdirect.net/configkit"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog" "go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/filecatalog"
) )
// CollectionInput mounts source at the selected collection's configuration
// destination, collections/<namespace>, and enables ConfigKit imports for that
// input. An invalid namespace returns an error matching ErrInvalidConfig. The
// source is not read or validated until ConfigKit loads it. Callers may adjust
// the returned input, including its filesystem context or imports setting.
func CollectionInput(namespace string, source configkit.Source) (configkit.Input, error) {
if !domain.IsValidResourceNamespace(namespace) {
return configkit.Input{}, fmt.Errorf("%w: invalid collection namespace %q", ErrInvalidConfig, namespace)
}
return configkit.Input{
Source: source,
Destination: configkit.Keys("collections", namespace),
Imports: true,
}, nil
}
// DirectoryInputs selects the YAML files beneath an explicitly chosen host // DirectoryInputs selects the YAML files beneath an explicitly chosen host
// directory as one strict-composition group. The returned order is deterministic; // directory as one strict-composition group. The returned order is deterministic;
// it does not assign precedence among those files. Discovery is recursive over // it does not assign precedence among those files. Discovery is recursive over

View File

@@ -1,14 +1,16 @@
package promptkit_test package promptkit_test
import ( import (
"context"
"errors"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"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 {
@@ -16,6 +18,78 @@ type sourceDocument struct {
Second string `yaml:"second"` Second string `yaml:"second"`
} }
func TestCollectionInputValidatesWithoutReadingSource(t *testing.T) {
input, err := promptkit.CollectionInput("docs", configkit.File(filepath.Join(t.TempDir(), "not-read.yaml")))
if err != nil {
t.Fatal(err)
}
if input.Destination.String() != "/collections/docs" || !input.Imports {
t.Fatalf("collection input = %+v, want destination /collections/docs with imports enabled", input)
}
for _, namespace := range []string{"", " ", "docs/api"} {
if _, err := promptkit.CollectionInput(namespace, configkit.NoSource()); !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Errorf("namespace %q error = %v, want ErrInvalidConfig", namespace, err)
}
}
}
func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
directory := t.TempDir()
mainPath := filepath.Join(directory, "collection.yaml")
defaultsPath := filepath.Join(directory, "defaults.yaml")
if err := os.WriteFile(mainPath, []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(defaultsPath, []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
t.Fatal(err)
}
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
if err != nil {
t.Fatal(err)
}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
Main: &configkit.Input{
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []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"),
},
}})
if err != nil {
t.Fatalf("construct engine with native collection import: %v", err)
}
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
}
}
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
files := fstest.MapFS{
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
"defaults.yaml": {Data: []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
}
filesystem := configkit.NewFilesystem("embedded collection", files)
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
if err != nil {
t.Fatal(err)
}
input.Filesystem = &configkit.FilesystemContext{FS: &filesystem, Root: "."}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
Main: &configkit.Input{
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []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"),
},
}})
if err != nil {
t.Fatalf("construct engine with embedded collection import: %v", err)
}
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
}
}
func TestDirectoryInputsSelectsSortedFilesWithRootContext(t *testing.T) { func TestDirectoryInputsSelectsSortedFilesWithRootContext(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {

View File

@@ -1,6 +1,7 @@
package promptkit_test package promptkit_test
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
@@ -11,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) {
@@ -37,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
@@ -72,20 +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{"1": map[string]any{ "prompts": []any{map[string]any{
"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,
@@ -105,39 +110,68 @@ func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T)
} }
} }
func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
openRouterID, rakestrawhomeID := promptkit.BackendOpenRouter, promptkit.BackendRakestrawHome
openRouterEndpoint, rakestrawhomeEndpoint := "https://openrouter.example/v1", "https://inference.example/v1"
model := "configured-model"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
{ID: openRouterID, Endpoint: &openRouterEndpoint},
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
})},
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
})},
}}},
}})
if err != nil {
t.Fatalf("construct engine with explicitly configured provider IDs: %v", err)
}
for profileID, backendID := range map[string]string{
"openrouter-profile": openRouterID,
"rakestrawhome-profile": rakestrawhomeID,
} {
inspection, err := engine.InspectProfile(context.Background(), profileID)
if err != nil || inspection.EffectiveModelParams.BackendID != backendID {
t.Fatalf("inspect %q = (%+v, %v), want backend %q", profileID, inspection, err, backendID)
}
}
}
func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *testing.T) { func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *testing.T) {
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
"1": default_profile: application
default_profile: application messages:
messages: - role: user
- role: user content: Hello
content: Hello output:
output: format: text
format: text validation_mode: none
validation_mode: none
`)}, `)},
}, "config") }, "config")
if err != nil { if err != nil {
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
@@ -173,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) {
@@ -189,43 +223,95 @@ func TestPublicConstructionRejectsSuppliedInvalidMainInput(t *testing.T) {
} }
} }
func TestPublicConstructionCanOverlayMaintainedBackend(t *testing.T) { func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T) {
endpoint := "http://local-openrouter.example/v1" 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{{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
promptkit.BackendOpenRouter: {Endpoint: &endpoint}, {Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
})}}}}, {ID: backendID, Endpoint: &endpoint},
})},
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
{ID: "application-profile", Backend: &backendID, Model: &model},
})},
}}},
}}) }})
if err != nil { if err != nil {
t.Fatalf("construct engine with backend overlay: %v", err) t.Fatalf("construct engine with backend overlay: %v", err)
} }
inspection, err := engine.InspectProfile(context.Background(), "mistral-small-3") inspection, err := engine.InspectProfile(context.Background(), "application-profile")
if err != nil { if err != nil {
t.Fatalf("inspect catalog profile: %v", err) t.Fatalf("inspect configured profile: %v", err)
} }
if inspection.EffectiveModelParams.Endpoint != endpoint { if inspection.EffectiveModelParams.Endpoint != endpoint {
t.Fatalf("effective endpoint = %q, want %q", inspection.EffectiveModelParams.Endpoint, endpoint) t.Fatalf("effective endpoint = %q, want %q", inspection.EffectiveModelParams.Endpoint, endpoint)
} }
} }
func TestPreparationRejectsAmbiguousPromptVersion(t *testing.T) { func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
definition := func(content string) map[string]any { backendEndpoint := "https://backend.example/v1"
return map[string]any{ defaultProfile := "not-yet-listed"
"messages": []any{map[string]any{"role": "user", "content": content}}, reachabilityEndpoint := " https://probe.example/ready?check=1 "
"output": map[string]any{"format": "text", "validation_mode": "none"}, authenticationKind := " OPENROUTER_KEY "
} authenticationEndpoint := "https://probe.example/key?scope=current"
} source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
{
ID: "local",
Endpoint: &backendEndpoint,
SmokeTests: &promptkit.BackendSmokeTestConfig{
DefaultProfile: &defaultProfile,
Reachability: &promptkit.ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint},
Authentication: &promptkit.AuthenticationProbeConfig{
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
},
},
},
})
backendEndpoint = "https://mutated.example/v1"
defaultProfile = "mutated-profile"
reachabilityEndpoint = "https://mutated.example/ready"
authenticationKind = "none"
authenticationEndpoint = "https://mutated.example/key"
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Main: &configkit.Input{Source: configkit.Mapping("versions", map[string]any{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: source}}}},
"prompts": map[string]any{"hello": map[string]any{
"1": definition("first"), "2": definition("second"),
}},
})},
}}) }})
if err != nil { if err != nil {
t.Fatalf("construct engine: %v", err) t.Fatalf("construct engine with dangling diagnostic default: %v", err)
} }
if result, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "hello"}); result != nil || !errors.Is(err, promptkit.ErrPromptLoad) { inspection, err := engine.InspectConfiguration()
t.Fatalf("ambiguous preparation = (%#v, %v), want ErrPromptLoad", result, err) if err != nil {
t.Fatal(err)
}
var effective bytes.Buffer
if err := inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
contents := effective.String()
for _, want := range []string{
"https://backend.example/v1",
"not-yet-listed",
"https://probe.example/ready?check=1",
"openrouter_key",
"https://probe.example/key?scope=current",
} {
if !strings.Contains(contents, want) {
t.Errorf("effective configuration does not contain %q:\n%s", want, contents)
}
}
for _, unwanted := range []string{
"mutated.example",
"mutated-profile",
} {
if strings.Contains(contents, unwanted) {
t.Errorf("effective configuration unexpectedly contains %q:\n%s", unwanted, contents)
}
}
}
func TestPreparationSelectsSinglePromptDefinitionByID(t *testing.T) {
engine := newSimpleEngine(t, "selected")
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
if err != nil || prepared == nil || len(prepared.Messages) != 1 || prepared.Messages[0].Content != "selected" {
t.Fatalf("prepare prompt by ID = (%+v, %v)", prepared, err)
} }
} }

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,14 +60,14 @@ 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{"1": map[string]any{ "prompts": []any{map[string]any{"id": "prompt",
"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

@@ -5,8 +5,8 @@ import (
"reflect" "reflect"
"unicode/utf8" "unicode/utf8"
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "go.maximumdirect.net/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue" "go.maximumdirect.net/promptkit/internal/jsonvalue"
) )
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) { func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
@@ -25,7 +25,6 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
} }
return domain.RunRequest{ return domain.RunRequest{
PromptID: req.PromptID, PromptID: req.PromptID,
PromptVersion: req.PromptVersion,
ProfileID: req.ProfileID, ProfileID: req.ProfileID,
SessionID: req.SessionID, SessionID: req.SessionID,
APIKey: req.APIKey, APIKey: req.APIKey,
@@ -73,7 +72,6 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
} }
return &PreparedRun{ return &PreparedRun{
PromptID: prepared.PromptID, PromptID: prepared.PromptID,
PromptVersion: prepared.PromptVersion,
PromptHash: prepared.PromptHash, PromptHash: prepared.PromptHash,
SelectedProfileID: prepared.SelectedProfileID, SelectedProfileID: prepared.SelectedProfileID,
SelectedBackendID: prepared.SelectedBackendID, SelectedBackendID: prepared.SelectedBackendID,
@@ -100,7 +98,6 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
RawOutput: result.RawOutput, RawOutput: result.RawOutput,
Validation: fromDomainValidationResult(result.Validation), Validation: fromDomainValidationResult(result.Validation),
PromptID: result.PromptID, PromptID: result.PromptID,
PromptVersion: result.PromptVersion,
PromptHash: result.PromptHash, PromptHash: result.PromptHash,
SessionID: result.SessionID, SessionID: result.SessionID,
RenderedPromptHash: result.RenderedPromptHash, RenderedPromptHash: result.RenderedPromptHash,
@@ -211,6 +208,12 @@ func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain
} }
func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget { func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
return fromDomainExecutionTargetWithOwnedParams(target, copyAnyMap(target.ExtraParams))
}
// fromDomainExecutionTargetWithOwnedParams projects scalar settings and takes
// ownership of extraParams, which the caller has already copied.
func fromDomainExecutionTargetWithOwnedParams(target domain.ExecutionTarget, extraParams map[string]any) ExecutionTarget {
return ExecutionTarget{ return ExecutionTarget{
BackendID: target.BackendID, BackendID: target.BackendID,
Endpoint: target.Endpoint, Endpoint: target.Endpoint,
@@ -222,7 +225,7 @@ func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
ServiceTier: target.ServiceTier, ServiceTier: target.ServiceTier,
ReasoningEffort: target.ReasoningEffort, ReasoningEffort: target.ReasoningEffort,
APIKeyEnv: target.APIKeyEnv, APIKeyEnv: target.APIKeyEnv,
ExtraParams: copyAnyMap(target.ExtraParams), ExtraParams: extraParams,
} }
} }
@@ -252,7 +255,6 @@ func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInsp
} }
return &PromptInspection{ return &PromptInspection{
PromptID: inspection.PromptID, PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash, PromptHash: inspection.PromptHash,
DefaultProfileID: inspection.DefaultProfileID, DefaultProfileID: inspection.DefaultProfileID,
Inputs: inputs, Inputs: inputs,

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

@@ -0,0 +1,231 @@
# Configuration And API Migration
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
and removes prompt-version labels.
Promptkit 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
automatic migration tool. The [framework format reference](../formats.md) owns
the current configuration contract, and the root package GoDoc owns exact API
signatures and errors.
## Update Go Imports
Promptkit's module and public package import path is now
`go.maximumdirect.net/promptkit`, replacing
`gitea.maximumdirect.net/eric/promptkit`. Update application, library, test, and
generated imports, then select a release using the new declaration:
```sh
go get go.maximumdirect.net/promptkit@VERSION
go mod tidy
```
Replace `VERSION` with a release that declares the vanity module path.
Earlier tags, including v0.16.0, retain their original module declaration and
use the former import path. No permanent `replace` directive is needed for the
cutover. Source browser, licensing, and hosted-release links continue to use
the Forge repository URLs, independently of Go's module identifier.
The package name remains `promptkit`. The path change itself does not rename
exported APIs or change their behavior, but Go treats packages imported through
the two paths as distinct. Update dependent packages that exchange Promptkit
types together and review any old-path dependencies retained after tidying.
The configuration and API adaptations below are also part of this migration.
## Select And Compose The Configuration You Need
Supply each backend, profile, prompt, schema, and collection source that the
application intends to use. Promptkit does not discover directories or install
provider definitions implicitly. An application may select an explicit
collection input with `CollectionInput`:
```go
input, err := promptkit.CollectionInput(
"openrouter",
configkit.File("config/openrouter.yaml"),
)
if err != nil {
return err
}
engine, err := promptkit.NewEngine(promptkit.Config{
Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
},
})
```
Collection resource IDs are local IDs, while their runtime identities are
qualified. For example, a profile with `id: gemini-flash-latest` in collection
`openrouter` is selected as `openrouter/gemini-flash-latest`. Stored backend,
prompt-default, and other resource references must use their exact intended IDs;
Promptkit does not rewrite references when a source is mounted. `extends`
selects a sibling ID in the same list, not a cross-collection reference.
Review collisions and all references when moving resources into collections.
The optional source-root `version` header now accepts `2` or `"2"`; headerless
sources remain valid. A collection's `release_version` remains an optional
label. Prompt-version labels have been removed: choose one definition per ID
and store any application-specific release label outside Promptkit. In a
standalone imported prompt item, root `version` is a source header, not a label.
For example, change a version-keyed definition:
```yaml
prompts:
report:
"2.0":
messages:
- role: user
content: Review this report.
output:
format: text
validation_mode: none
```
to one identified definition:
```yaml
prompts:
- id: report
messages:
- role: user
content: Review this report.
output:
format: text
validation_mode: none
```
The same conversion applies to backends and profiles. Sibling inheritance uses
identity references, and a later same-ID record replaces the whole lower record:
```yaml
profiles:
- id: base
endpoint: https://example.invalid/v1
model: example-model
- id: fast
extends: {id: base}
timeout_seconds: 30
```
To customize a lower resource's fields, add a distinct inherited variant as
above; when replacing the same ID, supply the complete intended record. Generic
append, replace, imports, and inheritance are described by the
[ConfigKit v1 configuration contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
Source-root controls are now `config:`, not `configkit:`. Imports are optional
unless the selected import declares `required: true`.
Update ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0: its Go
types are incompatible with the old module path. Promptkit now requires Go
1.27.0 or newer. Source builders take ordered slices with explicit IDs:
```go
endpoint, model := "https://example.invalid/v1", "example-model"
base := "base"
source := promptkit.ProfileSource("application", []promptkit.ProfileConfig{
{ID: "base", Endpoint: &endpoint, Model: &model},
{ID: "fast", Extends: &base},
})
```
Replace former maps passed to `BackendSource` in the same way, setting each
`BackendConfig.ID`. Duplicates are retained for loading to reject. Resource
inspection paths now address list indexes, for example
`configkit.Keys("profiles").Index(0).Key("model")`. Configuration inspection
and diffs expose resolved values, recorded normalization and provenance.
`InspectProfile` additionally applies backend completion; request preparation
can apply further overrides, so the views are not promised to be identical.
Remove `PromptVersion` and `PromptIdentity.Version` uses and the corresponding
`prompt_version` JSON members. Prompt hashes are opaque equality values and may
change when the removed field no longer participates in hashing.
## Identity Mapping
The reviewed catalog identities have slash-free profile keys. Their documented
mapping is:
| Previous identity | Namespaced identity or action |
| --- | --- |
| Backend `openrouter` | `openrouter/default` |
| Backend `rakestrawhome` | `rakestrawhome/default` |
| Each OpenRouter catalog profile `p` | `openrouter/p`, retaining the exact local key `p` |
| Profile `rakestrawhome-gemma-4-31b` | `rakestrawhome/rakestrawhome-gemma-4-31b` |
| Built-in `promptkit-openrouter-standard` | No automatic replacement; optionally define caller profile `openrouter/standard` |
| Built-in `promptkit-openrouter-auto` | No automatic replacement; optionally define caller profile `openrouter/auto` |
These are conversion examples, not aliases or bundled resources. Mounting old
`/v3` assets does not update backend references, profile inheritance, prompt
defaults, or diagnostic defaults inside them. Edit those values to the exact
qualified IDs you chose.
For example, an optional caller-owned standard profile may extend a selected
Gemini profile in the same `openrouter` collection:
```yaml
collections:
openrouter:
profiles:
- id: standard
extends: {id: gemini-flash-latest}
```
An optional auto profile may instead select the exact backend and model values:
```yaml
collections:
openrouter:
profiles:
- id: auto
backend: openrouter/default
model: openrouter/auto
```
Neither optional profile name is a Promptkit contract. Keep provider model
strings and settings as selected by the application, including any meaningful
prefixes. If a backend declares diagnostic defaults, update them to exact
qualified IDs; an unselected diagnostic default is not automatically made
resolvable by migration.
## Prompt Selection API
Prompt selection is ID-only. Remove version selectors from calls to `Run`,
`Prepare`, `PrepareExecution`, `ValidateRequest`, `InspectRequirements`, and
`SmokeTest`. Remove the `PromptVersion` field from `RunRequest`,
`RequirementInspectionRequest`, and `SmokeTestRequest`; `InspectPrompt` now
has the form `InspectPrompt(ctx, promptID)`. For example, select the prompt
explicitly in the request rather than setting a version selector:
```go
request := promptkit.RunRequest{
PromptID: "support/report",
ProfileID: "support/standard",
}
_, err = engine.Run(ctx, request)
if err != nil {
return err
}
```
If the former format contained multiple versions under one ID, choose the one
definition the application should install. Promptkit no longer retains or
returns its former descriptive label.
The public inventory APIs can help an operator inspect a captured effective
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
identities, while `ListNamespaces` and `ListCollections` expose resource-bearing
namespaces and effective collection declarations. Inventory does not select
which configuration version the application should deploy.
## Deployment Selection Remains With The Application
Applications decide which checked-in, embedded, operator-supplied, or
programmatic inputs to compose for each deployment. They also decide when to
construct a replacement engine after changing those sources. Promptkit does
not fetch catalog updates, select provider availability, validate upstream
catalog release status, or automatically migrate arbitrary consumer files.
The maintained [offline collection example](../../examples/go-library/prepare/main.go)
shows explicit source selection, local imports, layering, a scoped view, and
operator inventory without a provider call.

View File

@@ -12,9 +12,21 @@ 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)
demonstrates explicit collection composition and inventory. Applications
adapting older provider or versioned-prompt configuration can use the
[configuration migration guide](configuration-migration.md) for the required
caller-owned choices.
The following Go fragments are illustrative and omit surrounding package, The following Go fragments are illustrative and omit surrounding package,
import, and error-handling code. Use the maintained examples for complete import, and error-handling code. Use the maintained examples for complete
programs. programs.
@@ -22,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.
@@ -39,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 }
@@ -63,6 +102,34 @@ new engine to observe configuration edits. See the [constructor GoDoc](../../eng
for the exact public contract and the [framework format reference](../formats.md) for the exact public contract and the [framework format reference](../formats.md)
for definition fields. for definition fields.
### Use Scoped Engine Views
Create a view when an application wants short request IDs to resolve within a
selected collection:
```go
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
Prompt: "support",
Profile: "support",
})
if err != nil { return err }
prepared, err := scoped.Prepare(ctx, promptkit.RunRequest{
PromptID: "answer",
ProfileID: "concise",
})
```
Execution, preparation, prompt/profile inspection, requirement inspection, and
smoke diagnostics qualify slashless caller selections with their respective
namespaces. Already-qualified IDs pass through unchanged; configured defaults
and references are not rewritten. Each call replaces the view's scope defaults,
including clearing a scope with an empty field, without changing the receiver
or rebuilding its captured configuration and execution collaborators.
Namespace syntax is checked when creating the view; resource existence is
checked when selected. Configuration inspection remains global. See
[`WithNamespaces`](../../namespaces.go) for the exact public contract.
## Supply Embedded Application Defaults ## Supply Embedded Application Defaults
Use `FilesystemInputs` for embedded defaults and `DirectoryInputs` for an Use `FilesystemInputs` for embedded defaults and `DirectoryInputs` for an
@@ -74,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{
@@ -86,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
@@ -97,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.
}, },
@@ -109,8 +178,8 @@ 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. A caller underlay can overlay catalog backend fields just as it is not needed. Backend and profile definitions are supplied by the caller;
can overlay profiles; 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,
then switch the application to that engine for subsequent work. Existing then switch the application to that engine for subsequent work. Existing
@@ -118,11 +187,15 @@ engines and prepared handles retain their captured definitions; schema and
request artifact resources still follow their runtime lifecycle. Inspection request artifact resources still follow their runtime lifecycle. Inspection
does not watch or reload sources automatically. does not watch or reload sources automatically.
## Discover Available Profiles And Prompts ## Discover Available Resources And Collections
Use [`Engine.ListProfiles`](../../listing.go) and Use [`Engine.ListProfiles`](../../listing.go),
[`Engine.ListPrompts`](../../listing.go) to discover exact identities from the [`Engine.ListPrompts`](../../listing.go), and
engine's final captured configuration, then inspect a selected prompt: [`Engine.ListBackends`](../../listing.go) to discover exact identities from the
engine's final captured configuration. A scoped view filters by its respective
namespace by default; pass `NamespaceNamed`, `NamespaceUnqualified`, or
`NamespaceAll` to override that listing filter without changing execution
scope. Returned IDs remain full identities and can be used for exact selection:
```go ```go
profiles, err := engine.ListProfiles(ctx) profiles, err := engine.ListProfiles(ctx)
@@ -141,7 +214,7 @@ if err != nil {
} }
if len(prompts) > 0 { if len(prompts) > 0 {
selected := prompts[0] selected := prompts[0]
inspection, err := engine.InspectPrompt(ctx, selected.ID, selected.Version) inspection, err := engine.InspectPrompt(ctx, selected.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -149,6 +222,12 @@ if len(prompts) > 0 {
} }
``` ```
Use [`Engine.ListNamespaces`](../../listing.go) to discover resource-bearing
namespaces across the entire captured configuration, independent of view
scopes. [`Engine.ListCollections`](../../listing.go) reports effective
collection declarations, including metadata-only entries; it does not infer
collection metadata from resource membership or source history.
These lists describe effective membership, not ConfigKit contribution history, These lists describe effective membership, not ConfigKit contribution history,
and do not promise that a profile is ready to execute. Use inspection for and do not promise that a profile is ready to execute. Use inspection for
details and preparation to resolve request resources. The linked method GoDoc details and preparation to resolve request resources. The linked method GoDoc
@@ -158,14 +237,15 @@ sensitive application data.
## Inspect The Captured Configuration ## Inspect The Captured Configuration
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
operator-selected report of effective configuration, source descriptions, operator-selected report of resolved configuration, source descriptions,
diagnostics, and path history. For a literal ID containing a slash, construct diagnostics, and path history. Configuration inspection uses list indexes in
the semantic path with `configkit.Keys`: the collection's nested path rather than qualified runtime IDs. For a profile
at index `i` in collection `team`, construct the path with `configkit.Keys`:
```go ```go
view, err := engine.InspectConfiguration() view, err := engine.InspectConfiguration()
if err != nil { return err } if err != nil { return err }
path := configkit.Keys("profiles", "team/summary", "model") path := configkit.Keys("collections", "team", "profiles").Index(i).Key("model")
explanation, found := view.Explain(path) explanation, found := view.Explain(path)
if found && explanation.Effective.Redacted { if found && explanation.Effective.Redacted {
// Apply the application's disclosure policy before sharing the report. // Apply the application's disclosure policy before sharing the report.
@@ -176,8 +256,10 @@ if found && explanation.Effective.Redacted {
the application decides whether those reports may be disclosed. Credential- the application decides whether those reports may be disclosed. Credential-
shaped provider keys are redacted, but arbitrary prompt and provider content shaped provider keys are redacted, but arbitrary prompt and provider content
is not necessarily safe to share. Inspection uses the engine's captured result is not necessarily safe to share. Inspection uses the engine's captured result
and does not reopen sources or resolve credentials. `InspectProfile` instead and does not reopen sources or resolve credentials. It retains effective values,
shows backend-completed execution settings for one profile, and `Prepare` or including recorded field normalization and queue defaulting, alongside source
provenance.
`InspectProfile` instead shows backend-completed execution settings for one profile, and `Prepare` or
`PreparedExecution.Details` shows request-specific rendered messages, inputs, `PreparedExecution.Details` shows request-specific rendered messages, inputs,
and validation resources. None of those views is a substitute for the others. and validation resources. None of those views is a substitute for the others.
@@ -208,14 +290,79 @@ for _, difference := range differences {
``` ```
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go) See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
and ConfigKit's [v0.7.0 effective-configuration diff guidance](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.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.
## Run Explicit Smoke Diagnostics
[`Engine.SmokeTest`](../../smoke_diagnostics.go) runs one explicit diagnostic
using a target resolved from the engine's captured configuration. It does not
execute the selected application prompt or its inputs, session, artifacts, or
output schema. Unlike the offline examples and default test suite, calling it
can contact the selected provider and use configured credentials. It is a
consumer operation, not an automatic readiness verdict or a release check.
For example, a caller can request the built-in text inference check and treat
its optional fact as an observation rather than assuming that a nil Go error
means success:
```go
report, err := engine.SmokeTest(ctx, promptkit.SmokeTestRequest{
Kind: promptkit.SmokeTestTextInference,
ProfileID: profileID,
APIKey: apiKey,
})
if err != nil {
if report != nil {
retainDisclosureSafeSummary(report)
}
return err
}
if report != nil {
matched := report.Facts.ExpectedTextMatched
switch {
case matched == nil:
// The comparison was not evaluated or is unknown/inapplicable.
case *matched:
// The private expected-text comparison matched.
default:
// It did not match; the operation may still have concluded normally.
}
}
```
A nil Go error means the diagnostic concluded, not that its finding was
positive. Fact pointers are tri-state: nil means unknown, not evaluated, or
inapplicable. Reports contain factual observations and effective settings, not
generated text or provider bodies; identifiers and settings can still be
sensitive. The
[`SmokeTest` GoDoc](../../smoke_diagnostics.go) owns selection precedence,
exact fields, timeout and retry behavior, supported transports, and disclosure
details.
Caller cancellation can return both an error and a partial report. Preserve the
context error identity and inspect any available observations before returning:
```go
report, err := engine.SmokeTest(ctx, request)
if report != nil {
retainDisclosureSafeSummary(report)
}
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
// The report may describe work completed before interruption.
}
return err
}
```
## Inspect A Prompt Before Preparation ## Inspect A Prompt Before Preparation
Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's
@@ -223,7 +370,7 @@ declared inputs and output workflow without creating placeholder inputs or
resolving a profile: resolving a profile:
```go ```go
inspection, err := engine.InspectPrompt(ctx, "meeting.summary", "") inspection, err := engine.InspectPrompt(ctx, "meeting.summary")
if err != nil { if err != nil {
return err return err
} }
@@ -284,6 +431,41 @@ conditional entries. Do not treat an empty partial inventory as proof that no
input bindings are needed. See the method's [GoDoc](../../requirements.go) for input bindings are needed. See the method's [GoDoc](../../requirements.go) for
the exact classifications, supported syntax, ownership, and error contract. the exact classifications, supported syntax, ownership, and error contract.
## Validate A Concrete Request
Use [`Engine.ValidateRequest`](../../engine.go) when the application has a
complete `RunRequest` and needs to know whether that concrete invocation passes
preparation:
```go
request := promptkit.RunRequest{
PromptID: "meeting.summary",
Vars: map[string]string{"audience": "project leads"},
Inputs: map[string]promptkit.ArtifactRef{
"note": promptkit.Inline("Synthetic meeting notes"),
},
}
if err := engine.ValidateRequest(ctx, request); err != nil {
return err
}
```
Unlike `InspectRequirements`, this checks supplied values, the selected template
branches, and request bindings through the existing preparation path. It checks
only this invocation, does not aggregate errors, and loads every supplied input
according to ordinary preparation behavior, even if a branch does not use it.
It makes no model call and returns no prepared snapshot, but artifact and schema
resource I/O and its time and memory costs remain. Success does not certify
provider acceptance or later execution success. See the method's GoDoc for its
exact error and context behavior.
Scriptorium can map the returned error to its own diagnostics and exit status.
Its `validate` and `run` commands should construct equivalent requests for
equivalent caller inputs so their preparation behavior agrees. Separate
subprocess calls prepare independently; an opaque `PrepareExecution` handle
cannot be transferred from `validate` to `run`. Use `PrepareExecution` when one
process and engine must execute the exact prepared snapshot.
## Prepare Without Model Execution ## Prepare Without Model Execution
[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile, [`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile,
@@ -304,8 +486,11 @@ prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
The maintained The maintained
[offline preparation example](../../examples/go-library/prepare/main.go) [offline preparation example](../../examples/go-library/prepare/main.go)
shows a complete runnable setup with a prompt file, in-memory profile, and shows a complete runnable setup with a prompt file, in-memory profile, and
inline input. Exact request requirements and prepared-result fields belong to inline input. It demonstrates validation and preparation with the same request
the [`RunRequest` and `PreparedRun` GoDoc](../../types.go). to make the two operations concrete; this performs preparation twice and is
illustrative, not a requirement to validate before `Prepare` or `Run`. Exact
request requirements and prepared-result fields belong to the
[`RunRequest` and `PreparedRun` GoDoc](../../types.go).
## Prepare Now And Execute The Same Snapshot Later ## Prepare Now And Execute The Same Snapshot Later
@@ -475,7 +660,7 @@ copy, and credential behavior. The
interact with prompt definitions, layered profiles, built-ins, schemas, and interact with prompt definitions, layered profiles, built-ins, schemas, and
framework defaults. framework defaults.
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with keyed For programmatic profiles, use [`ProfileSource`](../../profiles.go) with identified
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit `ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null. zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
@@ -488,100 +673,32 @@ 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 `id` and `version` fields into keyed `profiles` and `prompts` Move resource IDs into identified `backends`, `profiles`, and `prompts` lists;
maps; convert `base_profile` to ConfigKit `extends`. IDs and references are each prompt ID identifies one installed definition without a version label.
exact literal keys, without trimming. A supplied zero, false, or empty string Convert `base_profile` to ConfigKit `extends: {id: parent}`. For the current
is now distinguishable from omission; null can clear or suppress an inherited collection identity mapping, ID-only prompt selection, and required
field according to the [field table](../formats.md#defaults-and-overrides). caller-owned choices, see the
Maps merge recursively across inheritance and layers. `{}` does not clear an [configuration migration guide](configuration-migration.md). Exact current
inherited map: use null to clear the whole map. At request time, a literal format, reference, inheritance, and presence semantics belong to the
`extra_params["extends"]` is ordinary provider data, not an inheritance [framework format reference](../formats.md). Prompt text and static templates
instruction; a nonempty request ExtraParams map replaces configured params. are captured at construction, not automatically reloaded between runs. Replace
Changing a model or backend does not clear unrelated explicit fields, though the engine to pick up changed definitions or message files.
backend-derived defaults are applied after inheritance to the final backend.
Invalid final definitions, including unused ones, now fail `NewEngine`; only
request-dependent failures remain at preparation or execution. Prompt text
and static templates are captured at construction, not automatically reloaded
between runs. Replace the engine to pick up changed definitions or message
files. The [format reference](../formats.md) owns exact field and presence
rules; the maintained examples show complete runnable workflows.
### Choose A Profile Ownership Model ### Choose A Profile Ownership Model
Choose the profile source that matches the application's continuity needs: Supply backend and profile definitions through the configuration sources your
application selects, alongside the default-on bundled OpenRouter inventory.
- Select `promptkit-openrouter-standard` or `promptkit-openrouter-auto` when Conventional backend ID constants are names only. Define or replace the model,
the maintained literal name is the desired consumer contract. Their current endpoint, credentials lookup, and settings that your application
selections are in the [format reference](../formats.md#built-in-profile-catalog). intends to support. A profile may inherit a sibling profile by its local key in
- Select an external catalog profile directly when the application accepts that the same root or collection list; its backend reference uses the exact
profile's catalog volatility. The selected external inventories are linked configured identity. Caller-selected source layers determine the final values. The
from the [format reference](../formats.md#external-profile-inventories). [format reference](../formats.md) describes the definition fields and
- Define a complete application-owned profile with an explicit backend or composition rules.
endpoint, model, and settings when the application owns those choices.
An application profile that inherits an external base follows later changes to
that base and fails if the base disappears; it is not a frozen copy. Promptkit
tests the catalog versions selected in its module graph. Independently advancing
a catalog dependency can remove an inherited base and produce an untested
combination. Pinning the resolved module graph preserves the embedded data for
a build, but cannot preserve provider availability. Running binaries neither
download catalog updates nor automatically replace withdrawn models. See the
[release policy](../policy/release.md#catalog-profile-compatibility) for the exact
cross-version guarantees.
### Alias A Built-In Profile
Give an application-owned profile ID a built-in base when prompts should select
the application ID while inheriting the built-in target. The child can override
only the setting it owns:
```go
profile := promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
"weather-light": {
Extends: &standardID,
ReasoningEffort: &high,
},
})
```
Place `profile` in a ConfigKit underlay, with `standardID` set to
`"promptkit-openrouter-standard"` and `high` to `"high"`. Select
`weather-light` in a prompt or `RunRequest.ProfileID`; it remains the
reported selected profile. Definition IDs and inheritance references are exact
keys. If the base is an external profile, apply the volatility guidance
above when deciding whether to use an application-owned complete definition
instead.
### Use The Rakestrawhome Built-In Profile
Set `RAKESTRAWHOME_INFERENCE_API_KEY` in the application environment, then
select `rakestrawhome-gemma-4-31b` as an ordinary profile ID. For example, a
prepared result identifies the selected built-in through
`BackendRakestrawHome`:
```go
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
PromptID: "meeting.summary",
ProfileID: "rakestrawhome-gemma-4-31b",
Inputs: inputs,
})
if err != nil {
return err
}
if prepared.SelectedBackendID != promptkit.BackendRakestrawHome {
return fmt.Errorf("unexpected backend %q", prepared.SelectedBackendID)
}
```
The maintained catalog supplies this backend, and callers may overlay its
effective configuration through a later ConfigKit source. Direct request credentials
and runtime endpoint overrides remain supported under their ordinary GoDoc and
format contracts.
### Inspect A Profile Before Prompt Work ### Inspect A Profile Before Prompt Work
@@ -651,8 +768,8 @@ it and shared backend identity or capacity policy is unnecessary:
```go ```go
endpoint, model := "http://localhost:8000/v1", "example-model" endpoint, model := "http://localhost:8000/v1", "example-model"
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{ profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
"local-summary": {Endpoint: &endpoint, Model: &model}, {ID: "local-summary", Endpoint: &endpoint, Model: &model},
}) })
// Add configkit.Input{Source: profile} to a caller underlay. // Add configkit.Input{Source: profile} to a caller underlay.
``` ```
@@ -667,11 +784,13 @@ identity, endpoint, and concurrency limit:
```go ```go
backendID, model := promptkit.BackendLocal, "example-model" backendID, model := promptkit.BackendLocal, "example-model"
backend := promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{ localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2), localBackend.ID = backendID
backend := promptkit.BackendSource("local backend", []promptkit.BackendConfig{
localBackend,
}) })
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{ profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
"local-summary": {Backend: &backendID, Model: &model}, {ID: "local-summary", Backend: &backendID, Model: &model},
}) })
// Add both sources as inputs in a caller underlay. // Add both sources as inputs in a caller underlay.
``` ```
@@ -682,13 +801,14 @@ capacity of 1024.
#### Configure A Complete Backend #### Configure A Complete Backend
Use a keyed `BackendConfig` value for authentication, extra request parameters, an Use an identified `BackendConfig` value for authentication, extra request parameters, an
explicit queue capacity, a custom ID, or multiple local endpoints: explicit queue capacity, a custom ID, or multiple local endpoints:
```go ```go
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0 endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendConfig{ backend := promptkit.BackendSource("GPU backend", []promptkit.BackendConfig{
"local-gpu": { {
ID: "local-gpu",
Endpoint: &endpoint, APIKeyEnv: &keyName, Endpoint: &endpoint, APIKeyEnv: &keyName,
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting, ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
ExtraParams: map[string]any{"provider_option": "enabled"}, ExtraParams: map[string]any{"provider_option": "enabled"},
@@ -697,7 +817,7 @@ backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendCo
``` ```
Place `backend` in a caller underlay and select `"local-gpu"` from a Place `backend` in a caller underlay and select `"local-gpu"` from a
`ProfileConfig.Backend` pointer or a keyed file profile. All backend `ProfileConfig.Backend` pointer or an identified file profile. All backend
contributions use the same ConfigKit layering rules, including overlays of contributions use the same ConfigKit layering rules, including overlays of
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go) maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
defines exact omission and construction behavior. defines exact omission and construction behavior.
@@ -727,7 +847,7 @@ backend := promptkit.BackendConfig{
} }
``` ```
The pointer distinguishes an explicit zero from omission. Use keyed The pointer distinguishes an explicit zero from omission. Use identified
`BackendConfig` literals inside `BackendSource` for programmatic definitions. `BackendConfig` literals inside `BackendSource` for programmatic definitions.
Capacity belongs to one engine and the selected backend ID; endpoint-only Capacity belongs to one engine and the selected backend ID; endpoint-only
profiles and custom backends without a configured limit remain unrestricted. profiles and custom backends without a configured limit remain unrestricted.

View File

@@ -43,13 +43,13 @@ Before starting work:
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. | | Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). | | Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. | | Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
| Maintained external catalogs or built-ins | The [internal source document](internal/sources.md), [framework format reference](formats.md), [testing policy](policy/testing.md), both catalog module repositories, each catalog's release procedure, and the selected module versions in `go.mod`. | | Caller-supplied configuration sources | The [internal source document](internal/sources.md), [framework format reference](formats.md), and [testing policy](policy/testing.md). |
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. | | Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. | | Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
| Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. | | Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. |
| Maintained example | The [preparation](../examples/go-library/prepare/main.go), [execution](../examples/go-library/run/main.go), and [observation](../examples/go-library/observe/main.go) examples, [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). | | Maintained example | The [preparation](../examples/go-library/prepare/main.go), [execution](../examples/go-library/run/main.go), and [observation](../examples/go-library/observe/main.go) examples, [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). |
| Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. | | Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. |
| Licensing, dependencies/catalog updates, copied material, or attribution | The [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution entries](oss/attribution.md); inspect the selected upstream source's notices. | | Licensing, dependencies, copied material, or attribution | The [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution entries](oss/attribution.md); inspect the selected upstream source's notices. |
| Release preparation or publication, including release notes | The [release policy](policy/release.md), [release procedure](release.md), and [OSS discoverability rules](policy/oss.md#discoverability). | | Release preparation or publication, including release notes | The [release policy](policy/release.md), [release procedure](release.md), and [OSS discoverability rules](policy/oss.md#discoverability). |
For cross-cutting changes, follow every applicable row. Do not create For cross-cutting changes, follow every applicable row. Do not create
@@ -67,7 +67,6 @@ the maintained examples when they are affected:
go test ./... go test ./...
go vet ./... go vet ./...
gofmt -w path/to/changed.go gofmt -w path/to/changed.go
python3 scripts/check-examples.py
git diff --check git diff --check
``` ```
@@ -77,12 +76,12 @@ describes its requirements and recovery. For an optional diagnostic of a
committed candidate without publication, run `./scripts/releasekit.sh check committed candidate without publication, run `./scripts/releasekit.sh check
VERSION`; this is not a required pre-publication step. VERSION`; this is not a required pre-publication step.
The standalone `python3 scripts/check-examples.py` command checks semantic The example workflows are covered by the Go suite; use
expectations for the three offline consumer examples. The focused `go test ./examples/go-library/...` to run them separately during development.
`python3 scripts/test-release.py` command exercises the release tooling against The focused `python3 scripts/test-release.py` command exercises the release
disposable local repositories; use it when changing that tooling, not as a tooling against disposable local repositories; use it when changing that
recurring release gate. Tests and examples require no provider credentials or tooling, not as a recurring release gate. Tests and examples require no provider
live inference calls. credentials or live inference calls.
Ordinary validation can run on intended uncommitted work. Release validation Ordinary validation can run on intended uncommitted work. Release validation
uses committed candidate contents and the selected local Go installation. uses committed candidate contents and the selected local Go installation.

View File

@@ -6,38 +6,98 @@ 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 has keyed top-level `backends`, `profiles`, and A configuration document may contain top-level `backends`, `profiles`, and
`prompts` mappings. Their keys are literal IDs, not filename-derived identities. `prompts` lists, plus a `collections` mapping of namespaced resource groups.
Prompt versions are a second keyed mapping. Keys and references must be Each resource has an explicit literal `id`, not a filename-derived identity.
nonblank and remain exact: Promptkit does not trim or re-key them. Old embedded IDs and references must be nonblank and remain exact: Promptkit does not trim or
`id`, `version`, and `base_profile` fields are invalid. Unknown fields are re-key them. Unknown fields are errors. Promptkit installs a default-on bundled
errors. OpenRouter underlay; callers still explicitly select a profile.
```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
"1.0.0": default_profile: local-summary
default_profile: local-summary messages:
messages: - role: user
- role: user content: Summarize this note.
content: Summarize this note. output:
output: format: text
format: text validation_mode: none
validation_mode: none collections:
support:
release_version: "1.4.0"
backends:
- id: offline
endpoint: https://example.invalid/v1
profiles:
- id: base
backend: support/offline
model: offline-model
- id: fast
extends: {id: base}
timeout_seconds: 30
prompts:
- id: answer
default_profile: support/fast
messages:
- role: user
content: Answer the selected caller input.
output:
format: text
validation_mode: none
``` ```
## Source Headers, Collections, And Resource IDs
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
`"2"` or strict decimal integer `2`; null and other values are invalid. This
header is distinct from a collection's `release_version` and ConfigKit's own
software version. ConfigKit checks and consumes accepted headers on non-main
inputs; a declaration on the main input remains visible in explicit
configuration inspection. Headerless inputs still undergo ordinary final-value
validation. ConfigKit owns generic import and source-history behavior.
For a standalone prompt item, a root `version` is only a source header, not
prompt metadata.
The root resource lists contain unqualified IDs. Each `collections` entry
has a namespace key and may contain `release_version` plus the same three
resource lists. Collection resource IDs are local keys; their runtime IDs
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
namespace and local key must be nonblank and slash-free, and references must
name their intended exact ID; mounting a source does not rewrite references.
For example, the `support/fast` profile above stores `support/offline` as its
backend ID. `extends: {id: base}` refers to a sibling in the same resource
list. It is not a qualified or cross-collection reference.
`release_version` is an optional opaque collection label. Absent or null means
no label; an explicitly empty string is retained. It does not select a resource
or change execution. Runtime lookups
use the captured effective resource indexes. `InspectConfiguration` is an
explicit operator view of resolved settings and provenance. Selected field
normalizations and the derived queue default are recorded in that tree, with
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
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
[maintained preparation example](../examples/go-library/prepare/main.go). For
breaking identity and prompt-selection changes, see the
[configuration migration guide](consumers/configuration-migration.md).
Every effective backend, profile, and prompt definition is validated when Every effective backend, profile, and prompt definition is validated when
`NewEngine` constructs the engine, even if it is never selected. A profile may `NewEngine` constructs the engine, even if it is never selected. A profile may
be partial: an endpoint and model need only be complete after request overrides be partial: an endpoint and model need only be complete after request overrides
@@ -52,36 +112,35 @@ Schema and request artifact reads still happen during preparation. Use
`InspectConfiguration` for an explicit effective-configuration or provenance `InspectConfiguration` for an explicit effective-configuration or provenance
report, `InspectProfile` for backend-completed profile settings, and preparation report, `InspectProfile` for backend-completed profile settings, and preparation
for request-specific rendered details. For identity discovery before inspection, for request-specific rendered details. For identity discovery before inspection,
see the consumer guide's [profile and prompt listing](consumers/pkg-promptkit.md#discover-available-profiles-and-prompts). see the consumer guide's [resource and collection listing](consumers/pkg-promptkit.md#discover-available-resources-and-collections).
## Prompt Definitions ## Prompt Definitions
The `prompts` mapping selects a literal prompt ID, then a string version key: The `prompts` list contains definitions selected by their literal IDs:
```yaml ```yaml
prompts: prompts:
meeting.summary: - id: meeting.summary
"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}}' inputs:
inputs: - name: note
- name: note required: true
required: true content_type: text/plain
content_type: text/plain description: Meeting note to summarize.
description: Meeting note to summarize. messages:
messages: - role: system
- role: system content: Return a concise summary.
content: Return a concise summary. cache_control:
cache_control: type: ephemeral
type: ephemeral ttl: 1h
ttl: 1h - role: user
- role: user content_file: ./summary.user.md
content_file: ./summary.user.md output:
output: format: markdown
format: markdown validation_mode: basic
validation_mode: basic repair_attempts: 0
repair_attempts: 0
``` ```
`messages` must be nonempty and `output` must be a valid mapping. Optional `messages` must be nonempty and `output` must be a valid mapping. Optional
@@ -89,9 +148,8 @@ prompts:
fields may be absent or cleared with null where appropriate. A null whole fields may be absent or cleared with null where appropriate. A null whole
definition, required role, output format, or validation mode is invalid. definition, required role, output format, or validation mode is invalid.
`required` and `repair_attempts` default to false and zero, respectively, and `required` and `repair_attempts` default to false and zero, respectively, and
are not nullable. A request that omits a prompt version succeeds only if that are not nullable. `InspectPrompt` selects the same captured definition without
prompt ID has exactly one effective version. `InspectPrompt` selects from the rendering or resolving a profile.
same captured definitions without rendering or resolving a profile.
### Inputs ### Inputs
@@ -156,36 +214,74 @@ last candidate and its failed validation result. Operational errors remain
errors. A request-level `OutputContract` replaces the whole configured one, errors. A request-level `OutputContract` replaces the whole configured one,
not individual fields; an empty request format defaults to `text`. not individual fields; an empty request format defaults to `text`.
## Built-In Backends ## Backend Definitions
The selected catalog modules contribute these backend definitions as the Backend definitions are supplied through caller-selected configuration
earliest underlay. Their settings can be overlaid by caller sources; these IDs sources. The conventional backend IDs are names only; PromptKit does not
are not reserved against configuration changes. install definitions for them.
| ID | Catalog endpoint | API-key environment name | Active generation limit |
| --- | --- | --- | ---: |
| `openrouter` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | 16 |
| `rakestrawhome` | `https://inference.ai.rakestrawhome.com/v1` | `RAKESTRAWHOME_INFERENCE_API_KEY` | 4 |
A backend definition may have `endpoint`, `api_key_env`, A backend definition may have `endpoint`, `api_key_env`,
`concurrency_limit`, `queue_capacity`, and `extra_params`. The final endpoint `concurrency_limit`, `queue_capacity`, `extra_params`, and `smoke_tests`. The
is required and must be an absolute HTTP(S) URL with a host, no user final endpoint is required and must be an absolute HTTP(S) URL with a host, no
information, query, or fragment. An omitted or null API-key environment name user information, query, or fragment. An omitted or null API-key environment
means no lookup. Omitted/null concurrency is zero (unlimited); a supplied name means no lookup. Omitted/null concurrency is zero (unlimited); a supplied
negative value is invalid. For positive concurrency, omitted/null queue negative value is invalid. For positive concurrency, omitted/null queue
capacity defaults to 1024. An explicit queue capacity, including zero, capacity defaults to 1024. An explicit queue capacity, including zero,
requires positive concurrency and must be nonnegative. Unlimited concurrency requires positive concurrency and must be nonnegative. Unlimited concurrency
has no queue. Provider maps contain JSON-compatible values; null entries are has no queue. Provider maps contain JSON-compatible values; null entries are
ordinary JSON null. Unknown fields, including a raw `api_key`, fail. ordinary JSON null. Unknown fields, including a raw `api_key`, fail.
`smoke_tests` is optional backend metadata for explicit diagnostic operations.
It may contain `default_profile`, `reachability`, and `authentication`:
```yaml
backends:
- id: openrouter
endpoint: https://openrouter.ai/api/v1
smoke_tests:
default_profile: application-profile
reachability:
endpoint: https://status.example/ready?check=1
authentication:
kind: openrouter_key
endpoint: https://openrouter.ai/api/v1/key
```
An omitted or null `smoke_tests` block disables these metadata defaults.
`default_profile` is preserved as an exact, nonblank profile ID; its existence
is checked only if an inference diagnostic selects that default. Omitted/null
`default_profile` means there is no inference default. Omitted/null
`reachability` or its endpoint uses the effective backend endpoint. An explicit
reachability endpoint is trimmed and must be an absolute HTTP(S) URL with a
host; unlike the backend base endpoint, its path and query are allowed. Null
means fallback, while an explicit blank value is invalid.
Omitted/null `authentication` means no authentication adapter is declared. A
non-null object requires `kind`, which is trimmed and case-normalized; the
recognized values are `none` and `openrouter_key`. Its omitted/null endpoint
uses the adapter's backend-relative destination (`openrouter_key` appends
`/key` to the effective backend base); an explicit endpoint follows the same
full-URL rules as reachability. `none` declares authentication inapplicable.
These values are configuration only: engine construction does not contact a
backend.
These fields are resolved through the ordinary ConfigKit source layers before
Promptkit validates the final effective values. Sibling inheritance can overlay
individual nested fields; a later same-ID resource replaces the whole record.
An explicit null can clear the whole `smoke_tests` block or one of its nested
objects. An endpoint set to null uses
its documented fallback, while an explicit blank endpoint is invalid. The
metadata does not change ordinary generation credential requirements or
provider settings.
## Profile Definitions ## 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
@@ -193,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
``` ```
@@ -236,58 +332,88 @@ 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 catalog definitions first, Promptkit-owned definitions `NewEngine` composes the bundled OpenRouter underlay first by default, then
next, then caller underlays in their given order, with an optional main input caller underlays in their given order, followed by an
last. Later tiers overlay earlier ones. Inputs within one underlay group are optional main input. Later layers overlay earlier ones. Inputs within one
strictly composed: conflicting contributions are errors, not priority underlay group are strictly composed: conflicting contributions are errors,
signals. The directory helpers discover `.yaml` and `.yml` files recursively not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
in sorted order, but that order does not give one file higher precedence. in sorted order, but that order does not give one file higher precedence.
An explicitly selected missing/unreadable root fails; an empty root contributes An explicitly selected missing/unreadable root fails; an empty root contributes
nothing. Imports are disabled by these helpers unless the caller enables them 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.
Backends, profiles, and prompts all follow these same source rules. A later Root backends, profiles, and prompts, as well as collection declarations and
profile or backend can change individual fields of a lower definition. A their resource lists, follow these same source rules. `CollectionInput`
request `ProfileID` takes precedence over a prompt `default_profile`; if mounts one explicitly selected source at a collection destination and enables
ConfigKit imports for that input; it does not discover or select sources.
A later same-ID record replaces the lower definition in full. Use a distinct
inherited sibling when a variant should retain its parent's fields.
A request `ProfileID` takes precedence over a prompt `default_profile`; if
neither is selected, preparation fails. Exact profile inspection selects one 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 another keyed profile; presence, explicit null, and recursively Each child inherits a surviving sibling in the same root or collection list;
merged maps determine the final fields. References are literal IDs, including collection inheritance cannot cross namespaces. Parents are ordinary validated
IDs containing punctuation. There is no Promptkit-specific depth limit or resources, and profiles may remain partial until request completion. Presence, explicit
second base-chain resolver. Missing references and cycles fail at construction. null, and recursively merged maps determine the final fields. Other resource
For generic inheritance and layer mechanics, consult references are exact IDs and use their qualified form when they select a
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit). collection resource. There is no Promptkit-specific depth limit or second
base-chain resolver. Missing references and cycles fail at construction. For
## Built-In Profile Catalog generic inheritance and layer mechanics, consult
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
Promptkit contributes two opt-in definitions; neither is selected by default:
| ID | Current definition |
| --- | --- |
| `promptkit-openrouter-standard` | Extends the selected OpenRouter catalog's `gemini-flash-latest` profile. |
| `promptkit-openrouter-auto` | Selects backend `openrouter` and model `openrouter/auto`. |
Select one through request `ProfileID` or prompt `default_profile`.
Caller layers may overlay these definitions or their catalog bases. The
[release policy](policy/release.md#catalog-profile-compatibility) owns their
cross-version compatibility boundary.
## External Profile Inventories
The selected catalog modules, not this document, own their profile inventories:
- [OpenRouter catalog v3.20260930.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/tag/v3.20260930.0/catalog/profiles)
- [Rakestrawhome catalog v3.20260930.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/tag/v3.20260930.0/catalog/profiles)
These are tested local data, not a promise of live provider availability.
The [release policy](policy/release.md#catalog-profile-compatibility) owns
the compatibility boundary.
## Schemas ## Schemas

View File

@@ -45,6 +45,45 @@ precedence over the backend default. Only the name reaches prepared metadata;
the environment value is read just before the provider call and is never added the environment value is read just before the provider call and is never added
to the JSON body. to the JSON body.
## Explicit Smoke Diagnostics
Promptkit's explicit smoke diagnostics are separate consumer operations; they
are not part of ordinary `Run`, the offline examples, or release validation.
With the built-in client, inference diagnostics use the same configured client
and `/chat/completions` request as generation. Their content cases are private
to Promptkit and are not application prompt, schema, or provider protocol
extensions. The public
[`Engine.SmokeTest` GoDoc](../../smoke_diagnostics.go) owns request selection,
report facts, and injected-client behavior.
HTTP-only diagnostics use the configured built-in HTTP client. Reachability is
one unauthenticated `GET` to the selected diagnostic URL; an observed response
counts as reachable even when its status is non-2xx. Authentication behavior
is selected from backend metadata, not inferred from the host. The
`openrouter_key` adapter makes one `GET` to `/key` relative to the effective
backend base, unless configuration supplies a full diagnostic endpoint (see
the [backend format reference](../formats.md#backend-definitions)). It may send
the available bearer credential, but can dispatch without one. A 401 is
recognized as rejection. A 200 is recognized as acceptance only when its JSON
object has a `data` object containing string `label`, Boolean `is_free_tier`,
and numeric `usage`; unrelated fields and account limits are ignored. This is
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),
not validation of the full account document. Other statuses and unrecognized
200 responses do not establish acceptance.
Diagnostic HTTP evidence describes the last request observed at Promptkit's
RoundTripper boundary and any response associated with it. It includes the
selected destination without URL user information, response status, and
credential presence when observed. Redirects can change the last observed
destination or strip credentials. This evidence does not prove that bytes
reached a peer or reveal dispatch hidden inside a custom transport. Diagnostic
reports do not retain headers, credentials, provider
bodies, or generated text. A consumer-injected `LLMClient` can support inference
diagnostics, but does not establish HTTP status, destination, reachability, or
upstream credential-supply facts; Promptkit does not fall back to a built-in
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

View File

@@ -88,6 +88,17 @@ release, including canceled acquisition without dispatch. A retry wait holds
the logical admission lease but no active permit. Observer callbacks must not the logical admission lease but no active permit. Observer callbacks must not
block on work that requires the same run's admission slot. block on work that requires the same run's admission slot.
## Smoke-Test Capacity
The engine's smoke tester shares this manager and generation coordinator with
the ordinary runner. Inference diagnostics reserve the same logical admission
slot and acquire the same backend-scoped active permits for actual model
attempts, including retries. They therefore participate in the same configured
backend limits as `Run`. Reachability and authentication HTTP probes do not
enter generation admission or consume active permits. The public
[`Engine.SmokeTest` GoDoc](../../smoke_diagnostics.go) owns the caller-visible
operation and injected-client limits.
## Cancellation And Release ## Cancellation And Release
Admission checks the caller context before reserving a slot. A call canceled Admission checks the caller context before reserving a slot. A call canceled
@@ -127,4 +138,7 @@ cover shared and independent backend pools during retry and repair. The
own the public prepared-capacity boundary. The own the public prepared-capacity boundary. The
[root error-boundary tests](../../errors_internal_test.go) own preservation of [root error-boundary tests](../../errors_internal_test.go) own preservation of
the public generation category and context identity when generation is the public generation category and context identity when generation is
canceled. canceled. The assembled
[smoke diagnostic contract tests](../../smoke_integration_contract_test.go)
verify that inference diagnostics share the engine's Run admission and recover
capacity after cancellation or failure.

View File

@@ -41,9 +41,22 @@ original error chain. This keeps injected attempt classifications consistent
with terminal observations without parsing diagnostic text or retrying injected with terminal observations without parsing diagnostic text or retrying injected
clients. clients.
The diagnostic path uses the same private attempt and retry implementation as
ordinary generation. It retains invocation-local transport, response,
generation, usage, capacity-wait, and retry-delay evidence before normal
result-discard and cancellation checks. Built-in preparation also supplies its
frozen request URL to the evidence path before attempts begin; injected
generation has no equivalent HTTP destination. This evidence is returned to
diagnostic orchestration; it is not added to public `ExecutionEvent` values or ordinary
observer accounting. The [model-client document](llm.md) describes the
transport-observation boundary, and the root
[`SmokeTestReport` GoDoc](../../smoke_diagnostics.go) owns report semantics.
Focused coordinator tests use controlled transport and wait functions. They Focused coordinator tests use controlled transport and wait functions. They
cover eligibility, exhaustion, cancellation, backoff, request freezing, permit cover eligibility, exhaustion, cancellation, backoff, request freezing, permit
release, and the injected-client path. The capacity manager tests own permit release, and the injected-client path. The
[evidence tests](../../internal/generation/evidence_test.go) own diagnostic
attempt retention and retry evidence. The capacity manager tests own permit
acquisition and idempotent release. Public acquisition and idempotent release. Public
[integration tests](../../reliable_execution_contract_test.go) cover retry, [integration tests](../../reliable_execution_contract_test.go) cover retry,
repair, observation, frozen prepared execution, and shared-backend behavior repair, observation, frozen prepared execution, and shared-backend behavior

View File

@@ -59,6 +59,33 @@ This client does not schedule retries; the [generation coordinator](generation.m
does so around its prepared attempts. The client has no tool-call support, does so around its prepared attempts. The client has no tool-call support,
provider catalog, inbound HTTP behavior, or durable session store. provider catalog, inbound HTTP behavior, or durable session store.
## Diagnostic HTTP Evidence
Built-in diagnostics wrap the selected HTTP client's `RoundTripper` for one
invocation using a shallow client copy, preserving its timeout, redirect policy,
cookie jar, and underlying transport without mutating the configured client.
Evidence distinguishes a diagnostic dispatch invocation from an observed
request and response. A newly observed request replaces the previous
destination-specific response evidence, so redirects retain only the last
observed hop. A response is recorded before envelope decoding or later context
checks can discard a generation result.
The collector keeps only the last observed destination (without URL user
information), status, credential-presence, response-availability, decoded
generation, and presence-aware usage facts needed by the diagnostic. It does
not retain headers, credentials, raw request or response bodies, or a redirect
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.
Reachability and named authentication probes use the same configured built-in
client but discard their response bodies after local recognition. The
[integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics)
owns their observable HTTP semantics.
An injected `LLMClient` does not pass through this HTTP boundary. Its returned
response can establish response and inference facts, but not an HTTP
destination, status, reachability, or upstream credential-supply fact. The
facade does not silently install a built-in transport for those diagnostics.
## Prepared Generation ## Prepared Generation
For [`RunPrepared`](../../engine.go), the runner supplies the model client with For [`RunPrepared`](../../engine.go), the runner supplies the model client with
@@ -137,7 +164,10 @@ own configuration, client cloning, deterministic deadline precedence,
authentication, request and response mapping, malformed data, error identity, authentication, request and response mapping, malformed data, error identity,
cancellation, endpoint selection and composition, pre-transport rejection, and cancellation, endpoint selection and composition, pre-transport rejection, and
bounded single-document successful-response framing, closure, and bounded single-document successful-response framing, closure, and
response-body suppression. The focused response-body suppression. The [HTTP evidence tests](../../internal/llm/http_evidence_test.go)
own invocation-local last-hop request/response facts, while the
[prepared-generation evidence tests](../../internal/llm/prepared_generation_evidence_test.go)
own retention across decoding and cancellation. The focused
[provider HTTP error tests](../../internal/llm/provider_http_error_test.go) [provider HTTP error tests](../../internal/llm/provider_http_error_test.go)
own envelope parsing, normalization, and bounded-reader cases; their own envelope parsing, normalization, and bounded-reader cases; their
[transport tests](../../internal/llm/provider_http_error_transport_test.go) [transport tests](../../internal/llm/provider_http_error_transport_test.go)

View File

@@ -23,6 +23,9 @@ callback. The caller supplies normalized failure metadata; the recorder never
parses an error string or receives provider bodies, credentials, headers, or parses an error string or receives provider bodies, credentials, headers, or
other content. Events are values with no mutable containers. Callback panics 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
the invocation, including namespace qualification when supplied by an engine
view.
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.
@@ -41,6 +44,11 @@ attempts above the first in a generation, and repair generations with at least
one dispatch. A terminal event is an aggregate and must not be added to its one dispatch. A terminal event is an aggregate and must not be added to its
attempt events. attempt events.
Smoke diagnostics mask inherited execution-recorder state while invoking
generation or HTTP collaborators. Their private attempt evidence is assembled
into a `SmokeTestReport`; it does not create ordinary run events or enter an
enclosing run's observation totals.
The [recorder tests](../../internal/observation/recorder_test.go) own usage The [recorder tests](../../internal/observation/recorder_test.go) own usage
presence, phase sums, event ordering, empty-run accounting, identity snapshots, presence, phase sums, event ordering, empty-run accounting, identity snapshots,
and callback panic behavior. The [generation coordinator](generation.md) owns and callback panic behavior. The [generation coordinator](generation.md) owns

View File

@@ -11,17 +11,16 @@ contributor workflow and validation.
| Component | Implemented responsibility | References | | Component | Implemented responsibility | References |
| --- | --- | --- | | --- | --- | --- |
| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, profile and backend mapping conveniences, injection options, public requests and results, prompt/profile/requirement inspection and identity listing, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [identity listing](../../listing.go), [requirement facade](../../requirements.go) | | Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, collection and resource mapping conveniences, injection options, public requests and results, prompt/profile/requirement inspection and identity inventories, concrete-request validation, explicit smoke diagnostics, observation values, opaque prepared-execution handles, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go), [smoke diagnostics](../../smoke_diagnostics.go), [identity listing](../../listing.go), [requirement facade](../../requirements.go) |
| `examples/go-library/prepare` | Demonstrates offline preparation and explicit configuration inspection with a prompt file, programmatic profile, and inline input. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) | | `examples/go-library/prepare` | Demonstrates offline preparation from two explicitly selected synthetic collections, source-relative imported content, a scoped view, filtered listings, and operator inventory. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go), [collection inputs](../../examples/go-library/prepare/collections/support/collection.yaml) |
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) | | `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) | | `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Holds a defensive, immutable lookup and capacity-policy index for final backend definitions validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) | | `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
| `internal/catalog` | Selects released keyed catalog assets and PromptKit-owned profile definitions as ConfigKit underlays; effective validation belongs to the configuration loader. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) | | `internal/configuration` | Loads root and namespaced collection resources through ConfigKit; records effective field presence, validates final values including exact references and optional diagnostic metadata, captures indexed prompt definitions and source-relative message content, compiled templates, input metadata snapshots, sorted backend/profile/prompt identities, resource namespace sets, effective collection declarations, backend and presence-aware profile snapshots, and the unchanged read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values, captures indexed prompt definitions, referenced message content, compiled templates, input metadata snapshots, sorted identity-only profile and prompt inventories, backend and presence-aware profile snapshots, and an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) | | `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) | | `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, validation, and source-neutral smoke diagnostics, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, output contracts, and resource-identity syntax and qualification. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [requirement values](../../internal/domain/requirements.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/engine` | Assembles selected catalog and caller configuration into one snapshot, then constructs capacity, rendering, validation, generation, repair, and the use-case runner for each engine. It delegates identity listing directly to the captured configuration inventory rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) | | `internal/engine` | Assembles caller-selected configuration into one snapshot, then constructs shared capacity, rendering, validation, generation, repair, the ordinary runner, and the smoke tester for each engine. Immutable namespace views share those collaborators and qualify caller-supplied execution, inspection, and diagnostic selections without rewriting captured references. Built-in engines provide the diagnostic HTTP collaborator; injected-client engines do not. It delegates identity listing directly to the captured configuration inventory rather than the runner. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#typed-configuration-loading), [internal runner](runner.md) |
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, and attempt observations. | [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 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) |
@@ -30,9 +29,9 @@ contributor workflow and validation.
| `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) |
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) | | `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) | | `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) | | `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded response decoding, authentication, deadline handling, per-invocation HTTP evidence and built-in diagnostic probes, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts. | [Internal execution observation](observation.md) | | `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts; smoke diagnostics keep their private evidence outside ordinary observer events. | [Internal execution observation](observation.md) |
| `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, and coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) | | `internal/usecase` | Selects captured prompts and profiles, enumerates requirements from captured prompt syntax, applies request overrides and completion checks, coordinates ordinary and single-claim prepared execution across rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair, and runs explicit smoke diagnostics with private cases, shared generation/capacity, and optional built-in HTTP probes. | [Internal runner](runner.md), [requirement inspection](../../internal/usecase/requirements_inspection.go), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
The internal engine assembles these internal components without exposing their The internal engine assembles these internal components without exposing their
representations. Consumers depend only on the root facade. representations. Consumers depend only on the root facade.

View File

@@ -32,6 +32,12 @@ The runner has no durable run or session store.
## Shared Prompt Selection ## Shared Prompt Selection
The internal engine applies each namespace view to slashless caller selections
before they reach the runner. The runner therefore selects captured prompts and
profiles by the resulting exact runtime IDs. Already-qualified selections pass
through, while configured defaults and stored references are used as authored
and are not requalified.
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
@@ -46,8 +52,8 @@ admission, or model collaborators. The root
exact contract. exact contract.
`InspectRequirements` uses the same metadata selection to resolve the `InspectRequirements` uses the same metadata selection to resolve the
request-profile/default identity, then obtains the exact captured requirement request-profile/default identity, then obtains the captured requirement
snapshot by the selected prompt ID and version. The analyzer receives only 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
@@ -70,6 +76,37 @@ artifact, schema, rendering, admission, or model-client work. The root
[`Engine.InspectProfile`](../../engine.go) GoDoc owns the public operation's [`Engine.InspectProfile`](../../engine.go) GoDoc owns the public operation's
exact contract. exact contract.
## Smoke-Test Diagnostics
The separate `SmokeTester` in `internal/usecase` selects from the same captured
configuration snapshot and backend/profile collaborators as the runner. The
internal engine applies namespace-view qualification to slashless caller IDs
before diagnostic selection; captured defaults and references remain exact.
Explicit profile selection takes precedence over prompt-default selection and a
backend diagnostic default; HTTP-only selection does not resolve a default
inference profile. Exact public selection rules belong to the root
[`Engine.SmokeTest` GoDoc](../../smoke_diagnostics.go).
Diagnostics do not call `Run`, `Prepare`, or the prompt preparation pipeline.
They do not render application messages, load application artifacts, use
application schemas, or inspect session templates. Private text and structured
cases and the structured case's schema remain internal to the diagnostic
operation; they are not entries in prompt/profile listings or inspection.
`InspectConfiguration` remains a separate, explicit operator-oriented view of
effective settings and source history. A diagnostic report may include selected
effective settings and identifiers, so consumers still apply their disclosure
policy.
The engine assembles the smoke tester with the runner's generation coordinator
and capacity manager. Inference holds the ordinary logical admission lease and
uses the same active permits, retries, timeout, and request evidence path as
generation. Reachability and authentication probes bypass generation capacity
and do not require a model. An engine with an injected `LLMClient` can run
inference diagnostics but has no built-in HTTP probe collaborator; HTTP-only
diagnostics report that limitation instead of falling back to another client.
The public GoDoc owns the caller-visible timeout, cancellation, report, and
transport semantics.
## Shared Preparation Pipeline ## Shared Preparation Pipeline
`Prepare` and `Run` share one private preparation pipeline split at the point `Prepare` and `Run` share one private preparation pipeline split at the point
@@ -111,6 +148,12 @@ into its private payload, while deep-copying mutable provider extras and
creating an independent details snapshot. Claim completion or discard clears creating an independent details snapshot. Claim completion or discard clears
the execution payload without removing the details snapshot. the execution payload without removing the details snapshot.
The root facade's `Engine.ValidateRequest` uses the same complete preparation
path as `Prepare` and discards the resulting internal prepared value without
converting it into a public snapshot. It does not add a runner operation or
validator component; request conversion and public error mapping remain at the
facade boundary.
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
out-of-range values fail as invalid requests. Endpoint overrides do not change out-of-range values fail as invalid requests. Endpoint overrides do not change
the selected backend identity. Non-empty extra-parameter maps replace whole the selected backend identity. Non-empty extra-parameter maps replace whole
@@ -250,4 +293,8 @@ Changes to orchestration should continue to use the existing package
interfaces, keep request state local to an invocation, and preserve the shared interfaces, keep request state local to an invocation, and preserve the shared
resolution and completion pipeline. Source, renderer, validator, or resolution and completion pipeline. Source, renderer, validator, or
model-client contract changes belong first in their owning package and model-client contract changes belong first in their owning package and
document. document. The
[smoke selection tests](../../internal/usecase/smoke_selection_test.go),
[diagnostic orchestration tests](../../internal/usecase/smoke_tester_test.go),
and [private inference-case tests](../../internal/usecase/smoke_inference_test.go)
own diagnostic selection, report assembly, and inference findings.

View File

@@ -13,40 +13,65 @@ 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
backend, profile, and prompt values. Replaced lower-layer domain values are root and collection backend, profile, and prompt values. Replaced lower-layer
not separately validated. It builds an immutable backend index and domain values are not separately validated. It indexes root resources under
presence-aware profile targets, applying final backend defaults only after their unqualified IDs and collection resources under their full
profile inheritance. It retains ConfigKit's result privately for explicit `namespace/local-key` IDs while retaining ConfigKit's resolved tree. It builds an immutable backend index and presence-aware profile
inspection, not for per-run resolution. The runtime/domain packages do not targets, applying final backend defaults only after profile inheritance. Runtime
depend on ConfigKit. lookups use those final indexes and exact stored references; they do not rewrite
or resolve resources from ConfigKit source history. The runtime/domain packages
do not depend on ConfigKit.
ConfigKit owns source headers, imports, composition, and source evidence.
Promptkit does not preparse contributions: it validates the final effective
values once, captures the indexed identities and their original resource
contexts, and retains ConfigKit's resolved result for explicit operator inspection.
Accepted non-main source headers are consumed during composition; the main
source declaration remains available in that inspection view. Collection
release labels are descriptive metadata, not selection inputs. The
[format reference](../formats.md#source-headers-collections-and-resource-ids)
owns the exact source-header and collection contracts.
The `ConfigurationInspection.Diff` facade method compares two retained results 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.7.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.7.0 comparison contract](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.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 prompt materialization and effective-profile snapshot construction, the After resource materialization and effective-profile snapshot construction, the
adapter captures lexically ordered, identity-only inventories from the final adapter captures lexically ordered identity inventories for backends, profiles,
prompt and indexed-profile maps. Its listing methods return caller-owned copies and prompts, per-kind resource namespace sets, and metadata for effective
of those inventories without consulting ConfigKit or copying definitions. collection declarations. Listing methods return caller-owned copies from those
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. The engine prepends selected catalog and order does not confer priority. Engine assembly prepends a dedicated embedded
Promptkit-owned definitions, then caller underlays and optional main. The OpenRouter underlay unless disabled, without modifying caller-owned slices.
Caller underlays are composed in their given
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.
The public `CollectionInput` helper mounts one caller-selected source at
`collections/<namespace>` and enables ConfigKit imports for that input. The
helper does not read the source; ConfigKit retains responsibility for loading,
composition, imports, and source-local resource context. Imported or inherited
`content_file` values continue resolving relative to the source that contributed
the effective field, not to a flattened runtime identity. Explicit
`InspectConfiguration` views expose the retained effective configuration and
provenance for operator review; runtime selection and inventories do not derive
from source history.
## Prompt Definitions ## Prompt Definitions
The configuration adapter indexes each prompt by exact ID and version, The configuration adapter indexes each prompt by exact ID,
materializes file-backed message content using the exact effective field's materializes file-backed message content using the exact effective field's
origin and resource context, and checks static message/session template syntax origin and resource context, and checks static message/session template syntax
at construction. It stores captured content, compiled templates, declared at construction. It stores captured content, compiled templates, declared
@@ -58,14 +83,18 @@ declaration; absent and null fields remain distinguishable from explicit
strings, including empty strings. It closes content-file handles before strings, including empty strings. It closes content-file handles before
returning and retains no source capability for public inspection. returning and retains no source capability for public inspection.
Role and cache-control normalization is applied to execution-owned message Prompt materialization shares a lazy metadata snapshot across root and collection
copies. ConfigKit v0.7.0 retains effective input message metadata and its resources during construction, avoiding a full metadata copy per collection.
resource origins; recording a sequence-wide normalization in the retained
configuration would replace origins of unchanged content-file fields. Normalization and defaults are recorded at individual resolved field paths,
including message roles and cache controls. List indexes identify fields for
presence, validation, sensitivity, and resource context; full IDs select
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
internal requirement snapshot lookup selects by exact prompt ID and version. internal requirement snapshot lookup uses the selected prompt ID alone.
These paths do not rescan configuration or reopen message files. `InspectPrompt` These paths do not rescan configuration or reopen message files. `InspectPrompt`
returns declared metadata without rendering or resolving profile, input, or returns declared metadata without rendering or resolving profile, input, or
schema resources, and its public shape and prompt hashes do not include the schema resources, and its public shape and prompt hashes do not include the
@@ -73,8 +102,8 @@ captured presence state. Runtime rendering still checks request variables and
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go) input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
cover resource origins, context containment, capture, and selection. cover resource origins, context containment, capture, and selection.
The configuration adapter shares exact ID/version selection across its The configuration adapter shares ID-only selection across its internal prompt
internal prompt lookups. The execution snapshot lookup returns an owned prompt lookups. The execution snapshot lookup returns an owned prompt
definition and cached hash; metadata inspection returns owned input metadata definition and cached hash; metadata inspection returns owned input metadata
without copying execution templates. Profile selection likewise has an without copying execution templates. Profile selection likewise has an
identity-only lookup for consumers that do not need the execution target, identity-only lookup for consumers that do not need the execution target,
@@ -112,18 +141,12 @@ dependency-bearing range scopes make discovery incomplete. Uncalled definitions
and bypassed session templates are not traversed. This internal walker does not and bypassed session templates are not traversed. This internal walker does not
change rendering behavior. change rendering behavior.
## Profiles And Built-Ins ## Profiles
`internal/catalog` selects the released keyed OpenRouter and Rakestrawhome ConfigKit resolves profile inheritance before the handoff. Within a collection,
assets as one ConfigKit underlay and PromptKit-owned profiles as the next. `extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference.
It discovers YAML inputs and supplies passive filesystem context without The configuration adapter validates final effective resource references and
parsing or pre-validating catalog definitions. The configuration loader field values, then builds target
validates only the final effective backend and profile values, including
consumer overlays of maintained bases or backends. There is no catalog-only
preflight, reserved source namespace, or replay of backend registration.
ConfigKit resolves profile inheritance before the handoff. The configuration
adapter validates effective references and field values, then builds target
snapshots with backend defaults and presence/suppression state. A profile can snapshots with backend defaults and presence/suppression state. A profile can
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
@@ -131,8 +154,7 @@ provider work. Preparation applies runtime overrides and then checks target
completeness. Identity-only profile selection can identify a partial captured completeness. Identity-only profile selection can identify a partial captured
profile without copying its target or requiring readiness. The profile without copying its target or requiring readiness. The
[target tests](../../internal/configuration/targets_test.go) cover effective [target tests](../../internal/configuration/targets_test.go) cover effective
target interpretation, while the [catalog tests](../../internal/catalog/catalog_test.go) target interpretation.
cover selected released assets.
The internal engine delegates listing through a narrow identity-inventory The internal engine delegates listing through a narrow identity-inventory
interface directly to the configuration snapshot. The execution runner has no interface directly to the configuration snapshot. The execution runner has no

View File

@@ -26,47 +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.7.1`. 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/6fffde4af8f31f99d3ee295c426eb3234cb0fb77/COPYRIGHT) | [COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/6fffde4af8f31f99d3ee295c426eb3234cb0fb77/LICENSE). [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/6fffde4af8f31f99d3ee295c426eb3234cb0fb77). [Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
## Promptkit OpenRouter Catalog v3 ## ReleaseKit
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3` Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (maintainer tool).
at `v3.20260930.0`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [MIT](licenses/MIT.txt) | License and notices: [LGPL 3.0](../../LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/COPYRIGHT) | [COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE). [LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0). [Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
## Promptkit Rakestrawhome Catalog v3
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3`
at `v3.20260930.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d).
## jsonschema ## jsonschema

View File

@@ -51,8 +51,8 @@ The [internal component overview](../internal/overview.md) owns the inventory
of implemented packages and their concrete responsibilities. This policy owns of implemented packages and their concrete responsibilities. This policy owns
the boundaries and dependency rules those components must preserve. the boundaries and dependency rules those components must preserve.
The internal engine composes selected catalog and caller ConfigKit inputs once, The internal engine composes caller-selected ConfigKit inputs once, then
then assembles the captured definitions, capacity manager, renderer, validator, assembles the captured definitions, capacity manager, renderer, validator,
outbound client, and use-case runner. The configuration adapter interprets and outbound client, and use-case runner. The configuration adapter interprets and
validates only the final effective values, including presence, null, and validates only the final effective values, including presence, null, and
resource context. It retains source history for explicit operator inspection, resource context. It retains source history for explicit operator inspection,
@@ -97,7 +97,8 @@ on consumers or on Scriptorium.
`internal/domain` owns source-neutral invariants for values shared across `internal/domain` owns source-neutral invariants for values shared across
multiple input and execution boundaries, including execution-setting bounds, multiple input and execution boundaries, including execution-setting bounds,
OpenAI-compatible base endpoints, session identifiers, and output-contract OpenAI-compatible base endpoints, session identifiers, and output-contract
legality. Callers retain source parsing, required-field rules, other legality, plus shared resource-identity syntax and qualification. Callers retain
source parsing, required-field rules, other
source-specific normalization, defaulting, error classification, and policy source-specific normalization, defaulting, error classification, and policy
specific to their own boundary. specific to their own boundary.

View File

@@ -88,7 +88,7 @@ secret values.
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. | | Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. | | Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. | | Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
| Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, built-in profiles, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. | | Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. |
Create supporting documents for existing interfaces and responsibilities when Create supporting documents for existing interfaces and responsibilities when
substantive content is available. substantive content is available.

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,
@@ -279,28 +285,8 @@ captured publication endpoint before retrying; the repository-specific
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.
## Catalog Profile Compatibility Hosted delivery is a separate post-publication step for new releases. It reuses
the immutable tag and note without rerunning candidate validation or waiting
External catalog profile data may be added, removed, renamed, or modified for CI. Retry failed delivery against that same version after resolving any
without deprecation or advance notice. This exception is limited to profile uncertain hosted write; preserve published tags and hosted content. Report
data imported from the maintained catalog modules. It does not weaken ordinary delivery status separately from the completed source release.
compatibility for Promptkit's public Go API, configuration and format semantics,
runtime behavior, catalog-module APIs, manifest schema, backend definitions, or
consumer-owned definitions.
Within the catalog versions selected and tested by a Promptkit release, and
unless a consumer overrides the name or its base, Promptkit keeps the literal
profile IDs `promptkit-openrouter-standard` and
`promptkit-openrouter-auto` available. The names do not promise a permanent
model, backend, routing behavior, settings, cost, output, or upstream
availability. The [framework format reference](../formats.md#built-in-profile-catalog)
owns their current definitions.
`v0.10.0` is the initial minor release for this compatibility transition.
Afterward, catalog-only updates and adjustments to the current selections may
ship in a patch release when all other compatibility obligations remain intact.
External profile removals can require consumer action; they are not automatic
migrations.
Catalog adoption and release validation remain local and offline. They do not
probe provider endpoints, request model lists, or make inference calls.

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,110 +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.
## Catalog Profile Compatibility ## Deliver The Hosted Release
The [release policy](policy/release.md#catalog-profile-compatibility) owns the After source publication, keep a clean checkout at the published tag's commit:
catalog compatibility boundary. This heading preserves older incoming links.
```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.

View File

@@ -15,7 +15,7 @@ the existing Go API. This refactor requires no consumer code changes.
This release selects OpenRouter `v2.20260922.1` and Rakestrawhome This release selects OpenRouter `v2.20260922.1` and Rakestrawhome
`v2.20260922.0`; their exact inventories are linked from the `v2.20260922.0`; their exact inventories are linked from the
[format reference](../formats.md#external-profile-inventories). [migration identity mapping](../consumers/configuration-migration.md#identity-mapping).
The maintained names provide continuity only within the catalog versions The maintained names provide continuity only within the catalog versions
selected and tested by a Promptkit release and when consumers do not override selected and tested by a Promptkit release and when consumers do not override
@@ -37,8 +37,8 @@ an available profile or supply their own complete definition; the new
`deepseek-flash-latest` and `deepseek-pro-latest` profiles are not automatic `deepseek-flash-latest` and `deepseek-pro-latest` profiles are not automatic
aliases and need not preserve the previous settings. aliases and need not preserve the previous settings.
See the [release policy](../release.md#catalog-profile-compatibility) for the See the [release policy](../policy/release.md#versioning-and-compatibility) for the
compatibility boundary, the [format reference](../formats.md#built-in-profile-catalog) compatibility boundary, the [migration identity mapping](../consumers/configuration-migration.md#identity-mapping)
for current Promptkit-owned definitions and selected external inventories, and for current resource mappings, and
the [consumer guide](../consumers/pkg-promptkit.md#choose-a-profile-ownership-model) the [consumer guide](../consumers/pkg-promptkit.md#choose-a-profile-ownership-model)
for configuration choices. for configuration choices.

View File

@@ -72,5 +72,5 @@ for a structured proposal-and-assessment workflow.
Backend catalog selections are unchanged from v0.10.0: OpenRouter Backend catalog selections are unchanged from v0.10.0: OpenRouter
`v2.20260922.1` and Rakestrawhome `v2.20260922.0`. See the `v2.20260922.1` and Rakestrawhome `v2.20260922.0`. See the
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility) [release compatibility policy](../policy/release.md#versioning-and-compatibility)
and [profile inventories](../formats.md#external-profile-inventories). and [migration identity mapping](../consumers/configuration-migration.md#identity-mapping).

View File

@@ -15,7 +15,7 @@ No public API, runtime behavior, or profile changes are introduced.
Promptkit remains LGPL-3.0-or-later; the catalogs are now MIT-licensed. Promptkit remains LGPL-3.0-or-later; the catalogs are now MIT-licensed.
See the [project grant](../../COPYRIGHT), See the [project grant](../../COPYRIGHT),
[component attribution](../oss/attribution.md), and [component attribution](../oss/attribution.md), and
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility). [release compatibility policy](../policy/release.md#versioning-and-compatibility).
## Upgrade ## Upgrade

View File

@@ -35,7 +35,7 @@ for API and source-selection changes.
Maintained OpenRouter and Rakestrawhome catalogs are selected as `/v3` modules, Maintained OpenRouter and Rakestrawhome catalogs are selected as `/v3` modules,
both at `v3.20260930.0`. Their asset format changed; consumers that read catalog both at `v3.20260930.0`. Their asset format changed; consumers that read catalog
assets directly must adopt the keyed format. The maintained Promptkit profile assets directly must adopt the keyed format. The maintained Promptkit profile
IDs remain available under the [catalog compatibility policy](../policy/release.md#catalog-profile-compatibility), IDs remain available under the [release compatibility policy](../policy/release.md#versioning-and-compatibility),
which does not promise fixed profile data. which does not promise fixed profile data.
## Upgrade ## Upgrade

View File

@@ -19,7 +19,7 @@ increases to 1.26.0.
engine's captured configuration, are lexically ordered and caller-owned, and engine's captured configuration, are lexically ordered and caller-owned, and
include consumer definitions and maintained profiles that remain effective. include consumer definitions and maintained profiles that remain effective.
Listing performs no provider or execution work. See the Listing performs no provider or execution work. See the
[discovery guide](../consumers/pkg-promptkit.md#discover-available-profiles-and-prompts) [discovery guide](../consumers/pkg-promptkit.md#discover-available-resources-and-collections)
and [GoDoc](../../listing.go). and [GoDoc](../../listing.go).
## Efficiency And Tooling ## Efficiency And Tooling

34
docs/releases/v0.15.0.md Normal file
View File

@@ -0,0 +1,34 @@
# Promptkit v0.15.0
This release adds error-only validation of a concrete prompt request without
calling a model. The minimum supported Go version remains 1.26.0.
## Validate A Concrete Request
`Engine.ValidateRequest` accepts an ordinary `RunRequest` and returns nil when
it passes preparation, or the existing preparation error. Consumers can use it
for fail-fast checks, including downstream CLI validation commands, without
receiving a prepared snapshot.
The method shares `Prepare`'s request conversion and preparation path. Unlike
requirements inspection, it checks the actual supplied values and bindings.
Artifact and schema resource I/O still occurs; success does not certify provider
acceptance or later execution success. A subsequent run prepares independently.
Validation is optional, not a required step before preparation or execution.
See the [request validation guide](../consumers/pkg-promptkit.md#validate-a-concrete-request)
and [GoDoc](../../engine.go) for usage and the exact contract. Consumers that
need execution tied to a checked snapshot can use the existing
[prepared execution workflow](../consumers/pkg-promptkit.md#prepare-now-and-execute-the-same-snapshot-later).
## Compatibility And Upgrade
No application-code, configuration, or Go-toolchain migration is required from
v0.14.0. This is an optional API addition; existing inspection, preparation, and
execution workflows remain available.
```sh
go get gitea.maximumdirect.net/eric/promptkit@v0.15.0
```
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.15.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.15.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.15.0/docs/oss/attribution.md)

86
docs/releases/v0.16.0.md Normal file
View File

@@ -0,0 +1,86 @@
# Promptkit v0.16.0
This release adds explicit smoke diagnostics, configuration collections,
namespace-scoped engine views, and expanded resource discovery. It also makes
intentional breaking changes to configuration ownership and prompt selection.
The minimum supported Go version remains 1.26.0.
## Configuration Collections And Discovery
Applications can compose caller-selected collections of backends, profiles,
and prompts. Collection resources have qualified identities such as
`support/answer`; root resources retain unqualified identities. `CollectionInput`
provides a convenience for mounting a selected ConfigKit input into a collection.
Promptkit now uses ConfigKit v0.8.0, including its optional source specification
header support.
`Engine.WithNamespaces` creates an immutable view with separate prompt, profile,
and backend scopes for short caller-supplied IDs. Views share the underlying
configuration, clients, capacity pools, and prepared-execution owner. Explicitly
qualified IDs and configured references keep their exact identities.
`ListProfiles` and `ListPrompts` support namespace filters. New `ListBackends`,
`ListNamespaces`, and `ListCollections` methods expose captured resource and
collection inventories. See the [collection format](../formats.md#source-headers-collections-and-resource-ids),
[scoped-view guide](../consumers/pkg-promptkit.md#use-scoped-engine-views), and
[discovery guide](../consumers/pkg-promptkit.md#discover-available-resources-and-collections).
## Explicit Smoke Diagnostics
`Engine.SmokeTest` provides backend reachability, authentication, text inference,
and structured-output inference diagnostics. Reports provide factual
observations, timing, and available usage rather than an overall readiness
verdict. Optional Boolean facts distinguish negative observations from unknown
or inapplicable checks; matching a JSON schema does not prove that a provider
enforced it.
A concluded diagnostic can report failure with a nil Go error. Invalid requests
return ordinary errors, and caller cancellation can return partial observations
with a recognizable context error. Diagnostics can contact providers and incur
inference costs only when explicitly invoked; they are not automatic execution
or release checks. See the [smoke diagnostic guide](../consumers/pkg-promptkit.md#run-explicit-smoke-diagnostics)
and [API GoDoc](../../smoke_diagnostics.go).
## Breaking Changes And Upgrade
Consumers upgrading from v0.15.0 should follow the
[configuration and API migration guide](../consumers/configuration-migration.md):
- Supply backend and profile definitions explicitly. Promptkit no longer bundles
provider catalogs or the `promptkit-openrouter-standard` and
`promptkit-openrouter-auto` profiles. Conventional backend names do not install
definitions automatically. Applications choose and maintain their sources.
- Convert version-keyed prompt mappings into one definition per prompt ID.
Prompt `version` is now an optional descriptive label, not a selector.
Choose deliberately which former definition to install.
- Remove `PromptVersion` selectors from `RunRequest`,
`RequirementInspectionRequest`, and `SmokeTestRequest`, where applicable.
Change prompt inspection calls to `InspectPrompt(ctx, promptID)`. Update code
consuming version-bearing inspection, identity, result, or observation fields
to handle optional labels, including nil and explicitly empty labels.
- When moving resources into collections, update stored backend, profile, and
diagnostic-default references to their intended qualified IDs. Mounting a
source does not rewrite references; profile inheritance remains within its
sibling mapping. Root configuration `version`, when supplied, is now a source
specification header, distinct from descriptive resource labels.
The migration guide includes identity mappings and before/after prompt examples.
The [maintained offline example](../../examples/go-library/prepare/main.go)
demonstrates explicit composition, source-relative content, scoped preparation,
and inventory without contacting a provider.
After adapting configuration and API calls:
```sh
go get gitea.maximumdirect.net/eric/promptkit@v0.16.0
```
## Correctness And Efficiency
Collection prompt materialization shares one lazy origin-metadata snapshot,
avoiding repeated full metadata copies across collections. Internal prepared
snapshots detach optional prompt labels. Smoke diagnostics preserve caller
cancellation and report actual inference destinations and known-zero usage when
no generation request was dispatched.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.16.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.16.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.16.0/docs/oss/attribution.md)

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)

View File

@@ -63,8 +63,8 @@ Every engine now includes the reserved `rakestrawhome` backend, identified by
selects that backend. Consumers can use the maintained endpoint, credential, selects that backend. Consumers can use the maintained endpoint, credential,
capacity, and model defaults without registering either definition themselves. capacity, and model defaults without registering either definition themselves.
See the [built-in backend and profile catalogs](../formats.md#built-in-backends) See the [backend format reference](../formats.md#backend-definitions)
and the [consumer adoption example](../consumers/pkg-promptkit.md#use-the-rakestrawhome-built-in-profile) and the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping)
for the current contracts. for the current contracts.
## Structured Generation Errors ## Structured Generation Errors
@@ -126,8 +126,8 @@ 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 [consumer alias example](../consumers/pkg-promptkit.md#alias-a-built-in-profile), 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

@@ -96,9 +96,8 @@ precedence rules. Promptkit no longer keeps duplicate embedded profile assets
or hard-coded definitions for those maintained backends. or hard-coded definitions for those maintained backends.
The module versions in Promptkit's `go.mod` identify the catalog releases The module versions in Promptkit's `go.mod` identify the catalog releases
tested with this release. The [built-in backend and profile format tested with this release. The [backend format reference](../formats.md#backend-definitions)
reference](../formats.md#built-in-backends) remains the canonical consumer remains the canonical consumer contract, while the [internal source documentation](../internal/sources.md#profiles)
contract, while the [internal source documentation](../internal/sources.md#profiles-and-built-ins)
describes the dependency boundary. describes the dependency boundary.
## Public API Changes ## Public API Changes

View File

@@ -0,0 +1,131 @@
# Functional Correctness Audit
Reviewed 2026-11-04 against commit `d9f6aad`.
## Scope And Evidence
Reviewed the public facade and conversions; ConfigKit handoff, normalization,
presence-aware target assembly, collections and namespace selection; artifact
loading and template rendering/requirement discovery; ordinary and single-use
prepared execution; output validation and repair; backend admission and active
permits; HTTP request/response handling and transient retries; execution
observations; and smoke-test selection and reporting. Contracts were checked
against GoDoc, the format and integration references, internal documentation,
and relevant existing tests. Source loading before the ConfigKit handoff remains
ConfigKit's responsibility.
Validation:
- `go test -race -vet=off ./...` passed, including the maintained examples.
- `go vet ./...` passed.
- An offline, temporary public-API reproduction outside the repository confirmed
the number conversion and captured schema payload described below. It used
`ProfileSource`, `Prepare`, a runtime override, and `WithSchemaFS` with
`fstest.MapFS`; no provider was contacted.
No production code or tests were changed. This audit does not establish provider
availability or acceptance of arbitrary JSON Schema features.
## 1. Provider Source Helpers Silently Change Exact JSON Numbers
**Priority:** P2. Confirmed data-conversion defect.
**Code:** [providerValueForMapping](../../../provider_values.go:51), used by
[ProfileSource](../../../profiles.go:33) and
[BackendSource](../../../backends.go:100).
**Circumstances:** A caller supplies a valid `json.Number` in provider
`ExtraParams` that cannot be represented exactly by the helper's chosen native
number type. After signed and unsigned integer parsing fail, the helper converts
the value to `float64` without checking whether that conversion loses precision.
The same problem affects sufficiently precise fractional values.
**Expected:** A source helper preserves the meaning of a supported provider
value, or reports that the value cannot be represented. It must not silently
substitute a different number. This concerns PromptKit's own convenience-source
conversion, not additional validation of ConfigKit's raw sources.
**Actual / reproduction:** With
`ExtraParams: map[string]any{"number": json.Number("18446744073709551617")}`
on a profile, `NewEngine` succeeds. JSON encoding of
`Prepare(...).EffectiveModelParams.ExtraParams` produces:
```json
{"number":18446744073709552000}
```
Supplying the identical value through `RunRequest.Execution.ExtraParams`
instead produces:
```json
{"number":18446744073709551617}
```
The built-in client serializes those effective values directly into the request,
so configuration-source selection changes the actual provider parameter.
`BackendSource` shares the same converter. No high-precision numeric preservation
claim needs to be added for arbitrary source formats to correct this discrepancy.
**Potential remediation:** Preserve exact numbers through a numeric
representation supported by ConfigKit, if available. If that contract cannot
represent a particular `json.Number` exactly, surface a deferred source error
rather than returning a rounded float or a numeric-looking string. Check numeric
value equivalence rather than textual spelling: alternate exponent spellings
need not be preserved. Add focused regression coverage for both source helpers
and contrast with the already-preserving runtime path.
## 2. External Schema Resources Do Not Reach The Provider Constraint
**Priority:** P2. Confirmed schema-packaging gap; remediation requires choosing
how provider-facing schemas accommodate the existing local-reference feature.
**Code:** [preparedValidation.SchemaDocument](../../../internal/validate/standard_validator.go:63),
[StandardValidator.PrepareValidation](../../../internal/validate/standard_validator.go:74),
[FSValidator.PrepareValidation](../../../internal/validate/standard_validator.go:126),
and [structuredOutputFromValidationPlan](../../../internal/usecase/runner.go:419).
**Circumstances:** A JSON Schema output contract uses a root schema referring to
another file within the configured schema source. The
[schema format contract](../../formats.md#schemas) supports such references.
Preparation compiles the transitive resources locally, but `SchemaDocument`
exposes only the unchanged root document. The runner always uses that document
for the native provider constraint in JSON Schema mode.
**Expected:** The provider-facing constraint should carry the schema resources
needed to interpret the supported local references, or there should be an
explicitly chosen way to retain local validation without submitting an
unresolvable native constraint. This is not a promise that every provider accepts
every schema dialect feature.
**Actual / reproduction:** With these files under `WithSchemaFS`:
```text
root.json: {"type":"object","properties":{"x":{"$ref":"child.json"}},"required":["x"]}
child.json: {"type":"integer"}
```
`Prepare` succeeds, but `PreparedRun.StructuredOutput.JSONSchema.Schema` contains
only the root object, including the literal `$ref: "child.json"`. No child
document, bundled definition, or reference rewrite is present. The HTTP adapter
passes that object unchanged to `response_format.json_schema.schema`. An upstream
provider has no access to the application filesystem or private `fs.FS`, so it
cannot resolve that reference from the request. A provider requiring resolvable
native schemas can reject generation before local validation or repair is
reached. No live-provider rejection was tested.
The internal source document accurately describes the existing root-only
projection; the gap is between the supported local schema-resource graph and
its mandatory projection into an outbound constraint, rather than a failure of
local compilation or prepared-resource freezing. Self-contained schemas are
unaffected.
**Potential remediation:** Produce a self-contained provider schema from the
operation's captured resources, preserving reference semantics, identifiers,
anchors, and recursion. Avoid naive recursive textual substitution. An
alternative is an explicit choice to use local-only schema validation when
native submission cannot represent the resource graph. Any alternative that
restricts existing schema sources or silently disables native structured output
requires a deliberate compatibility/policy decision. Protect the selected
behavior with an offline test combining transitive schema preparation and the
actual outgoing request, while retaining frozen local validation for
`RunPrepared` and repair.

View File

@@ -0,0 +1,147 @@
# Algorithmic Efficiency Audit
Reviewed 2026-11-04 against commit `0d27252`.
## Scope And Method
Reviewed PromptKit-owned configuration assembly, provider-value conversion and
copying, rendering and requirement analysis, inventory selection, prepared-run
ownership, validation orchestration, repair formatting, retry coordination,
observations, and capacity management. Findings below follow from implementation
and operation counts; no benchmarks or live-provider calls were needed.
The public facade delegates to internal components. ConfigKit supplies the final
configuration; PromptKit constructs immutable runtime indexes and captured
templates. Execution uses operation-local validation plans, shared backend
capacity, and a generation coordinator. Inspection reads the captured snapshot.
ConfigKit and ReleaseKit internals are outside this repository audit.
### Deliberate Choices Preserved
Checked current internal documentation and relevant retired plans in Git history,
including the performance plan at `8a5c0de`, configuration adoption at `5f7b194`,
and collections at `89af2a1`. This audit does not reopen:
- lexical inventory ordering, recursive provider-map overlay semantics, or
conservative requirement-discovery semantics;
- captured parsing, per-render artifact-text reuse, operation-local schema
compilation, frozen retry payloads, and linked-list FIFO permits with
constant-time cancellation removal;
- the existing JSON depth/work bounds, output/diagnostic limits, or copies
deliberately retained to protect public and injected-collaborator ownership;
- synchronous filesystem/template/schema operations or the selected retry and
repair budgets.
The opportunities below concern implementation work that those choices do not
require. Complexity statements use expected constant-time Go map operations and
treat identifier comparison/hashing cost separately. No new public performance
guarantee, cache, caller limit, or release gate is proposed.
## 1. Condition Evidence Unions Can Require Quadratic Work
**Priority:** P2. Requirement-inspection path; most relevant to deeply nested
conditions rather than ordinary small templates.
**Code:** [evaluateCommand](../../../internal/prompt/requirements.go:142) and
[mergeConditionEvaluations](../../../internal/prompt/requirements.go:318).
Each comparison recursively evaluates its operands, then allocates fresh
`missingVariables` and `contentInputs` maps and copies both operands' accumulated
evidence. A left-heavy expression with distinct missing caller keys, such as
successively nesting `eq (eq .v0 .v1) .v2`, carries an increasingly large missing
key set through each ancestor. Template parsing accepts these expressions;
inspection can encounter them even when their eventual execution would fail or
the inspection result is partial.
For an expression of size n, successive unions copy approximately
2 + 3 + ... + n entries: **Theta(n²) total set work and cumulative allocation**.
The final unique evidence has only O(n) entries. This is separate from the
previously fixed repeated syntax-subtree scan: visiting each syntax node once
does not eliminate repeated copies of the evidence produced by those nodes.
**Opportunity:** Transfer ownership of temporary sets and merge the smaller set
into the larger one, rather than copying both at every comparison. For this
expression family that is O(n); small-to-large merging gives O(n log n) aggregate
set-insertion work across general expression trees. Another option is an
operation-local evidence accumulator that records each discovery directly,
while keeping only condition truth/limitation state on recursive return values.
Choose the simpler approach after checking temporary-set ownership. Preserve
current grammar, classifications, deduplication, sorted output, cancellation,
and partial-result behavior; interpreting more expressions is not necessary.
## 2. Recursive Provider Overlay Repeatedly Clones Descendant Subtrees
**Priority:** P3. Construction-time improvement with an existing depth bound.
**Code:** [overlayProviderMap](../../../internal/configuration/targets.go:131).
The helper deep-copies the entire lower map before considering overrides. When
both values at a key are maps, it recursively invokes itself on a descendant
that was already copied, and copies that whole descendant again. The first copy
is then replaced and discarded. All descendants on an overlapping path can be
copied once per ancestor.
Let N be the lower tree's node count and D its overlapping map depth. Node-copy
work can reach **O(ND)**. For a narrow chain it is the triangular sum
D + (D-1) + ... + 1. A wide K-entry leaf below D overlapping ancestors is cloned
roughly D times instead of once. `jsonvalue.CopyMap` also sorts map keys, so the
same wide leaf's sorting work is repeated at those ancestors.
The existing maximum container depth of 100 is important: this is **not an
unbounded quadratic-in-N claim under today's fixed limits**. It is an avoidable
depth-dependent multiplier in traversal and cumulative allocations within those
limits, multiplied again when multiple profiles overlay the same backend. That
bound and the recursive-map/value-replacement semantics remain selected choices.
**Opportunity:** Clone the lower tree once, then overlay into the owned clone
without re-cloning existing descendant maps. Copy incoming replacement values
where needed to preserve isolation from configuration inputs. Alternatively,
construct the merged output in one traversal. Either removes the repeated
ancestor copies: expected O(N + M) merge traversal for lower/upper trees of sizes
N and M, apart from the retained validation/sorting costs. Preserve null and
array replacement, empty-map behavior, input ownership, and existing JSON
validation/bounds; do not replace ConfigKit's configuration machinery.
## 3. Filtered Listings Copy And Scan The Complete Inventory
**Priority:** P3. Scalability opportunity for engines with many namespaces.
**Code:** [filterIdentityList](../../../internal/engine/engine.go:306),
[ListPrompts](../../../internal/engine/engine.go:270), and the
[configuration inventory methods](../../../internal/configuration/listing.go:14).
A named-namespace request first obtains a detached copy of all N identities and
then scans it to build its k matching results. Even an absent namespace costs
**Theta(N) time and O(N) temporary storage**, rather than work proportional to
the selected inventory. Listing C namespaces separately costs Theta(CN); when
each has a bounded number of entries, that becomes quadratic in total inventory
size despite only O(N) total returned entries.
The collection implementation plan at `89af2a1` expressly allowed namespace
buckets or sorted ranges to avoid repeated full scans; it did not require the
present whole-inventory-copy algorithm. The lexical ordering and caller-owned
result contracts do not prevent indexed selection.
**Opportunity:** Capture per-kind namespace buckets or ranges alongside existing
construction-time inventories, then copy only the selected entries. Buckets
allow expected O(1 + k) selection/copy; binary search over sorted full IDs can
support named-prefix ranges in O(log N + k). Keep unqualified and all-ID modes
correct without assuming unqualified entries form one contiguous lexical range.
Resolve filters before requesting the inventory, and retain exact namespace
matching, full IDs, ordering, context behavior, and independent returned slices.
An all-ID request must still spend O(N) to return N owned entries; that is not a
defect. No public pagination API or mutable cache is needed.
## Review Outcome
These three opportunities are supported by direct operation-count analysis;
they are not measured latency claims. The largest asymptotic issue is growing
condition-evidence copying. The overlay finding is explicitly bounded, and the
listing finding matters as independent namespace count grows.
No additional ordinary correctness defect was established in this pass. The
findings in [the correctness audit](01-correctness.md) remain separate and were
not duplicated here. No production code, tests, dependencies, or established
behavior were changed, so the successful race-suite/vet evidence recorded there
still applies to the unchanged implementation; those checks were not rerun just
for this documentation change.

View File

@@ -0,0 +1,216 @@
# Complexity And Maintainability Audit
Reviewed 2026-11-04 against commit `5adc1c1`.
## Method And Architectural Assessment
Used the local codebase-memory graph's cyclomatic/cognitive complexity,
function-length, and call-relationship tools to locate candidates. Reviewed
functions and methods, including test hotspots, rather than relying on
function-only rankings. Inspected implementation, callers, owning interfaces,
and current architecture/internal documentation before selecting findings.
Metrics are analyzer-specific indicators, not correctness proofs, acceptance
thresholds, or requirements to split every large switch.
PromptKit's overall boundaries remain useful: a public facade adapts values and
errors; configuration interprets ConfigKit's final handoff and captures runtime
indexes; the runner coordinates preparation and execution; generation owns
attempts/retries; capacity owns admission and permits; transport and schema
adapters own external protocols. No repository-wide architectural rewrite or new
public abstraction is warranted by this review.
Representative hotspot metrics from the current graph:
| Function | Cyclomatic | Cognitive |
| --- | ---: | ---: |
| `requirementWalker.visit` | 50 | 137 |
| `validateEffective` | 39 | 132 |
| `requirementWalker.evaluateCommand` | 26 | 103 |
| `providerValueForMapping` | 33 | 84 |
| `jsonvalue.copyValue` | 29 | 66 |
| `loaded.buildExecutionProfiles` | 27 | 58 |
| `loaded.materializePrompt` | 30 | 52 |
| `Coordinator.generate` | 24 | 49 |
The findings below are behavior-preserving refactoring opportunities, not
confirmed bugs or automatic release blockers. They cover both package-local
complexity and interactions between packages. No production code, tests, or
dependencies were changed, and no empirical testing or benchmarks were needed.
## 1. Consolidate Schema Compilation And Express The Runner's Actual Interface
**Code:** [validator interfaces](../../../internal/validate/validator.go:10),
[schema implementations](../../../internal/validate/standard_validator.go:45),
[runner preparation](../../../internal/usecase/runner.go:398), and
[engine assembly](../../../internal/engine/engine.go:133).
**Complexity:** The validator maintains direct validation and prepared
validation, with separate filesystem and `fs.FS` compilation paths. Compiler
construction, resource registration, error wrapping, and cancellation checks
are repeated across direct schema-validation methods and `PrepareValidation`.
The package's constructors return `Validator`, although the runner actually
requires `ValidationPreparer` and discovers it through a runtime type assertion.
An implementation satisfying the declared constructor dependency can therefore
still be unsuitable for the execution path.
**Opportunity:** Make the runner's preparation dependency explicit in its
internal field/constructor types, updating assembly and focused test
collaborators together. Retain the direct validation interface where callers
actually need it. Consolidate compilation behind one internal operation, with
small source-specific loading/URL/loader adapters; direct and prepared callers
can reuse that operation rather than separately implementing registration and
compilation. No public validator API or generic source framework is needed.
**Preserve:** Direct validation currently rejects malformed JSON before trying
to compile its schema; simply implementing direct `Validate` as eager
`PrepareValidation` followed by plan validation would change that error/I/O
ordering. Keep that short-circuit, operation-local compiled resources, prepared
freezing, schema dialect and containment rules, synchronous cancellation
precedence, and the runner's existing nil-validator behavior where used.
Coordinate schema representation changes with correctness-audit finding 2,
without treating this refactor alone as its fix.
## 2. Decompose Configuration Validation And Resource Materialization
**Code:** [validateEffective](../../../internal/configuration/load.go:410),
[materializePrompt](../../../internal/configuration/prompts.go:58),
[buildExecutionProfiles](../../../internal/configuration/targets.go:14), and
[resolution callbacks](../../../internal/configuration/resolve_prompts.go:10).
**Complexity:** `validateEffective` combines namespace checks, reference indexes,
backend settings, capacity arithmetic, smoke metadata, profile bounds, and prompt
references in one nested traversal. Prompt materialization then combines input
validation, presence projection, content-file I/O, message validation, template
compilation, output-contract construction, hashing, and inspection assembly.
Target assembly is another long presence-state decision chain. Reviewing a
single field change requires following several distinct responsibilities inside
large functions.
**Opportunity:** Keep the top-level traversal/index preparation visible, but
delegate coherent record-level work to backend, profile, and prompt helpers
with explicit record/path/index arguments. Isolate prompt input projection,
message materialization/compilation, and output-contract construction into
small functions returning the values the caller needs. Keep target presence
interpretation in one identifiable helper rather than distributing it between
new callers. Organize files by those responsibilities only where that helps
navigation; moving unchanged code between files is not itself the improvement.
**Preserve:** Validate only final ConfigKit-provided values. Keep recorded
normalization, field presence/null distinctions, exact references, error
identities, source-relative resource contexts, lazy metadata capture, and
construction-time template/hash capture. Materialization legitimately combines
validation with resource access; this finding does not require moving every
check into a new validation pass or aggregating formerly fail-fast errors.
Avoid a reflection-driven field schema that obscures the domain rules.
## 3. Untangle Requirement Traversal, Expression Evaluation, And Evidence
**Code:** [requirement walker](../../../internal/prompt/requirements.go:356),
[syntax dispatch](../../../internal/prompt/requirements.go:451),
[condition evaluation](../../../internal/prompt/requirements.go:93), and
[command traversal](../../../internal/prompt/requirements.go:652).
**Complexity:** The approximately thousand-line file contains mutually recursive
syntax walking and partial expression evaluation, branch reachability, caller
scope tracking, template-call handling, evidence promotion, issue construction,
and result sorting. `if` and `with` repeat branch orchestration. `input`, `index`,
root variables, and skipped-path behavior appear in both evaluation and
conservative traversal. Correctness depends on the interaction of return-value
flags and mutable walker state such as `rootKnown`, `dependencies`, and the
unknown-effect flags, not just the local switch case.
**Opportunity:** Separate the existing roles into small internal units: syntax
recognition/evaluation, branch/scope traversal, and evidence/result accumulation.
Factor shared `if`/`with` orchestration while making their different body-dot
rules explicit. Reuse recognition of supported command shapes instead of
duplicating it between evaluative and inventory-only paths. Make existing
template scope entry/exit state handling explicit. Keep a small enum/value model
rather than adding a general expression interpreter or pluggable AST framework.
**Preserve:** The deliberately conservative recognition scope, skipped-path
inventory, optional `index` behavior, named-template limitations, incomplete
results, and cancellation semantics. Traversal and evaluation are not
interchangeable: inactive paths must retain recognizable names without
evaluating caller-derived names. Any shared helper must express that distinction.
The evidence-union efficiency finding in [audit 02](02-efficiency.md) is related
but separate; a mechanical file split would not fix it.
## 4. Clarify Ownership And Policies Across The JSON Value Walkers
**Code:** [source mapping conversion](../../../provider_values.go:31),
[bounded JSON copying](../../../internal/jsonvalue/jsonvalue.go:67), and
[public-value copying](../../../convert.go:406).
**Complexity:** Three recursive reflection-based paths independently handle
interfaces/pointers, maps, sequences, scalars, and nil values. Each adds its own
policy: source conversion translates into ConfigKit-compatible containers and
defers unsupported-value errors; `jsonvalue` validates and bounds owned copies;
facade copying detaches already-valid values while preserving their types.
Similar-looking switches conceal different assumptions and failure behavior.
The numeric conversion defect in correctness-audit finding 1 demonstrates a
concrete risk at one of these boundaries, but is not a new finding here.
**Opportunity:** First make the three responsibilities explicit at their call
boundaries. Isolate source-number encoding from structural traversal, and
identify reusable scalar/indirection/container-copy primitives only where their
semantics truly match. Prefer removing a redundant traversal path or reusing an
existing trusted-value copy operation over maintaining another policy-rich
reflection switch. Keep ConfigKit-specific mapping encoding in its adapter;
do not teach the source-neutral domain about ConfigKit.
**Preserve:** Supported concrete types, nil behavior, cycle/depth/work handling,
caller ownership, and deferred source errors. In particular, passing source
builders through rejecting `CopyMap` would introduce validation before the
ConfigKit handoff and is not an acceptable consolidation. Nor should an
infallible facade projection acquire new rejection behavior merely to reuse a
validator. A universal callback-configured reflection framework could increase
complexity; retain separate small adapters where their policies differ.
## 5. Extract Attempt Accounting From Generation Control Flow
**Code:** [Coordinator.generate](../../../internal/generation/coordinator.go:98),
[generateInjected](../../../internal/generation/coordinator.go:203),
[execute](../../../internal/generation/coordinator.go:303), and
[diagnostic evidence consumers](../../../internal/usecase/smoke_tester.go:246).
**Complexity:** The coordinator interleaves permit acquisition, dispatch,
cancellation, retry decisions, observer events, diagnostic evidence, and
presence-aware usage accumulation. The built-in and injected paths repeat
generation-number setup, evidence finishing, attempt projection, and usage
projection. The injected path also repeats its non-dispatched cancellation
reporting. `execute` returns five parallel values, increasing the amount of
state the caller must keep aligned while constructing two reporting forms.
**Opportunity:** Return one small internal attempt-result value containing
outcome and timing/dispatch facts, and extract common projection/accounting
operations where evidence is genuinely shared. Keep the retry loop primarily
about eligibility, budget, waits, and terminal outcomes. Keep injected and
built-in dispatch strategies explicit rather than manufacturing HTTP facts for
injected clients or introducing an extensible retry framework.
**Preserve:** Release active permits before callbacks/backoff, one-shot injected
dispatch, frozen built-in requests, usage-presence distinctions, private
diagnostic evidence, ordinary observer boundaries, and retention of decoded
facts before cancellation discards ordinary results. The HTTP adapter should
continue owning transport facts; accounting extraction must not push provider
protocol knowledge into the runner or observation package.
## Prioritization And Non-Findings
Start with the schema interface/compilation consolidation and configuration
helpers: their responsibilities provide comparatively clear refactoring seams.
The walker and generation coordinator need narrower, behavior-preserving units
because their state transitions are more subtle. Coordinate source conversion
cleanup with the independently required number-preservation fix.
High scores alone did not justify findings for explicit public error-category
switches, presence-aware source-field encoding, regular-file/schema read loops,
or table-driven tests. Those branches often directly express selected contracts.
Large facade files also include substantial GoDoc and straightforward value
projection, which is not evidence that public/internal separation has failed.
Keep useful test scenarios; do not rewrite tests solely to reduce their metrics.
No additional ordinary correctness defect was established in this pass.
[Audit 01](01-correctness.md) and [audit 02](02-efficiency.md) retain their own
findings. No implementation changes or additional consumer guarantees are
implied by this refactoring catalog.

View File

@@ -0,0 +1,215 @@
# Resource Management Audit
Reviewed 2026-11-04 against commit `fb87fab`.
## Scope And Evidence
Reviewed file and response-body ownership, bounded reads, HTTP client/transport
lifetimes, timeout contexts and retry timers, backend admission and permits,
prepared-handle cleanup, retained snapshots, JSON conversion, rendering buffers,
schema resources, and repair feedback. The architecture uses immutable
engine-lifetime configuration, invocation-local execution/validation resources,
and caller-owned public results. Production code launches no PromptKit-owned
goroutines or background workers requiring a shutdown protocol.
Evidence consists of source/call-graph review, relevant existing tests, and a
small temporary offline diagnostic outside the repository. The diagnostic used
a controlled HTTP transport, counted body reads/closure, inspected the existing
prepared-handle owner reference, and compared string backing addresses. No
benchmark, heap-size measurement, deliberate exhaustion, live network call,
production change, or new repository test was needed.
Focused existing tests passed for content-file closure, HTTP error-body limits
and closure, independent attempt contexts/body closure, read cancellation,
prepared discard/ownership, diagnostic formatting, synchronous schema
cancellation, and repeated-value JSON work bounds. These tests establish the
existing protected behavior; they do not cover all findings below.
## 1. Authentication Diagnostics Read An Unbounded Upstream Body
**Priority:** P2. Unbounded remote-response consumption; a finite limit would be
a new policy decision, not enforcement of an existing documented byte limit.
**Code:** [runOpenRouterKey](../../../internal/usecase/smoke_authentication.go:66),
[recognizedOpenRouterKeyResponse](../../../internal/usecase/smoke_authentication.go:139),
and [diagnostic HTTP execution](../../../internal/llm/http_evidence.go:129).
**Circumstances:** An authentication endpoint returns HTTP 200 with a large or
continuously growing body. Recognition calls unrestricted `io.ReadAll` and then
unmarshals the whole document into raw-message maps. Neither declared length nor
streamed bytes are bounded on this path. Generation's 16 MiB success-body limit
and 64 KiB error-body limit do not apply to diagnostic GETs.
**Consequences / evidence:** The offline diagnostic supplied a valid small
identifying `data` object plus 4 MiB of irrelevant padding. The probe read all
4,194,375 bytes, closed the body, and reported credential acceptance. Increasing
the padding has no corresponding byte-budget check. A fast misconfigured or
unexpected endpoint can consume substantial memory before its timeout; concurrent
authentication probes multiply consumption because HTTP-only diagnostics bypass
generation admission. A timeout bounds elapsed time, not bytes allocated within
that time. There is no demonstrated descriptor leak: the body is closed.
**Potential remediation:** Select a finite body budget appropriate to this small
diagnostic protocol, enforce it for both declared and streamed/underreported
lengths, and close without unbounded draining on overflow. Preserve single-JSON
framing and the distinction between unknown acceptance and observed rejection;
an oversized response must not be reported as credential rejection. Decide the
budget and overflow report classification explicitly, then update the owning
contract and tests. This need not impose new limits on application prompts,
artifacts, or generated output.
## 2. Convenience Source Conversion Expands Shared Graphs Before Bounded Loading
**Priority:** P2. Potentially exponential allocation before upstream resource
checks can run.
**Code:** [providerMapForMapping](../../../provider_values.go:19),
[providerValueForMapping](../../../provider_values.go:31),
[ProfileSource](../../../profiles.go:33), and
[BackendSource](../../../backends.go:100).
**Circumstances:** A caller builds a small acyclic Go value graph containing
repeated map/slice references. Each level can point twice to the same child:
```go
value := map[string]any{"value": true}
for range depth {
value = map[string]any{"left": value, "right": value}
}
```
The source converter tracks only active ancestors to identify cycles. Once one
branch finishes, its visit is removed, so the sibling independently converts
the same entire child. It allocates a fresh expanded tree before calling
`configkit.Mapping`. For d levels, O(d) distinct input maps become
Theta(2^d) output maps. The depth guard does not constrain width or total work;
this expansion can become impractical well below depth 100.
**Consequences:** Programmatic source creation can exhaust memory before
`NewEngine` or ConfigKit's deferred diagnostics are reached. This is not a claim
that ConfigKit lacks bounds: the selected ConfigKit v1.1.0's mapping builder
checks node, byte, and depth budgets during its own snapshot traversal
(`internal/engine/value.go`, `valueBuilder.build`). It is called only after
PromptKit has already expanded the graph. PromptKit's later `jsonvalue` node
budget is also too late to protect this conversion.
**Potential remediation:** Memoize completed structural conversions so repeated
input containers reuse already-converted containers until ConfigKit snapshots
them with its existing bounds. Keep active-ancestor tracking distinct from the
completed memo so cycles still become deferred source errors. Account correctly
for container identity, including distinct slice views. Alternatively, seek
upstream support for the additional Go value forms so this conversion can be
removed. Preserve immediate source ownership and deferred failure behavior;
do not introduce a PromptKit raw-source validation pass or duplicate ConfigKit's
configuration limits to fix this pre-handoff allocation problem.
## 3. Inert Prepared Handles Retain Their Entire Execution Owner
**Priority:** P3. Avoidable retained-memory dependency after use/discard.
**Code:** [PreparedExecution.owner](../../../internal/usecase/prepared_execution.go:24),
[Discard](../../../internal/usecase/prepared_execution.go:96),
[claim](../../../internal/usecase/prepared_execution.go:172), and
[payload clearing](../../../internal/usecase/prepared_execution.go:188).
**Circumstances:** A consumer retains a completed or discarded handle for its
supported `Details` access, while replacing or dropping the originating engine.
Payload clearing releases the per-execution plan, credential, observer, and
execution snapshot, but the handle's `owner *Runner` remains unchanged.
**Consequences / evidence:** The reference path is
`handle -> Runner -> captured repositories/clients/validator/capacity manager`.
Thus a small inert handle keeps the originating engine's complete runtime
snapshot and collaborators reachable, not just its deliberately retained details.
The offline diagnostic confirmed the same nonnil runner pointer after both
discard and successful execution. Retaining handles from successive engine
reconstructions can retain successive large snapshots. Keeping details itself
is required behavior, and this has no additional owner-retention cost while the
application still retains the same engine anyway.
**Potential remediation:** Clear the owner reference when the handle becomes
inert, checking ownership under the same mutex to avoid racing the current
unlocked owner read. Another option is a small standalone identity token shared
by a runner and its handles, which establishes ownership without retaining the
runner graph. Such a token must have reliable distinct identity; do not rely on
distinct zero-sized allocations comparing unequal. Preserve related namespace
views, foreign-owner rejection, single claim, discard/run races, and details
availability. No engine `Close` method or automatic closing of caller-owned
collaborators is implied.
## 4. Repair Feedback's Output Bound Does Not Bound Its Temporary Serialization
**Priority:** P3. Avoidable memory amplification on validation failures.
**Code:** [formatRepairDiagnostics](../../../internal/usecase/repairer.go:119).
**Circumstances:** A failed schema validation produces a large diagnostic or many
diagnostics and repair is enabled. The formatter normalizes every string, marshals
the complete array, then discovers that it exceeds 64 KiB. It subsequently
marshals individual full entries again before truncating one of them.
**Consequences:** Although the returned feedback is bounded correctly, temporary
storage scales with the complete diagnostic text, including text omitted from
the request. JSON escaping can amplify its size, and normalization adds another
copy for invalid UTF-8. Large validation messages and concurrent repairs can
therefore create large additional buffers despite producing tiny feedback.
The complete diagnostics already exist for the validation result; these extra
full encodings do not help preserve them. Existing formatting tests check output
bounds/data preservation, not scratch-space use.
**Potential remediation:** Encode feedback incrementally with bounded buffering
and lookahead sufficient to distinguish a complete array from an omitted suffix.
Stop encoding omitted text rather than allocating its full JSON representation.
Keep the existing 64 KiB result bound, ordered entries, rune-safe truncation,
valid UTF-8, JSON escaping, and in-bound omission notice. Leave the original
validation diagnostics and full previous candidate unchanged. This is an
implementation opportunity, not a claim that the existing contract promises a
64 KiB peak heap footprint or permission to add a diagnostic-input limit.
## 5. Short Normalized Session IDs Can Pin Large Input Strings
**Priority:** P3. Avoidable backing-buffer retention in retained metadata.
**Code:** [NormalizeSessionID](../../../internal/domain/session.go:10),
[preparation](../../../internal/usecase/runner.go:312), and
[prepared metadata construction](../../../internal/usecase/runner.go:491).
**Circumstances:** A direct or rendered session ID contains substantial leading
or trailing whitespace around a short valid identifier. `strings.TrimSpace`
returns a substring; normalization validates that substring and returns it
without detaching its backing storage. Public conversion preserves the string.
**Consequences / evidence:** An offline public-API probe prepared a request with
1 MiB of spaces followed by `s`. The returned session ID had length one but its
backing address still pointed inside the large original allocation. Retaining a
prepared value, handle details, or run result can therefore retain that buffer
after the original request/rendering buffer would otherwise be collectible.
The 256-code-point semantic limit does not prevent this retention. This does not
require changing which padded identifiers are accepted.
**Potential remediation:** Detach accepted normalized session metadata with a
small owned string, for example `strings.Clone` after validation. Preserve empty
omission, Unicode trimming/counting, hashes, and wire content. Review other
short canonical metadata returned by trimming for the same pattern when making
the change; do not indiscriminately clone large prompt/message payloads or add a
new raw-session-size restriction.
## Cleanup Checks And Non-Findings
No additional descriptor, timer, connection-ownership, or goroutine leak was
established. Artifact/schema handles use deferred closure; prompt content handles
close after reading; generation response bodies close on success and decoding,
status, and size failures. Diagnostic bodies close, and their timeout cancellation
is attached to body closure. Retry timers stop on exit. Admission/active permits
release on errors, cancellation, and panic unwinding. Prepared payloads otherwise
clear as intended.
Caller-selected artifact/schema/prompt sizes, intentionally unlimited backends,
and caller-retained ready handles are not classified as bugs merely because
their total memory can grow. Synchronous dependency calls may delay cancellation
by design, rather than being detached into leaking goroutines. Borrowed HTTP
transports/filesystems/clients remain subject to their ownership contracts; this
audit proposes no blanket shutdown or disposal API.
Previously recorded repeated-copying issues remain in
[the efficiency audit](02-efficiency.md); they are not duplicated here.

View File

@@ -0,0 +1,197 @@
# Error-Handling Audit
Reviewed 2026-11-04 against commit `6b1323d`.
## Scope And Evidence
Reviewed error detection, wrapping, public translation, diagnostic reporting,
cancellation and timeout handling, retries, validation/repair failures, and
cleanup across construction, configuration inspection, prompt/profile selection,
artifact reading, rendering, ordinary and prepared execution, generation, and
smoke diagnostics, plus the small release-entrypoint wrappers. Contracts were
checked against GoDoc, integration references, the architecture policy, ADR 0001,
and existing tests.
Configuration composition belongs to ConfigKit; PromptKit validates and
interprets its final handoff and preserves upstream load errors. The runner adds
operation context, generation coordinates attempts and status retries, the HTTP
adapter classifies/redacts provider failures, and the public facade translates
internal categories. Content-validation failures and concluded diagnostic
failures are generally data rather than Go errors under their respective
contracts. Prepared handles permit one execution claim; failure after a
successful claim does not make the handle reusable.
Evidence includes source/call-graph review and a temporary public-API diagnostic
outside the repository. It used injected collaborators and a controlled HTTP
transport; it made no live network calls. No production code or repository
tests were changed.
Existing error, failure, cancellation, timeout, panic-cleanup, and retry tests
passed using:
```sh
go test -count=1 -run 'Error|Failure|Fail|Cancel|Timeout|Panic|Release|Retry|Retries' \
. ./internal/llm ./internal/generation ./internal/usecase \
./internal/capacity ./internal/validate ./internal/artifact \
./internal/configuration
```
These tests establish protected behavior but do not cover the three reproductions
below. Findings from the earlier correctness and resource-management audits are
not repeated here.
## 1. Nested Collaborator Errors Can Hide The Owning Operation's Public Category
**Priority:** P2. Confirmed public error-classification defect.
**Code:** [publicErrorFor](../../../errors.go:63),
[mapPublicError](../../../errors.go:16), and
[wrapGenerationError](../../../internal/usecase/runner.go:271).
**Failure condition:** An injected `LLMClient` or `ArtifactReader` returns an
error obtained from another PromptKit operation. Ordinary mapped errors retain
their internal chain. `publicErrorFor` searches that complete chain with
`errors.Is` and chooses the first matching category in a fixed priority order,
regardless of which operation the outer runner classified.
**Expected:** A model-client failure matches `ErrLLMGenerate`; an artifact-reader
failure matches `ErrArtifactLoad`. The injected cause remains available through
`errors.Is`, as their [public GoDoc](../../../engine.go:49) specifies. Preserving
the nested cause may also preserve its public categories; it must not replace
the category for the operation that just failed.
**Actual / reproduction:** A child engine's `Run` returns a missing-prompt error.
A parent engine selects a valid prompt, then its injected client returns that
child error. The outer error contains the text `failed to generate output`, but:
```text
errors.Is(err, ErrLLMGenerate) = false
errors.Is(err, ErrPromptNotFound) = true
errors.Is(err, childError) = true
```
Returning the same child error from an injected artifact reader similarly
produces `errors.Is(err, ErrArtifactLoad) == false`. This is reproducible using
only public APIs, without importing internal sentinels. Consumers can therefore
miss the documented failure category when composing PromptKit-backed adapters.
**Potential remediation:** Translate the owning operation's classification
independently of an arbitrary collaborator's nested chain, while retaining the
original injected cause. Explicit boundary/stage information or mapping at the
operation boundary can do this. Merely reordering the global switch risks
breaking intentional direct-selection behavior: a genuinely missing prompt or
profile must retain its documented distinction from other load failures.
Protect nested-client and nested-reader cases through public-boundary regression
coverage; preserve existing capacity, retry-exhaustion, provider-error redaction,
and context identities.
## 2. Successful-Status Body Read Failures Are Misreported As Malformed JSON
**Priority:** P2. Confirmed loss of failure provenance and cause.
**Code:** [decodeOpenAIChatResponse](../../../internal/llm/openai_compatible_client.go:373).
**Failure condition:** A 2xx response body returns a non-EOF I/O error other than
`context.Canceled` or `context.DeadlineExceeded`, either during the first decode
or while checking the required final EOF. For example, a connection can fail
while reading the response stream after headers were received.
**Expected:** Distinguish an underlying read/transport failure from evidence of
invalid JSON or trailing data. Retain the read cause through a redacting error
so callers can inspect it with `errors.Is`/`errors.As`, consistent with the
[transport category](../../../engine.go:65). Do not report trailing data when the
reader failed without supplying any. This does not change the established
classification of invalid/truncated JSON or introduce transport retries.
**Actual / reproduction:** The controlled transport returned HTTP 200 and a body
that supplied either a partial JSON prefix or a complete valid chat envelope,
then returned a distinct read-error sentinel. Both executions returned
`ErrLLMGenerate` plus `ErrMalformedResponse`, did not match `ErrTransport`, and
discarded the sentinel. For the complete envelope, the error said `response
contains trailing data`, although no trailing bytes were supplied. Inference
smoke diagnostics likewise reported `malformed_response` with nil Go error
rather than a transport failure. Thus the issue affects both public execution
errors and factual diagnostic classification.
The integration contract explicitly guarantees exact causes for
[`http.Client.Do` failures](../../integrations/openai-compatible-chat.md#response-handling),
not every decoder error. The finding concerns the separate loss and
misclassification of an actual body-read failure, rather than asserting a new
requirement to expose JSON parser errors or provider content.
**Potential remediation:** Track underlying reader failures separately from
decoder/framing failures, or reliably distinguish those classes before replacing
the decode error. Reuse the redacting transport-error path for genuine read
failures. Keep syntax/type errors, EOF-truncated JSON, extra JSON values, and
size overflow in the existing malformed-response category; preserve cancellation
precedence and bounded reads/closure. Add focused coverage for interrupted
initial decoding and interrupted EOF verification, including diagnostic failure
classification. Default formatting must remain free of raw response content and
private transport details.
## 3. Inference Diagnostics Swallow Some Invalid Request-Construction Errors
**Priority:** P2. Confirmed mismatch with the diagnostic error contract.
**Code:** [runInference](../../../internal/usecase/smoke_tester.go:207),
[generation preparation](../../../internal/generation/coordinator.go:96), and
[request payload validation](../../../internal/llm/openai_compatible_client.go:511).
**Failure condition:** An inference diagnostic's effective settings are invalid
in a way detected by built-in request construction rather than target selection.
A runtime `Execution.ExtraParams` override containing the reserved key `model`
is one public example. The coordinator returns `llm.ErrInvalidRequest` before
dispatch with no attempt evidence. `runInference` treats every generation error
as a concluded diagnostic failure.
**Expected:** The [SmokeTest contract](../../../smoke_diagnostics.go:238) says invalid
kinds, settings, and selections return an ordinary Go error before diagnostic
work. A locally invalid request should follow that path and expose the public
invalid-request category. This is distinct from a provider rejecting a validly
constructed request or a diagnostic concluding with an observed failure.
**Actual / reproduction:** Calling text-inference `SmokeTest` with
`Execution.ExtraParams: map[string]any{"model": "override"}` returned nil Go
error and a report with `Completed == true`, `Failure == invalid_request`, no
attempts, and zero transport calls. The request-construction error and its
explanation were discarded. The caller cannot use the documented ordinary-error
path to identify this configuration mistake.
**Potential remediation:** Preserve the distinction between local built-in
request-preparation failures and concluded execution failures. Return preparation
errors through the use-case/public invalid-request mapping rather than marking
the diagnostic complete; preparation can remain in the generation adapter, with
enough provenance returned to the caller to make that distinction. Do not infer
that every zero-attempt diagnostic is an error: missing required credentials,
capacity rejection, unsupported operations, and inapplicability have intentional
report semantics. Preserve concluded provider failures and the separate
caller-cancellation behavior. Add one public-boundary regression for the
reserved-field override, retaining existing tests for those intentional reports.
## Reviewed Behavior Not Raised As Defects
- ConfigKit owns raw-source composition and errors before the accepted handoff;
no additional PromptKit raw-source validation is proposed.
- Non-2xx provider detail parsing is deliberately best-effort. Unreadable,
oversized, or unrecognized error bodies retain the received status without
exposing the raw body. That differs intentionally from decoding a 2xx body.
- Retries are limited to selected HTTP statuses. Transport failures, malformed
responses, and arbitrary injected-client failures are not automatically
retried; no expansion is needed for these findings.
- Completed output-validation failures are returned as validation results;
operational schema failures are errors. Repair failure propagation and
exhaustion retain that distinction.
- Caller cancellation, configured request timeouts, cancellation during capacity
acquisition/backoff, synchronous schema operations, and partial diagnostic
evidence have explicit, tested handling. Callback panics propagate while
deferred permit/admission cleanup still runs.
- Ignored SHA-256 writer errors cannot occur with the selected implementation.
Best-effort closure of read-only files and HTTP bodies does not imply a
persistence guarantee or require converting a completed read into a failure.
- Release wrappers use fail-fast shell execution and `exec` to propagate the
selected tool's status. Publication and recovery implementation belongs to
ReleaseKit; no release or publication operation was run for this audit.
No additional state-recovery or cancellation defect was substantiated in this
pass. The findings do not call for a new recovery subsystem, automatic replay of
possibly completed provider work, or changes to source ownership.

View File

@@ -0,0 +1,205 @@
# Security Audit
Reviewed 2026-11-04 against commit `cf0bfd4` and the current worktree. Pending
relocations of earlier audit documents do not change the production baseline
and were left untouched.
## Scope, Architecture, And Threat Boundary
PromptKit is an in-process Go library with a public facade and internal
configuration, execution, transport, validation, and observation components.
ConfigKit owns source loading/composition; PromptKit interprets its final
handoff and captures immutable definitions. Execution reads caller-selected
artifacts, renders captured templates, resolves credentials, and contacts the
selected backend. Prepared handles privately retain one execution's settings.
Inspection and observation expose deliberately selected metadata.
Reviewed configuration and request validation, filesystem/resource selection,
template and JSON handling, credential flow, public formatting and reports,
HTTP requests/redirect handling, provider responses, schema loading, repair,
backend capacity, prepared ownership, and release-entrypoint wiring. The review
included interactions with the selected ConfigKit and JSON Schema dependencies
and an advisory scan of runtime packages.
The [architecture policy](../../policy/architecture.md#repository-and-consumer-boundary)
and [consumer security guidance](../../consumers/pkg-promptkit.md#protect-files-and-generated-data)
place application authorization, untrusted path/endpoint admission, payload-size
policy, and output disclosure with the consumer. Configuration sources,
templates, and injected Go collaborators are application-selected capabilities.
Upstream HTTP response bytes remain an external trust boundary. This review
does not treat ordinary file selection, runtime endpoint overrides, namespace
selection, or supported message roles as authorization mechanisms.
No production code or repository tests were changed, no secrets were collected,
and no external provider or forge was probed. Network activity was limited to
obtaining the public Go vulnerability checker/database and reading official
advisories. This is a source review with focused diagnostics, not a claim that
every possible consumer deployment is secure.
## Evidence
- Source/call-graph review and the applicable public GoDoc, framework formats,
integration contract, policies, and ADR 0001.
- Existing focused tests passed for credential/report formatting, sensitive
configuration inspection/diffs, schema reference containment, provider-body
bounds, JSON value cycles/depth, prepared ownership, and capacity boundaries.
- A temporary offline public-API diagnostic used a controlled HTTP transport
and a synthetic credential. It verified the authentication-body behavior in
finding 2 and confirmed that credential was absent from the serialized report.
- `govulncheck` v1.9.0 scanned `./...` using Go 1.27.0 and the Go vulnerability
database last modified `2026-11-02T22:01:43Z`. The runtime scan covered the
module, its examples, ConfigKit v1.1.0, jsonschema/v6 v6.0.2, YAML v4.0.0-rc.6,
and x/text v0.14.0. It is not a complete audit of ReleaseKit's implementation
or every dependency used only by the release tool.
The text-mode scan reported ten symbol-level standard-library advisories, plus
package/module-only matches. Those are candidate matches, not ten established
PromptKit vulnerabilities. Applicability was checked against the actual
operations described below rather than inferred from interface-call traces.
## 1. The Audited Go Toolchain Predates Applicable Networking Security Fixes
**Priority:** P2. Build/deployment security finding, not a newly discovered
PromptKit algorithm defect.
**Relevant code:** [declared Go minimum](../../../go.mod:3),
[default HTTP-client construction](../../../internal/llm/openai_compatible_client.go:58),
[generation dispatch](../../../internal/llm/openai_compatible_client.go:211), and
[diagnostic dispatch](../../../internal/llm/http_evidence.go:129).
**Condition and impact:** The local toolchain used for this audit is Go 1.27.0.
Applications built with that patch can exercise affected standard-library
networking through PromptKit's normal outbound client:
- [GO-2026-6611](https://pkg.go.dev/vuln/GO-2026-6611) describes excessive CPU
consumption caused by a malicious HTTP/2 peer repeatedly changing initial
stream windows. An application using HTTP/2 against a hostile or compromised
backend, with enough active streams for amplification, can suffer availability
loss. The Go 1.27 fix is in 1.27.2. Body-size limits do not address protocol
processing below response decoding.
- [GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809) adds explicit rejection of
compromised intermediate certificates. The affected conditions depend on the
platform and trust-store state. When the old trust path remains accepted, an
attacker able to present a matching certificate chain and intercept the
connection could compromise confidentiality/integrity of prompts, responses,
or bearer credentials. The Go 1.27 fix is in 1.27.3. This is not a claim that
every default system trust store remains affected.
**Evidence and limits:** The advisories establish affected versions and attack
conditions; source review establishes use of the standard outbound HTTP/TLS
path. No certificate forgery, HTTP/2 attack, or running consumer binary was
tested. The scanner's surprising interface-based paths from unrelated methods
are not relied upon as proof of exploitation. PromptKit is source-only: a
consumer's actual compiled toolchain and transport configuration determine its
exposure. The `go 1.27.0` directive is a compatibility minimum, not a record of
which patch built an application.
**Potential remediation:** Use an appropriate current patched Go toolchain,
at least 1.27.3 within this series for these two fixes, for release validation
and ReleaseKit execution; advise consumers to rebuild affected applications
with patched Go. Recheck advisories against that selected toolchain. This fits
the existing distinction between consumer minimum and maintainer toolchain in
the [release procedure](../../release.md#prepare-the-candidate).
Whether to raise the consumer minimum for the identified fixes is a separate
compatibility decision under the existing policy, not an automatic consequence
of this audit. These fixes do not require custom TLS verification, a blanket
HTTP/2 ban, or a new recurring release gate.
## 2. Authentication Probes Permit Unbounded Remote-Body Memory Consumption
**Priority:** P2. Existing availability finding, confirmed at the external
response boundary. This is the same mechanism as resource-management audit
finding 1, not a second independent remediation item.
**Relevant code:** [authentication recognition](../../../internal/usecase/smoke_authentication.go:139),
[authentication probe execution](../../../internal/usecase/smoke_authentication.go:66),
and [diagnostic HTTP execution](../../../internal/llm/http_evidence.go:129).
**Condition:** A consumer explicitly invokes an `openrouter_key` authentication
diagnostic. The selected endpoint, a compromised provider, or an intermediary
on an operator-selected plaintext connection returns HTTP 200 with a large or
continuously growing body. Recognition uses unrestricted `io.ReadAll` followed
by whole-document JSON unmarshalling. Unlike generation response decoding,
this path has no byte budget. HTTP-only probes also do not use generation
capacity admission.
**Impact:** The remote peer controls memory consumption before the diagnostic
returns. A fast response can cause large allocations or process termination
before a deadline, and concurrent probes multiply the exposure. A configured
timeout bounds time, not bytes allocated during that interval. This requires a
diagnostic call; it is not an unauthenticated inbound attack on a PromptKit
server, and ordinary `Run` does not execute the probe.
**Evidence:** The offline diagnostic returned a minimal recognized account
object and 4 MiB of irrelevant padding. PromptKit read all 4,194,374 bytes,
closed the body, and concluded with credential acceptance. The body used an
unknown content length. No deliberate memory exhaustion was attempted. The
synthetic bearer credential was supplied to the controlled transport and was
absent from the serialized diagnostic report.
**Potential remediation:** Select a finite body budget for this small diagnostic
protocol and enforce it for declared and streamed lengths, closing on overflow
without unbounded draining. Preserve factual reporting: overflow establishes
neither credential rejection nor acceptance. Agree the limit and overflow
classification explicitly; no existing authentication-body byte guarantee is
being enforced. The existing resource-audit remediation can own this fix;
there is no need for separate global limits on prompts or artifacts.
## Other Candidates And Boundary Checks
- **Scanner applicability:** Server-only advisories GO-2026-6603, 6612, 6613,
and 6617 do not establish an attack on PromptKit's outbound client. ECH
server parsing (6607), multipart-form parsing (6608), and Range-serving
behavior (6609) likewise lack a matching operation here. The CONNECT-with-body
condition in 6605 and raw HTTP/2-to-HTTP/1 header forwarding in 6610 are absent
from built-in POST/GET generation and factual diagnostic reporting. Windows
junction-directory creation (6604) is not established by read-only resource
opens. These exclusions concern PromptKit, not all possible code in a host
application.
- **Module-only matches:** x/text advisories GO-2026-5970 and 6629 were not
reported as called vulnerable symbols; no matching use of `norm.Iter` or
`precis.Nickname` was established. html/template advisories 6599 and 6600 do
not apply to the text/template renderer. No separate runtime third-party
vulnerability was substantiated by this scan; that is not a general safety
certification of those modules.
- **Files and schemas:** Ordinary artifacts intentionally allow unrestricted
caller-selected regular files. Host-backed `content_file` contexts use
ConfigKit's rooted filesystem, while the standard schema loader checks
canonical containment and rejects remote reference schemes. Generic `fs.FS`
inputs remain caller-supplied capabilities; logical path checks do not turn an
arbitrary filesystem implementation into a host sandbox. No additional
confinement guarantee is proposed.
- **Injection:** Variables and artifact text are rendered as data rather than
reparsed as Go templates. Captured templates have the input helper and normal
text/template functionality, not a shell or process-execution helper.
Provider payloads use JSON encoding and reserved-field checks. Model output
is data for validation/repair, not executable code or an authorization
decision. Prompt injection and domain-aware interpretation remain consumer
concerns.
- **Credentials and disclosure:** Direct keys are request-scoped, excluded from
supported JSON and public request summaries, and removed from diagnostic
settings and prepared details. Provider error defaults redact details;
explicit provider accessors and arbitrary application content retain the
documented consumer disclosure responsibility. Inspection/diff tests protect
configured sensitive provider paths. No blanket redaction of arbitrary
prompt, schema, variable, error, or provider content is promised.
- **Ownership and availability:** Engine snapshots and copied public values
prevent ordinary cross-call mutation. Prepared handles enforce owner binding
and single claim. Capacity and retry cleanup are tested. Intentionally
unlimited backends, synchronous template/schema dependency calls, and
application-selected large files are not newly classified as vulnerabilities.
The other resource-audit findings remain maintenance items rather than
independently demonstrated remote attacks.
- **Release tooling:** Local launchers pass quoted arguments through `exec`;
there is no PromptKit runtime command-execution surface. The delivery token
path is ignored and selected for committed-file protection. Publication,
token-file permission enforcement, and recovery implementation belong to
ReleaseKit; no publication command was run during this audit.
## Recommended Follow-Up
Use a patched Go toolchain before release preparation and address the existing
authentication-body availability finding. Neither requires a new application
authorization subsystem or stronger filesystem, endpoint, or semantic-output
contracts. No additional PromptKit-specific confidentiality, integrity, or
authorization defect was substantiated within the documented boundary.

View File

@@ -0,0 +1,284 @@
# Test Coverage And Effectiveness Audit
Reviewed 2026-11-04 against commit `f7085d7` and the current worktree. Existing
audit-document moves were preserved. No production code or tests were changed.
## Assessment And Method
The suite broadly follows the [testing policy](../../policy/testing.md): real
ConfigKit composition, filesystems, rendering, validation, and engine assembly
are exercised alongside controlled model clients and HTTP transports. Internal
tests own focused rules and coordination, external-package root tests protect
the public facade, and the three maintained examples have Go workflow tests.
Capacity, prepared-handle state transitions, ownership, cancellation, retries,
repair, observations, and configuration inspection have substantial behavioral
coverage. This is a targeted improvement list, not a recommendation to replace
the test architecture or pursue a percentage threshold.
Both required analyses were performed: review of missing behavioral protection,
and review of assertion/fixture cost and coupling. Evidence includes source and
call-graph review, existing GoDoc and integration contracts, full-suite coverage,
repeated race-enabled execution, and reuse of the earlier error audit's temporary
offline diagnostic. No live provider, paid API, new repository test, production
mutation, or external mutation-testing service was needed.
Validation used the local Go 1.27.0 toolchain:
```sh
go test -count=1 -covermode=atomic -coverpkg=./... -coverprofile=/tmp/promptkit-testing-audit-LHAyNm/coverage.out ./...
go tool cover -func=/tmp/promptkit-testing-audit-LHAyNm/coverage.out
go test -race -vet=off -shuffle=42 -count=2 ./...
```
Both suite runs passed, including the examples. The race run exercised two
shuffled passes; this is evidence against readily reproducible order/race
problems, not proof that no flakes exist. Coverage output remains outside the
repository. The commands are audit evidence, not additional release gates.
Combined coverage is **86.4%**, or 4,782 of 5,536 instrumented statements.
Selected package totals below combine hits across all test binaries. The
per-binary percentages printed by `-coverpkg=./...` are not individual-package
coverage percentages; repeated profile spans were combined before calculating
these totals.
| Component | Combined statement coverage |
| --- | ---: |
| Public facade | 81.0% |
| Configuration | 91.0% |
| Domain | 98.0% |
| Capacity | 98.0% |
| Generation coordination | 91.2% |
| HTTP model client | 94.2% |
| Execution use cases | 90.5% |
| Rendering and requirement inspection | 78.4% |
| Output/schema validation | 75.8% |
Coverage located candidates; the findings below concern specific risks and
assertions, not the percentages themselves. Testing recommendations associated
with already recorded implementation defects should accompany those fixes,
rather than becoming duplicate remediation projects.
## 1. RunRequest's Privacy Formatting Contract Is Not Exercised
**Priority:** P2. Missing protection for a documented confidentiality boundary.
**Relevant code:** [RunRequest formatting](../../../formatting.go:5) and the
analogous existing [SmokeTestRequest privacy test](../../../smoke_test_contract_test.go:135).
**Deficiency:** `RunRequest.String`, `GoString`, and their shared formatter all
have 0% combined statement coverage. The suite protects formatting of generated
client requests, smoke-test requests, and opaque prepared handles, but does not
exercise the ordinary run request's own formatting contract.
**Unverified behavior:** A change that prints the direct key, input bodies, or
variable values from `RunRequest` can pass the complete suite. This is an
assertion gap, not evidence that current formatting actually leaks those values.
**Potential improvement:** Add one small public-boundary test with distinct
synthetic private markers and exercise the supported String/GoString formatting
paths on values and pointers. Assert absence of the data covered by the GoDoc;
do not snapshot the complete summary or freeze incidental field order/count
formatting. If adding a JSON check, assert only the documented direct-key
exclusion, not a stable JSON shape or omission of ordinary request data.
Reflection-based disclosure is explicitly outside this formatter guarantee.
## 2. The Public Single-Schema-File Option Has No Execution Coverage
**Priority:** P2. An exported source-selection path is entirely unprotected.
**Relevant code:** [WithSchemaFile](../../../engine.go:212),
[SetSchemaFile](../../../internal/engine/engine.go:75), and
[fileSource](../../../internal/engine/engine.go:211).
**Deficiency:** All three functions have 0% combined coverage. The existing
[single-file validator test](../../../internal/validate/standard_validator_test.go:596)
constructs `FSValidator` directly with `fstest.MapFS`; it does not test the public
option, host-file selection, or its engine wiring.
**Unverified behavior:** The public option could ignore its argument, fail to
replace `SchemaDir`, select the wrong root/base name, or mishandle invalid files
without this validator test noticing. Direct validator success does not establish
that a consumer can reach that behavior through the supported API.
**Potential improvement:** Use `t.TempDir` and a real schema file in a small
public integration test. Exercise construction plus preparation/validation
through that file's base name, and representative invalid selections such as a
missing file or directory. Include source-option replacement where it protects
the documented last-valid-option behavior. Assert public error identity and
observable schema selection, not private helper calls. This does not require a
helper-by-helper test matrix or model-provider access.
## 3. Programmatic Provider-Value Conversion Lacks Representative Value Cases
**Priority:** P2. Conversion and ownership coverage misses known data-integrity
and deferred-error risks.
**Relevant tests:** [programmatic source ownership/order](../../../sources_contract_test.go:14)
and [programmatic construction](../../../construction_contract_test.go:226).
**Implementation:** [providerValueForMapping](../../../provider_values.go:31).
**Deficiency:** The converter has only 11.9% combined coverage. The principal
source-ownership test mutates a flat string-valued map. It does not establish
preservation and ownership for representative nested/typed containers or the
`json.Number` branch, nor deferred failure for unsupported provider values.
The more extensive `internal/jsonvalue` tests protect a different traversal and
do not execute this pre-ConfigKit convenience conversion.
**Unverified behavior:** The suite passes with the exact-number rounding defect
already recorded in [the correctness audit](01-correctness.md#1-provider-source-helpers-silently-change-exact-json-numbers).
It also would not detect many regressions in nested source capture or the
documented source-creation-versus-loading failure boundary.
**Potential improvement:** Exercise both `ProfileSource` and `BackendSource`
through `NewEngine` with a compact set of representative supported nested
values, mutations after source creation, and unsupported/cyclic values that
should fail during loading. Add the numeric regression when addressing the
existing precision finding. Compare meaningful final values or encoding, not Go
container identity, numeric spelling, or a required float representation.
Keep tests at the final handoff/public source-helper boundary; do not duplicate
ConfigKit's raw-source validation or its entire value-limit suite. Work-bound
coverage for the separately identified shared-graph issue belongs with that fix.
## 4. Error Tests Miss Composed Causes And Pre-Dispatch Diagnostic Failures
**Priority:** P2. Covered code paths lack important boundary-case assertions.
**Relevant tests:** [public generation failures](../../../generation_error_contract_test.go:111),
[direct mapper cases](../../../errors_internal_test.go:13),
[body cancellation](../../../internal/llm/prepared_generation_test.go:161), and
[inference diagnostic cases](../../../internal/usecase/smoke_inference_test.go:14).
**Deficiency and demonstrated blind spots:**
- Injected failures are mostly plain marker errors or a single explicit
category. They do not check a real nested PromptKit error returned through a
different operation. The suite passes although an injected client/reader can
lose the outer public generation/artifact category.
- Tests cover `http.Client.Do` failures and cancellation during successful-body
reading, but not an ordinary non-EOF read error during initial decoding or
final EOF verification. Such errors currently lose their cause and become
malformed-response failures.
- Diagnostic tests cover invalid selection/settings and concluded generation
failures separately, but miss a runtime setting rejected by built-in request
construction before dispatch. A reserved `ExtraParams["model"]` override
currently returns a completed report with nil Go error.
These are the three implementation findings in the error-handling audit, not
new duplicate defects. Re-running its temporary offline public-API diagnostic
against the current tree confirmed all three while the complete suite passed.
**Potential improvement:** Add narrowly owned regressions with those fixes:
real nested public causes through the injection boundary; a controlled body
reader failing at each decode/framing phase; and a public `SmokeTest` call with
a reserved runtime field and counted transport. Assert relevant error identities,
cause preservation, factual failure classification, and whether dispatch occurred.
Avoid reproducing the internal error topology by hand or matching complete
messages. Preserve intentional nil-error diagnostic findings, cancellation
precedence, best-effort non-2xx detail parsing, and status-only retry policy.
## 5. Multi-Resource Schema Tests Do Not Establish A Usable Provider Constraint
**Priority:** P2. Missing assembled validation-to-wire assertion.
**Relevant tests:** [prepared execution source freezing](../../../prepared_execution_contract_test.go:19),
its [referenced schema fixture](../../../prepared_execution_contract_test.go:809),
and [local reference validation](../../../internal/validate/standard_validator_test.go:627).
**Deficiency:** Local tests establish that referenced schemas compile and
validate. The prepared-execution test checks that the injected client's schema
matches frozen details. Its opaque model client accepts that schema without
interpreting references. Equality between those two values does not establish
that the provider-facing constraint contains the resources it needs.
**Unverified behavior:** Both sides can contain the same unresolved `value.json`
reference while the test passes. This is the packaging gap already identified
in [the correctness audit](01-correctness.md#2-external-schema-resources-do-not-reach-the-provider-constraint).
The freezing test's fake is appropriate for its stated ownership purpose; the
missing protection is a separate built-in-path integration case.
**Potential improvement:** Once the schema-packaging policy is selected, add one
offline assembled test with a root and child schema, capture the actual built-in
HTTP payload, and assess the chosen provider-facing representation independently
of `Details`. For a bundled native schema, compile/evaluate the captured
constraint without granting access to PromptKit's original local files; if the
chosen policy supports a local-only fallback, assert that behavior instead.
Keep the existing freezing test. Do not require live provider acceptance or
promise support for arbitrary provider schema dialect features.
## 6. Artifact Tests And Benchmarks Pin An Explicitly Opaque Hash Algorithm
**Priority:** P3. Remove or rewrite change-detector assertions, retaining the
substantive file-reading tests.
**Relevant assertions:** [size-hint test](../../../internal/artifact/reader_test.go:287)
and [benchmark correctness check](../../../internal/artifact/reader_benchmark_test.go:156).
**Deficiency:** Both independently calculate a hex SHA-256 digest and require
that exact value. The [public Artifact.Hash contract](../../../types.go:290)
expressly leaves its format and algorithm unspecified. A legitimate algorithm
or encoding change would fail tests unrelated to the requested behavior.
**Potential improvement:** Replace those algorithm-specific checks with the
opaque equality relationships needed for each test. For the under/overreported
size cases, comparing with the inline reader for the same complete body still
detects hashing only a reported-size prefix. The existing
[source-parity test](../../../internal/artifact/reader_test.go:27) already owns
repeatability, same-content equality, and changed-content inequality. Retain
body, actual-size, metadata, and read-boundary assertions. The benchmark may
reuse an opaque expected hash prepared outside measurement or rely on the
owning behavioral test; it need not maintain its own SHA-256 contract.
## 7. Bundled Catalog Expectations Have Multiple Test Owners
**Priority:** P3. Consolidation/removal candidate for duplicated assertions.
**Relevant tests:** [internal complete catalog table](../../../internal/configuration/bundled_test.go:12),
[public option/inventory table](../../../bundled_openrouter_contract_test.go:54),
[HTTP payload matrix](../../../bundled_execution_contract_test.go:51), and
[bundled smoke defaults](../../../bundled_smoke_contract_test.go:16).
**Deficiency:** The current count of 17, complete ID inventory, selected
ID-to-model mappings, tier-plugin data, and diagnostic default model recur
across configuration, facade, wire, and diagnostic tests. A catalog update can
require expectation edits in several layers even when loading, option handling,
routing, and serialization are unchanged. The internal plugin assertion also
searches `fmt.Sprint` output instead of comparing the plugin's structure.
**Potential improvement:** Choose one owner for the complete maintained asset
inventory and expected model/plugin/default data. Remove duplicate full-catalog
counts/lists from option permutations and keep those tests focused on enable,
disable, last-option selection, isolation, and override behavior. Retain a small
assembled wire/diagnostic set for distinct integration risks; controlled
overrides can isolate routing assertions from a changing catalog default.
Use semantic structural assertions for plugin data. Keep independent expected
asset values in the selected owner rather than deriving every expectation
from the production loader, which could conceal an omitted or incorrect asset.
This is consolidation, not a proposal to stop checking bundled configuration.
## Findings Not Supported And Work To Preserve
- No general test-flakiness, default-suite live-provider dependency, or race
failure was reproduced. Bounded timeout guards and channel synchronization
are not classified as flaky merely because a test involves concurrency.
- The public API/type-boundary checks and their checker fixtures protect an
explicit architectural rule; they are not automatically obsolete because
they inspect structure. Stable JSON shape tests protect selected contracts.
- Fakes at model/HTTP boundaries, counted paid-effect calls, and no-work-after-
rejection checks are appropriate. A fake is not deficient merely because it
does not model unrelated provider behavior.
- Exhaustive domain-rule tables plus representative boundary wiring tests are
generally sound. No wholesale deletion of error, cancellation, capacity,
ownership, or prepared-lifecycle coverage is recommended.
- Low coverage of thin wrappers, example `main` functions, defensive impossible
branches, and unexercised reflection variants is not independently a finding.
Interface-only packages do not need artificial tests to obtain a package
percentage. Conservative requirement inspection is not obliged to fully
evaluate unsupported template expressions just to cover every AST branch.
- The standalone release-wiring fixture remains scoped to local disposable
repositories under the documented testing model. This audit does not propose
moving it into every normal test run or retesting ReleaseKit's algorithms.
Prioritize the missing public privacy/source-option protection and regressions
for the already identified defects. Make the two assertion-cleanup changes as
focused rewrites, retaining their useful behavioral coverage. None of these
recommendations requires new public guarantees or a coverage-percentage gate.

View File

@@ -0,0 +1,199 @@
# API And Interface Contract Audit
Reviewed 2026-11-04 against commit `31f1e6a` and the current worktree. Existing
audit-document moves were preserved. No production code or repository tests
were changed.
## Scope And Assessment
The public root package remains a facade over configuration, execution,
generation, capacity, rendering, validation, and observation components.
ConfigKit owns composition before the final configuration handoff; PromptKit
validates that handoff and captures its runtime indexes and templates. Public
adapters translate owned request/result values and errors. Namespace views
share execution ownership and capacity, while separately constructed engines
remain independent.
The review compared exported GoDoc, configuration and integration contracts,
internal collaborator interfaces, call paths, and existing behavioral tests.
It covered preparation/preflight/execution parity, prepared-handle lifecycle,
optional values and numeric presence, configuration inspection and listing,
injected collaborator ownership, output repair, diagnostics, observations,
serialization, cancellation, and public error categories. It did not treat
different intentionally documented workflows as defects or infer additional
admission, filesystem, provider, or semantic-validation guarantees.
Four findings warrant remediation. The configuration behaviors in findings 2
and 3 are consistent with the current format reference and implementation;
their older contradictory descriptions should be corrected rather than used
to change those behaviors.
## 1. ArtifactReader Permits Buffer Reuse Before PromptKit Can Copy It
**Priority:** P2. A documented ownership permission permits data races and
inconsistent input snapshots.
**Relevant interfaces/code:** [ArtifactReader GoDoc](../../../types.go:294),
[public reader adapter](../../../artifact_reader.go:19), and
[copyBytes](../../../convert.go:490).
**Contract defect:** The interface says that `Read` may be called concurrently
and that the reader may reuse or mutate the returned artifact and body after
`Read` returns. The adapter can only copy those values after that return.
There is no synchronization or acknowledgement between return and completion
of the copy. “Immediately copies” does not make concurrent reuse safe.
**Circumstances and consequence:** A reader serializes its own `Read` calls
with a mutex and returns a reusable buffer. Another invocation can overwrite
that buffer as soon as the first call returns, while the first adapter is still
copying it. Both calls follow the published reuse permission, yet preparation
can race, mix contents, or associate metadata with the wrong bytes. This is
not a demonstrated defect in the default file reader or in readers that
return independent snapshots.
**Evidence:** A temporary, offline public-API diagnostic used two concurrent
`Prepare` calls and a mutex-protected reader returning a shared 1 MiB buffer.
`go run -race` reported a write in the next `Read` racing with `copyBytes`
through `publicArtifactReaderAdapter.Read`. The adapter's existing copy test
establishes detachment after copying, not safety during that copy.
**Potential remediation:** Resolve the return-value ownership contract before
v1.0. The straightforward approach is for a successful `Read` to return a
per-invocation snapshot that remains stable while PromptKit consumes it, with
reuse permitted after the enclosing preparation/execution method returns.
Returning fresh owned data is the simplest implementation for concurrent
readers. If reuse immediately after `Read` itself returns must remain
supported, it requires an explicit borrowing/release arrangement; another
unsynchronized copy cannot provide that permission. Add focused coverage for
the selected lifetime, without globally serializing unrelated engine work.
## 2. Profile Inspection Misstates Required-Credential Semantics
**Priority:** P2. The public inspection contract describes valid results as
impossible and incorrectly limits the sources that can satisfy a requirement.
**Relevant interfaces/code:** [Engine.InspectProfile](../../../engine.go:313),
[ProfileInspection.APIKeyRequired](../../../types.go:372),
[profile-inspection consumer example](../../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work),
[effective-target construction](../../../internal/configuration/targets.go:99),
and [credential availability check](../../../internal/usecase/runner.go:611).
**Contract defect:** Both method and field GoDoc claim that `APIKeyRequired`
is mutually exclusive with a nonblank `EffectiveModelParams.APIKeyEnv`. They
also describe the flag as requiring a direct credential or a request-level
environment override. In fact, `api_key_required: true` suppresses a
backend-derived environment source only when no explicit profile source was
provided. An explicit profile environment source remains usable, as the
[credential format reference](../../formats.md#credentials) already states.
Inspection can therefore legitimately return both the flag and a nonblank
environment name. The consumer example also calls every reported environment
source optional, regardless of the flag.
**Circumstances and consequence:** A profile sets `api_key_required: true` and
`api_key_env: PROMPTKIT_INTERFACE_AUDIT_KEY`. Inspection returns `true` and
that name; preparation succeeds using its populated environment variable
without a direct key or request override. Consumers following the GoDoc may
reject this valid combination or report credential readiness incorrectly.
**Evidence:** A temporary public-API diagnostic demonstrated that combination
and successful preparation. The existing
[effective-target test](../../../internal/configuration/targets_test.go:88)
also includes a required profile with an explicit environment source.
**Potential remediation:** Describe the flag as a local credential-availability
requirement, distinguish source selection from required availability, and
remove the mutual-exclusion/direct-only claims. Align the consumer explanation
with the format reference and retain inspection's intentional lack of an
environment-value lookup. Do not reject the valid configuration or change
the public target's stable JSON shape to make the old wording true.
## 3. The Wire Contract Incorrectly Says Configured Provider Maps Replace
**Priority:** P2. Conflicting published contracts predict different outbound
provider parameters.
**Relevant interfaces/code:** [integration request-body contract](../../integrations/openai-compatible-chat.md#request-body),
[profile defaulting reference](../../formats.md#defaults-and-overrides),
[overlayProviderMap](../../../internal/configuration/targets.go:133), and
[runtime override merge](../../../internal/usecase/runner.go:562).
**Contract defect:** The integration document says backend, profile, and request
extra-parameter maps all use whole-map replacement. Actual configured profile
maps recursively overlay backend defaults, including nested maps. A nonempty
runtime `ExecutionTargetOverride.ExtraParams` instead replaces the entire
configured map. The format reference and runtime-override GoDoc describe
these distinct behaviors correctly.
**Circumstances and consequence:** A backend supplies `backend_only: true` and
`nested: {backend: true}`; its profile supplies `profile_only: true` and
`nested: {profile: true}`. All entries survive into the effective target and
built-in HTTP body. A reader of the integration contract would expect the
backend entries to disappear, potentially leading to surprising provider
options or ineffective attempts to clear defaults. A runtime map containing
only `request_only: true` does remove the configured entries.
**Evidence:** A temporary public-API diagnostic observed both forms through an
injected client and confirmed the configured merged fields through a controlled
built-in HTTP transport. No external request was made. The existing
[effective-target test](../../../internal/configuration/targets_test.go:70)
also exercises recursive configured overlay.
**Potential remediation:** Replace the stale configuration-precedence claim
with a short link to the format reference. Keep the integration contract
focused on flattening the resulting effective map into the HTTP body and on
reserved request fields. Preserve the distinct runtime replacement behavior;
do not add another merge policy or duplicate its full configuration reference.
## 4. Identity GoDoc Still Describes Removed Normalization And Version Fields
**Priority:** P3. Small but concrete public-contract inconsistencies.
**Relevant interfaces/code:** [ProfileInspection.ProfileID](../../../types.go:364),
[ListPrompts GoDoc](../../../listing.go:90), and
[literal profile identity test](../../../profile_inspection_contract_test.go:13).
**Contract defect:** `ProfileInspection.ProfileID` is described as trimmed,
but configured IDs and lookups preserve meaningful whitespace exactly.
`ListPrompts` promises ownership of each `Version` pointer even though
`PromptIdentity` contains only `ID`; collection-level `ReleaseVersion` belongs
to the separate collection inventory.
**Circumstances and consequence:** Inspecting the valid profile `" padded "`
returns that exact ID, while looking up `"padded"` fails. Callers expecting the
documented trimmed identity can fail later lookups. The nonexistent version
field creates an erroneous expectation about available prompt metadata.
**Evidence:** The existing literal-identity test explicitly protects preserved
whitespace and failure of the trimmed lookup; a temporary public-API diagnostic
confirmed it. The current `PromptIdentity` declaration and facade conversion
have no version field.
**Potential remediation:** State that the profile ID is the exact selected
identity after any namespace qualification, and remove the nonexistent version
pointer language. Keep literal identity behavior and collection metadata
unchanged; no new normalization, version field, or compatibility guarantee is
needed.
## Existing Findings And Validation
The earlier error-handling audit already records loss of owning public error
categories for nested injected causes, loss of successful-body I/O causes, and
pre-dispatch smoke failures incorrectly becoming concluded reports. Those are
also interface-contract concerns, not new duplicate findings here. The hidden
`ValidationPreparer` requirement behind the runner's `Validator` parameter is
already recorded in the [complexity audit](03-complexity.md). No additional
independent defect was established in the reviewed prepared-handle lifecycle,
optional-value handling, inspection ownership, or observation/result mapping.
Focused existing tests passed with the local Go 1.27.0 toolchain:
```sh
go test -race -count=1 -run 'Test(InspectProfile|ListIdentities|ScopedIdentityListings|PublicArtifactReaderAdapter|PublicLLMClientAdapter|ConfigurationInspection|LoadBuildsEffectiveTargetsAfterInheritance)' . ./internal/configuration
```
The separate temporary reader-reuse diagnostic intentionally produced a race
report and exited unsuccessfully, substantiating finding 1 despite the green
existing tests. Temporary programs and outputs remain outside the repository.
All diagnostics were offline with synthetic configuration and credentials;
no paid provider calls, new repository tests, or production changes were used.
These checks are review evidence, not additional release gates.

View File

@@ -0,0 +1,249 @@
# Behavioral Equivalence And Shared Abstraction Audit
Reviewed 2026-11-04 against commit `5177db0` and the current worktree. Existing
audit-document moves were preserved. No production code or repository tests
were changed.
## Assessment And Method
The public package adapts consumer values into internal configuration and
execution components. ConfigKit owns source composition; PromptKit interprets
its final handoff and captures indexes and templates. The runner shares
preparation and execution machinery across public workflows, generation owns
attempts and retries, and capacity owns admission and permits. Domain rules,
validation, JSON-value ownership, and observation have existing shared owners.
The architecture does not need another public abstraction or a general-purpose
utility package to address the opportunities below.
This review started from tests: direct cross-path comparisons, common test
matrices, and separately asserted matching behavior. It then examined the
corresponding implementation and callers to distinguish a shared responsibility
from similar-looking code. Matching syntax, similar coverage, or duplicated
test setup alone did not establish an extraction opportunity.
Three bounded refactoring opportunities warrant consideration. They provide
test-backed scope for findings 1, 4, and 3 respectively in the
[complexity audit](03-complexity.md), rather than three additional independent
bugs or duplicate remediation projects. None is a newly demonstrated behavioral
failure or an automatic release blocker. Existing behavior, not a stronger
equivalence promise, is the preservation target.
## 1. Share The Schema Compilation Lifecycle Across Source And Execution Paths
### Evidence
- [TestJSONSchemaNumericConstraintsRetainJSONPrecision](../../../internal/validate/standard_validator_test.go:721)
runs the same exact-number constraints through operating-system and `fs.FS`
validators using `jsonSchemaValidatorSources`. Both must distinguish adjacent
large integers, preserve precise decimal boundaries, and accept the same
valid instances.
- [TestJSONSchemaDecodingRequiresOneCompleteDocument](../../../internal/validate/standard_validator_test.go:805)
uses that same source matrix to require equivalent schema/instance framing
and the distinction between operational schema errors and rejected output.
- [TestSchemaReferencesPreserveEscapedFilenames](../../../internal/validate/standard_validator_test.go:492)
applies the same reference cases to both source implementations, with an
explicit platform exception for filenames illegal on Windows.
- [TestStandardValidatorPreparedSchemaSurvivesSourceRemoval](../../../internal/validate/standard_validator_test.go:228)
and [TestFSValidatorPreparedSchemaSurvivesSourceMutation](../../../internal/validate/standard_validator_test.go:416)
separately require the same captured schema to accept an integer-valued
object and reject a string-valued one after its source changes. Their source
operations differ, but the prepared validation responsibility matches.
These tests support common schema semantics and compilation ownership; they do
not require identical filesystem representations or diagnostic strings.
### Implementation
[StandardValidator and FSValidator](../../../internal/validate/standard_validator.go:74)
each implement `PrepareValidation` and direct `validateJSONSchema`. The four
paths repeat compiler setup, root-resource handling, cancellation boundaries,
compilation/error wrapping, and construction or use of executable schema
state. The direct host-file path lets its loader fetch the root, whereas the
other paths explicitly load/register it. Decoding, compiler construction,
schema execution, and content-result classification already have shared helpers;
the remaining duplication is the lifecycle around them.
### Opportunity And Potential Remediation
Keep source-specific resolution/loading adapters, but introduce one private
compilation operation in `internal/validate` that produces the root document
and compiled executor for the caller's selected source. Reuse it from prepared
validation and from the direct schema-validation callback. Retain the already
shared artifact-validation/result logic. This makes future dialect, framing,
number-preservation, and cancellation changes less likely to land in only some
of the four paths.
The existing package is the appropriate owner; neither a public compiler API
nor a generic filesystem framework is needed. The related runner dependency
clarification remains part of complexity-audit finding 1.
### Tradeoffs And Test Independence
Direct validation currently rejects malformed output JSON before accessing its
schema. Eagerly implementing it as `PrepareValidation` followed by plan
validation would change I/O and error ordering. Share compilation when that
work is reached, not necessarily the entire entry point. Preserve contained
host paths and symlink handling, logical `fs.FS` paths, escaped resource URLs,
single-file sources, synchronous cancellation precedence, and operation-local
prepared freezing. These source differences belong in their adapters.
Keep the source matrix and independently specified valid/invalid instances.
Do not compute expected results using the new compilation helper or delete
source-specific containment/cancellation tests merely because compilation is
shared. Provider-facing packaging of external schema references is a separate
correctness finding, not solved by this extraction.
## 2. Centralize Structural Copying Of Already-Normalized JSON Values
### Evidence
- [TestPublicLLMClientAdapterGivesClientOwnedNestedValues](../../../llm_adapter_internal_test.go:14)
requires nested provider maps, schema values, messages, and cache metadata to
remain independent when an injected client mutates and retains its request.
- [TestRunnerPreparedExecutionDetachesTransferredSnapshotSources](../../../internal/usecase/prepared_execution_test.go:634)
separately requires nested provider maps and schema values to survive source
mutation, repeated `Details` calls, and execution without aliasing.
- [TestCopyHandlesIndirectionAndIsolatesNestedMutations](../../../internal/jsonvalue/jsonvalue_test.go:156)
exercises the underlying JSON-value ownership responsibility with typed
collections and repeated acyclic children. The adjacent scalar/collection
tests protect compatible concrete types and exact `json.Number` values.
- [TestCopyAndCopyMapApplyDistinctEmptyKeyRules](../../../internal/jsonvalue/jsonvalue_test.go:204)
explicitly demonstrates a necessary policy distinction: general schema
objects can have empty keys, while provider maps cannot.
The common requirement is an independently owned copy of accepted nested
values. These tests do not establish universal equivalence of validation,
normalization, and copying on arbitrary Go values.
### Implementation
The facade's [copyAny/copyReflectValue](../../../convert.go:406) implements a
separate recursive reflection copier for values projected into client requests,
prepared details, results, and profile inspections. Internal prepared-state
cloning uses [clonePreparedRun](../../../internal/usecase/prepared_execution.go:226)
and [jsonvalue.Copy/CopyMap](../../../internal/jsonvalue/jsonvalue.go:30).
The latter package already owns compatible JSON-tree copying and validation.
Ordinary runtime provider values reach facade projection after JSON-value
validation/copying. The runner also copies its schema document through
[structuredOutputFromValidationPlan](../../../internal/usecase/runner.go:419)
before projecting it. Thus these normal runtime paths have a common accepted
value domain, even though their entry-point policies are different.
### Opportunity And Potential Remediation
Move ownership of trusted JSON-tree cloning into `internal/jsonvalue`, reusing
its compatible structural-copy machinery where semantics match, so the facade
does not maintain another general reflection traversal. Keep public/domain DTO
field projection in the facade. A small internal trusted-value clone operation
is a possible approach; reuse of existing fallible operations is another where
the caller can preserve the existing error boundary. Select the simpler option
after inventorying the concrete callers and values, rather than introducing a
configurable reflection framework.
The meaningful payoff is removing an independent traversal implementation,
not wrapping the existing facade copier under another name. This does not
require consolidating every pointer or slice helper in the repository.
### Tradeoffs And Test Independence
`Copy` validates and bounds values and normalizes null/indirection; the facade
copier assumes prior acceptance and can preserve typed nils and pointers.
Neither is a drop-in replacement for the other on all possible inputs.
Preserve observed types, nil behavior, exact numbers, schema empty keys, and
caller ownership at the relevant boundary. An infallible projection must not
silently turn a new copy error into a zero value, a panic, or an additional
consumer restriction.
The [configuration-boundary ADR](../../adr/0001-configuration-boundary.md)
also remains authoritative. `providerValueForMapping` serves a different,
pre-handoff source-encoding purpose and defers unsupported-value errors to
ConfigKit. It must not be routed through a rejecting runtime copier as part of
this work. Source number conversion is separately recorded in the correctness
audit; visual similarity between its reflection switch and a copier is not
enough to equate their contracts.
Keep literal expected values and mutation-based assertions in both facade and
internal tests. A shared production copier must not also manufacture their
expected snapshots. Retain focused tests for policy differences rather than
replacing them with one “all copy functions are equivalent” test.
## 3. Share If/With Requirement-Branch Orchestration, Not Template Execution
### Evidence
- [TestAnalyzeRequirementsClassifiesKnownConditions](../../../internal/prompt/requirements_test.go:268)
verifies required/optional classification for selected and skipped `if`
branches, and directly compares the recognized branch choice with the real
Go renderer. This anchors recognized condition semantics in actual rendering.
- [TestAnalyzeRequirementsHandlesWithDotAndNestedReachability](../../../internal/prompt/requirements_test.go:332)
requires the same selected/skipped classification for `with` and nested
`if`, while separately protecting `with`'s changed dot and preserved root
caller-map access.
- [TestAnalyzeRequirementsReportsUnresolvedDecisionAndContentConditions](../../../internal/prompt/requirements_test.go:369)
protects conditional dependencies and meaningful incompleteness for unknown
decisions. It also establishes that an unresolved condition around plain
text alone does not make discovery incomplete.
- [TestAnalyzeRequirementsDoesNotHideCallerMapScopes](../../../internal/prompt/requirements_test.go:14)
compares several scope forms against actual template execution while allowing
incomplete analysis for constructs outside recognition scope.
The tests support one reachability/evidence policy for recognizable branches,
with explicit scope differences. They do not require the inspector to execute
or completely interpret arbitrary templates.
### Implementation
The `IfNode` and `WithNode` cases in
[requirementWalker.visit](../../../internal/prompt/requirements.go:451)
independently perform inactive-path traversal, condition evaluation, then/else
reachability selection, dependency-counter comparison, and limitation recording.
`with` adds a different body-dot calculation. `branchReach`, condition
evaluation, and `template.IsTrue` already provide shared lower-level behavior,
but their surrounding branch protocol is repeated.
### Opportunity And Potential Remediation
Extract a small package-private branch traversal operation that owns the common
reachability and evidence sequence, with explicit body/else scope handling.
Leave node recognition and `withDotIdentity` visible at the node-specific
boundary. Keeping this in `internal/prompt` would allow a change to skipped-path
handling or incompleteness reporting to be reviewed and implemented once.
This is the narrowly supported part of complexity-audit finding 3. It does not
justify sharing the requirement walker with the real template executor or
building an interpreter abstraction.
### Tradeoffs And Test Independence
An abstraction that treats `if` and `with` identically would lose caller-map
scope information. Preserve `with`'s body-dot behavior, else scope, inactive
computed-name handling, and the placement of dependency bookkeeping used to
decide whether an unresolved condition matters. A helper with numerous mode
flags or callbacks would obscure those differences; prefer a small explicit
operation over a generalized AST visitor framework.
Retain separate node-specific assertions and the independent standard-library
rendering comparisons. Extracting production traversal is not a reason to
calculate expected inventories using the production branch helper or to
strengthen `Complete` into an execution-readiness guarantee.
## Recommendation And Validation
Start with schema compilation, which has the clearest shared lifecycle and
cross-source test matrix. The branch helper is a smaller local follow-up.
Treat JSON-copy consolidation as a more careful ownership refactor: remove a
traversal only after its caller/policy differences are accounted for. No new
package boundary is necessary solely to carry out these changes, and retaining
small source/boundary-specific adapters is appropriate.
Existing offline tests passed with the local Go 1.27.0 toolchain:
```sh
go test -race -count=1 ./internal/validate ./internal/prompt ./internal/jsonvalue
go test -race -count=1 -run 'Test(PublicLLMClientAdapter|PreparedExecutionFreezesSourcesAndReturnsIndependentDetails|RunnerPreparedExecutionDetachesTransferredSnapshotSources|RunnerRunAndPrepareResolveSameProfileAndEffectiveSettings|RunnerPreparationNormalizesOutputContractConsistently|ValidateRequestMatchesPrepareContract)' . ./internal/usecase
```
Passing tests substantiate the reviewed current behavior, not equivalence of a
future refactor. No new tests, production changes, live provider requests,
benchmarks, or additional release gates were introduced by this audit.

View File

@@ -0,0 +1,229 @@
# Package Cohesion And Architectural Boundary Audit
Reviewed 2026-11-04 against commit `b39460a` and the current worktree. Existing
audit-document moves were preserved. No production code, tests, dependencies,
or functionality were changed.
## Scope, Method, And Architectural Assessment
Reviewed the 17 repository-owned internal Go packages, their production import
relationships, engine assembly, relevant callers, and tests. The public facade
was checked where it establishes the boundary around those packages. Third-party
package implementations were outside the audit's scope.
The [current component inventory](../../internal/overview.md) broadly matches
the implementation. Configuration owns the final ConfigKit handoff and captured
resource indexes; the engine assembles collaborators; use cases coordinate
selection, preparation, execution, and diagnostics. Generation owns dispatch
and retries, capacity owns admission and permits, and outbound transport,
rendering, artifacts, schema validation, observations, and shared values have
identifiable owners. No repository-wide package reorganization is warranted.
The public facade does not leak internal types or receive reverse production
imports from internal packages in the checks run below. Deliberate boundaries
remain useful: configuration completion differs from request overrides;
diagnostic orchestration differs from ordinary execution; domain invariants
differ from source-specific presence and error handling. A broad package, a
small interface-only package, or an import of another internal component is not
itself a cohesion defect.
Three focused ownership improvements warrant consideration. They are
maintainability opportunities, not newly demonstrated runtime bugs or automatic
release blockers.
## 1. Retire The Test-Only Capacity Client As A Production Scheduling Surface
**Relevant code:** [capacity.NewClient and client.Generate](../../../internal/capacity/client.go:17),
[generation constructors and dispatch](../../../internal/generation/coordinator.go:37),
[engine assembly](../../../internal/engine/engine.go:157), and
[runner scheduling test](../../../internal/usecase/runner_test.go:2391).
**Basis:** The engine selects `generation.NewBuiltIn` or `NewInjected` for every
production engine. The coordinator owns active-permit acquisition around each
attempt, retry waits, observation, and diagnostic evidence. The earlier
`capacity.NewClient` still provides a separate model-client wrapper that
acquires/releases a permit around an opaque call. Both use the same manager;
there are not two independent FIFO algorithms or pools.
Caller analysis found only tests invoking the older constructor: capacity tests
and a runner test that assembles initial/repair scheduling with this wrapper.
The [capacity document](../../internal/capacity.md#fifo-generation-permits)
acknowledges its retention for focused callers/tests, but no production caller
was found. Its presence leaves capacity owning a model-client adapter after
production dispatch responsibility moved to generation, and makes one runner
assembly test exercise a different scheduling composition than the engine.
**Consequences:** The extra adapter and its documented role remain maintenance
surface without a production need. Future scheduling changes can require
reasoning about a legacy assembly alongside the real one. Green tests of the
wrapper alone do not demonstrate that coordinator dispatch applies the same
permit lifetime. Existing coordinator and facade tests do protect that actual
path, so this is not a claim that production scheduling is untested or broken.
**Potential remediation:** Keep the manager and its admission/FIFO primitives
in `internal/capacity`, and generation dispatch integration in
`internal/generation`. Replace the runner test's wrapper with the production
injected coordinator. For focused capacity scenarios, test `Manager.Acquire`
directly or retain the small one-shot adapter as test-owned scaffolding rather
than a production constructor. Once callers are adjusted, the production
wrapper can be retired and its documentation updated.
Preserve the useful FIFO, cancellation-race, independent-pool, and panic-release
assertions when adjusting test ownership. This finding does not call for moving
queue internals into generation, deleting those scenarios, or wrapping the
coordinator in another capacity client, which could acquire the same limit
twice. Retaining the wrapper is viable if a real internal production need is
identified; current test reachability alone does not establish one.
## 2. Put OpenRouter Authentication Protocol Details Behind The Outbound Adapter
**Relevant code:** [authenticationProbe.runOpenRouterKey](../../../internal/usecase/smoke_authentication.go:66),
[recognizedOpenRouterKeyResponse](../../../internal/usecase/smoke_authentication.go:139),
[diagnostic endpoint derivation](../../../internal/usecase/smoke_selection.go:166),
and [HTTP authentication transport](../../../internal/llm/http_evidence.go:111).
**Basis:** `internal/usecase` currently interprets OpenRouter-specific HTTP
statuses and the account-response keys `data`, `label`, `is_free_tier`, and
`usage`. It owns JSON envelope parsing, identifying-field type checks, and body
consumption. The same package derives the OpenRouter-relative `/key` endpoint.
Meanwhile `internal/llm` owns authenticated HTTP execution, credential
resolution, evidence collection, and response ownership mechanics. One provider
integration is therefore divided between the application coordinator and its
outbound adapter.
This division is different from a use case consuming factual HTTP evidence:
the precise external account document and provider endpoint are protocol
knowledge. The [architecture policy](../../policy/architecture.md#architectural-approach)
calls for encapsulating external integration details in internal adapters.
The parser is already functionally isolated, making the ownership seam fairly
small. The authentication tests in
[smoke_authentication_test.go](../../../internal/usecase/smoke_authentication_test.go:64)
also show that this is provider protocol behavior rather than application
selection policy.
**Consequences:** A change to OpenRouter's account endpoint or identifying
response fields requires changing application-layer code as well as the
transport integration. Adding another authentication adapter would extend
provider-specific branching inside the use-case package. This complicates the
otherwise clear distinction between choosing a diagnostic and implementing
the upstream protocol.
**Potential remediation:** Move the OpenRouter request/response adapter,
including its default destination and response recognition, into
`internal/llm` alongside the existing diagnostic transport. Have the use case
consume a small factual outcome/evidence value. Keep target selection,
configured applicability, unsupported-operation reporting, caller cancellation,
overall completion, and report assembly in the use case. The existing
consumer-owned diagnostic interface is a useful seam; engine assembly can
supply the concrete outbound implementation without requiring protocol parsing
inside the coordinator.
An alternative is a focused internal diagnostic-adapter package if separation
from inference transport becomes useful. Another package or a provider plugin
framework is not necessary for the current single protocol adapter. Do not
introduce a new dependency from the outbound adapter back into `usecase`, or
move application report/completion policy into transport merely to relocate
the parser. The diagnostic feature legitimately deals with HTTP facts; a
blanket prohibition on HTTP types or status observations in use cases is not
implied by this finding.
Preserve anonymous authentication probing, explicit endpoint overrides,
neutral tri-state facts, private body/credential handling, and the absence of
ordinary observer events. The previously recorded unbounded authentication-body
read is a separate resource/security finding; relocation alone does not fix it.
## 3. Narrow Smoke-Value Copy Helpers To Their Actual Ownership Boundaries
**Relevant code:** [domain.CloneSmokeTestRequest](../../../internal/domain/smoke_diagnostics.go:157),
[domain.CloneSmokeTestReport](../../../internal/domain/smoke_diagnostics.go:179),
[smokeReportTarget](../../../internal/usecase/smoke_tester.go:432), and
[public smoke-value conversion](../../../smoke_diagnostics.go:279).
**Basis:** `CloneSmokeTestRequest` is called only by its own domain test;
actual request ownership is established by public-to-domain conversion.
`CloneSmokeTestReport` has one production caller: `smokeReportTarget` creates
an otherwise empty report containing a target, clones the full report, and
extracts just that target. Whole-report facts, timing, and attempt cloning have
no runtime use through that caller. Public report conversion independently
copies the actual facts, timings, attempts, and provider map into public types.
Thus the domain package maintains a broad request/report-copy API, but the
real internal consumer needs only a detached target/provider map. The
whole-report ownership functionality is exercised by its domain test rather
than by actual whole-report production use. Public DTO conversion is a
legitimate separate responsibility, not something that should disappear into
domain types or public aliases.
**Consequences:** Smoke-value changes have additional copy lists and tests to
maintain even where those copies are not needed in production. A temporary
report container obscures the narrow target-detachment operation. Keeping a
general cloner solely because its own test calls it reverses the relationship
between production responsibility and supporting tests.
**Potential remediation:** Express the target-copy operation directly at its
use-case boundary using existing JSON-value ownership, or a small target clone
helper if actual callers justify one. Retain public request/report projection
in the facade and shared JSON-tree copying in its existing owner. Retire the
unused request cloner and the unnecessary whole-report clone surface after
confirming no additional callers need them. Remove only clone-specific helper
code that is then unused. The backend-cloning helpers have real registry
consumers and are outside the proposed retirement; retain that metadata
ownership behavior.
Preserve provider-map error propagation, caller-owned result values, and
credential/endpoint redaction. Keep the meaningful
[public conversion ownership test](../../../smoke_conversion_internal_test.go:13)
and relevant use-case report tests. If genuine internal whole-report snapshots
are needed, retaining an explicitly used domain cloner is a reasonable
alternative. This finding does not require sharing public/internal DTO types
or routing public conversion through a full internal clone followed by another
copy. Coordinate any JSON traversal consolidation with
[audit 10](10-equivalence.md#2-centralize-structural-copying-of-already-normalized-json-values).
## Related Existing Findings
The runner's declared `Validator` dependency and actual `ValidationPreparer`
requirement remain the interface-boundary clarification in
[complexity-audit finding 1](03-complexity.md#1-consolidate-schema-compilation-and-express-the-runners-actual-interface).
The facade's generic JSON reflection copier has an existing internal owner
identified in audit 10. These are relevant ownership concerns, not additional
independent findings to count again. Large orchestration functions and repeated
schema compilation within an already coherent package likewise retain their
existing complexity/equivalence remediation scope.
## Reachability And Validation Evidence
Production imports were enumerated with `go list` and compared with the graph
and source callers. Rapid Type Analysis used the locally available pinned
`golang.org/x/tools` v0.52.0 command, with reporting restricted to
repository-owned internal packages:
```sh
go list -f '{{.ImportPath}}|{{join .Imports " "}}' ./internal/...
go run golang.org/x/tools/cmd/deadcode@v0.52.0 -test -filter='^go.maximumdirect.net/promptkit/internal/' ./...
go run golang.org/x/tools/cmd/deadcode@v0.52.0 -filter='^go.maximumdirect.net/promptkit/internal/' ./...
```
With tests included, the tool reported only the unused test helper
`internal/usecase/runner_test.go:stringPtr`. That small test cleanup does not
justify a separate architectural finding. Test-only production helpers remain
reachable to this analysis when their tests call them.
Without tests, the roots are the repository's example executables, not every
possible downstream library consumer. The output includes valid public
workflows that those examples do not exercise, so it is not a deletion list.
The capacity-wrapper and request-cloner observations above were additionally
checked against actual source references and assembly; graph in-degree or
example reachability alone was not treated as proof of obsolescence. The
analysis also covers only the selected local build configuration.
The following existing offline checks passed with local Go 1.27.0:
```sh
go test -race -count=1 -run 'Test(PublicFacadeDoesNotExposeInternalTypes|InternalProductionPackagesDoNotImportFacade|RepositoryDoesNotImportFormerModule|SmokeTestConcludedInferenceMismatchHasNilGoErrorAndDetachedValues)' .
go test -race -count=1 ./internal/capacity ./internal/generation ./internal/domain ./internal/usecase
```
No new release gate, consumer contract, provider call, production edit, or test
removal is proposed as part of this audit pass. The tools left module metadata
unchanged.

View File

@@ -0,0 +1,233 @@
# Dead Code And Obsolete Functionality Audit
Reviewed 2026-11-04 against commit `3b76031` and the current worktree. Existing
audit-document moves were preserved. No production code, tests, dependencies,
or functionality were changed.
## Scope And Assessment
Reviewed repository-owned Go packages, public entry points, configuration and
runtime values, collaborator interfaces, default/optional paths, embedded
resources, build constraints, and the release launchers and their wiring test.
The architecture remains a public facade over captured configuration, use-case
orchestration, generation/capacity, and focused internal adapters. The former
standalone configuration repositories have already been retired; this review
found smaller residues rather than another abandoned subsystem.
The review combined Rapid Type Analysis, Staticcheck unused/data-flow checks,
graph caller discovery, source inspection, and the format, internal, policy,
and roadmap documents. Tests count as real callers, but a helper's self-test
does not by itself establish a production need. Conversely, absence from the
examples or default execution path is not evidence that an optional public
feature is obsolete.
Four cleanup areas warrant consideration. These are low-priority reductions in
state and maintenance surface, not newly demonstrated runtime bugs. None calls
for deleting a supported public feature or imposing a new release gate.
## 1. Remove Backend Presence Bookkeeping With No Runtime Consumer
**Relevant code:** [loaded.backends](../../../internal/configuration/load.go:122),
[backendFields and presence capture](../../../internal/configuration/load.go:215),
[effective backend construction](../../../internal/configuration/targets.go:14),
and [Backend.QueueCapacitySet](../../../internal/domain/domain.go:187).
**Evidence:** Construction allocates a per-backend field-state map and fills it
through `captureFields(..., state.backends)`. No production code reads the
recorded backend states. The only lookup of the captured queue state is in
[TestLoadDefaultsAbsentQueueCapacity](../../../internal/configuration/load_test.go:755).
Unlike profile presence, backend presence is not consumed while constructing
effective execution targets.
`QueueCapacitySet` is also assigned during target construction and asserted in
tests, but never read to select a production behavior. The current assignment
is simply whether concurrency is positive; it no longer distinguishes an
explicit queue setting from a derived default. The registry's capacity-policy
projection uses `ConcurrencyLimit` and `QueueCapacity`, not this flag.
**Why obsolete:** Queue defaulting and its provenance are already handled by
[resolveBackends](../../../internal/configuration/resolve_targets.go:11) and
ConfigKit's derived-default record. Typed final values drive runtime capacity;
the retained side map and Boolean do not contribute to that behavior or to the
public inspection view. No documented compatibility or planned-feature need for
these extra records was found.
**Potential remediation:** Remove the backend-only presence capture, its field
list and retained map, and the unused runtime Boolean/assignment. Adjust tests
to assert effective queue behavior and, where relevant, the existing ConfigKit
inspection/provenance result. Keep `captureFields`, profile field-state
tracking, and prompt-input metadata presence: those have actual consumers and
preserve required absent/null/explicit-value distinctions. Retain explicit
zero queue capacity, derived defaulting, and operator history unchanged.
This removes unnecessary construction work and retained per-backend maps; it
does not justify dropping configuration provenance or changing its ownership
under the [configuration-boundary ADR](../../adr/0001-configuration-boundary.md).
## 2. Retire Legacy Fields In The Captured Prompt Representation
**Relevant code:** [PromptDefinition.OutputFormat](../../../internal/domain/domain.go:148),
[PromptMessageTemplate.ContentFile](../../../internal/domain/domain.go:173),
[prompt materialization](../../../internal/configuration/prompts.go:58),
[resolveOutputContract](../../../internal/usecase/runner.go:639), and
[definition hashing](../../../internal/promptdef/hash.go:12).
**Evidence:** Supported engine construction requires and validates a complete
output contract. Materialization sets `definition.Validation` to that contract
and duplicates its format into `definition.OutputFormat`. The runner reads
the duplicate only when `Validation.Format` is empty, a condition that the
captured definitions supplied by engine construction cannot have. The standard
runner test-definition helper fills both fields with the same value; no
supported public repository-injection path supplies an alternative definition.
For messages, materialization resolves the real source `content_file` into
captured `Content` and does not populate the domain `ContentFile` field. The
renderer uses `Content`/captured templates, not that path field. Its one
nonempty assignment is a renderer fixture demonstrating that already-loaded
content is rendered without opening a file. The source-format
`promptMessageConfig.ContentFile` remains actively used and is a different
field.
**Why obsolete:** The runtime representation still carries source-era message
path data and a second output-format source after configuration loading became
the authoritative materialization boundary. The empty-format fallback is
disconnected from supported engine definitions. No current documentation or
roadmap records a compatibility requirement for these internal duplicate fields.
**Potential remediation:** Use `PromptDefinition.Validation.Format` as the one
captured format and remove the legacy duplicate/fallback. Remove only the
runtime message path field, adjusting its fixture to exercise captured text.
Preserve the actual YAML `content_file` feature, source-relative resource
resolution, ConfigKit origin inspection, and construction-time capture. Also
preserve the separate, supported request-override rule that an empty replacement
`OutputContract.Format` defaults to text.
**Serialization caveat:** These fields are included indirectly by
`HashDefinition`'s `json.Marshal`, even when a message path is empty. They are
therefore not completely inert bytes: removing them changes current prompt
hash values. Account for that effect explicitly and protect the documented
opaque-equality relationships between inspection and preparation. Do not claim
bit-identical hashes after changing the hash input, or invent a permanent hash
encoding guarantee merely to perform this cleanup. The fields are internal
representation, not part of the public stable DTO shapes.
## 3. Remove Unused Diagnostic State And A Discarded Helper Result
**Relevant code:** [reachabilityProbeOutcome.initialEndpoint](../../../internal/usecase/smoke_reachability.go:24),
[HTTP report assembly](../../../internal/usecase/smoke_tester.go:376),
[fileSource](../../../internal/engine/engine.go:211), and
[SetSchemaFile](../../../internal/engine/engine.go:75).
**Evidence:** The reachability probe assigns its private outcome's
`initialEndpoint`, but the smoke tester never reads that member. It records
the real public attempt's `InitialEndpoint` from the resolved report target.
Only `TestReachabilityProbeRemovesURLCredentials` inspects the private echo.
Actual sanitized request/evidence and report behavior are established through
other values.
Separately, `fileSource` returns a filesystem, file name, source-directory
string, and error. Its only caller, `SetSchemaFile`, always discards the
directory result. The directory is still needed internally to construct
`os.DirFS`; exposing it as another return value serves no caller.
**Potential remediation:** Remove the unused probe-outcome member and its
assignment; retain credential-removal assertions against the actual request,
evidence, or public diagnostic report. Reduce `fileSource`'s private signature
to the consumed results. Keep its directory calculation, file checks, selected
filesystem/root, and the public `WithSchemaFile` option intact.
Neither cleanup removes `SmokeTestAttempt.InitialEndpoint`, destination
evidence, or single-schema-file support. Those public features are live even
though an extra private field/result is unnecessary.
## 4. Remove Orphan Helpers Whose Only Callers Are Their Own Tests
**Relevant code and evidence:**
- [filecatalog.RelativePath](../../../internal/filecatalog/catalog.go:71) is
called only by `TestRelativePathNested`. Current source discovery and
source-relative resource resolution use other helpers; no production or
public-facade caller uses this wrapper around `filepath.Rel`.
- [resourceNamespaceFromID](../../../internal/domain/resource_identity.go:34)
is called only by `TestResourceNamespaceFromID`. Actual namespace inventory
and filtering paths do not call it. The exported identity-validation and
qualification operations remain active and are not candidates for removal.
- [runner_test.go:stringPtr](../../../internal/usecase/runner_test.go:2592)
has no callers, including in tests. Both reachability analysis and
Staticcheck `U1000` report it.
Caller searches found no reflection, registration, build-constrained caller,
or supported external API exposing these helpers. The first two belong to
internal packages and have only their direct self-tests; no documented future
or compatibility purpose was found. The third is already test-only and unused.
**Potential remediation:** Remove the orphan helpers and their now-irrelevant
self-tests, keeping behavioral coverage of real discovery, namespace
inventory/filtering, and identity qualification. Remove the unused pointer
helper without replacing it. There is no reason to introduce a new abstraction
or move the self-tests to another layer solely to preserve these unused APIs.
## Intentional Retention And Related Findings
The following distinctions prevent removal based on misleading reachability:
- `WithSchemaFile` and `InlineWithURI` are supported public helpers despite
being unreachable from the current test executables. The former's missing
public wiring coverage is already in [audit 08](08-testing.md); it is not
evidence that the option should be deleted.
- The older capacity client wrapper and no-repair runner constructor are
expressly retained for focused internal callers/tests in the current
[capacity](../../internal/capacity.md#fifo-generation-permits) and
[runner](../../internal/runner.md#collaborators) documentation. This audit
does not classify them as dead functionality or recommend deleting them.
[Audit 11](11-boundaries.md) discusses the wrapper's architectural placement;
altering that deliberate retention is a separate decision.
- Repository capability fallbacks, standalone rendering/validation, injected
clients' nil usage-presence compatibility, nil-validator/admitter behavior,
and optional provider settings have meaningful callers or documented
purposes. Normal engine assembly taking another path does not obsolete them.
- ConfigKit YAML fields and stable JSON DTO fields can be used by reflection;
formatter methods and interfaces can be invoked indirectly. Embedded
OpenRouter and smoke-schema assets, the Linux FIFO test, and release scripts
likewise have consumers outside a simple direct-call list. They were not
treated as dead. No unused public configuration option or abandoned release
launcher was established.
The domain smoke-request/full-report cloners have a narrow-or-unused consumer
surface already described in [audit 11, finding 3](11-boundaries.md#3-narrow-smoke-value-copy-helpers-to-their-actual-ownership-boundaries).
That ownership/remediation scope is not counted again here. The schema and
JSON-copy consolidations in prior audits likewise remain their existing work,
not an excuse to delete intentional alternative implementations wholesale.
## Tooling And Validation
The checks used local Go 1.27.0 and pinned audit-tool versions:
```sh
go run golang.org/x/tools/cmd/deadcode@v0.52.0 -test -filter='^go.maximumdirect.net/promptkit(/|$)' ./...
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -checks 'U1000,SA4*' ./...
go list -f '{{.ImportPath}} {{join .GoFiles " "}} {{join .IgnoredGoFiles " "}}' ./...
```
`deadcode -test` reported the two public helpers and unused test pointer helper
listed above. Staticcheck reported only `stringPtr` (`U1000`) and exited with
status 1 because of that finding; it reported no additional selected `SA4*`
diagnostics. These are audit observations, not successful lint sign-offs.
Neither tool establishes that all tested or serialized state is necessary,
which is why the write-only and redundant fields required manual review.
Rapid Type Analysis starts from executable roots; tests add roots, and example
programs do not exercise every library consumer path. The analysis is local-build
evidence, not proof about all platforms or downstream programs. In particular,
platform-specific schema handling and optional library operations were retained.
Existing offline race-enabled tests passed:
```sh
go test -race -count=1 ./internal/configuration ./internal/domain ./internal/filecatalog ./internal/prompt ./internal/engine ./internal/usecase
go test -race -count=1 -run 'Test(ConfigurationInspection|PreparedExecution|ValidateRequestMatchesPrepareContract|AppendedMessagesComposeFrozenEffectivePrompt)' .
```
Tool execution left `go.mod` and `go.sum` unchanged. No production changes,
test removals, provider calls, or new validation gates were made during this
review.

View File

@@ -0,0 +1,570 @@
# Consolidated Pre-1.0 Audit Catalog
Consolidated on 2026-11-04 from the 11 audit documents in this directory.
Their 41 numbered findings are represented by 34 entries below. Entries that
describe the same underlying issue share one remediation scope; missing
regression coverage for an identified defect is included with that defect.
Some broad testing findings consequently support more than one entry.
The order reflects consequence, exposure, and the value of clarifying public
contracts before v1.0. Confirmed defects and conditional security exposure come
first, followed by contract/test gaps, resource efficiency, and maintainability.
This ordering does not upgrade the original severities or turn every cleanup
into a release gate. The source audits retain their evidence, qualifications,
and validation results. None of these entries is marked remediated by this
documentation-only consolidation.
## 1. Update The Audited Maintainer Toolchain For Applicable Security Fixes
**Conditional security exposure.** The audited Go 1.27.0 toolchain predates
networking fixes applicable to HTTP clients, including HTTP/2 CPU exhaustion
and conditional certificate-trust exposure. Use a patched maintainer toolchain
and repeat the focused security assessment against it. Actual downstream
exposure depends on the toolchain that builds the consumer and its transport
and trust configuration; raising PromptKit's minimum Go requirement is a
separate compatibility decision, not an implied remedy.
Sources: [Security 1](07-security.md#1-the-audited-go-toolchain-predates-applicable-networking-security-fixes).
## 2. Bound Authentication-Probe Response Consumption
**Confirmed resource/security defect.** Authentication diagnostics read a
successful upstream body with unbounded `io.ReadAll`. A deadline does not bound
the bytes a fast server can supply. Introduce finite response consumption and
clean closure, with an explicit choice of byte budget and overflow reporting.
Overflow should not be interpreted as proof of rejected authentication.
Relocating the provider adapter in entry 26 would not itself fix this defect.
Sources: [Resources 1](05-resources.md#1-authentication-diagnostics-read-an-unbounded-upstream-body),
[Security 2](07-security.md#2-authentication-probes-permit-unbounded-remote-body-memory-consumption).
## 3. Avoid Exponential Expansion During Convenience Source Conversion
**Confirmed resource defect.** `ProfileSource` and `BackendSource` conversion
expand repeated references before ConfigKit performs its bounded snapshot.
A small shared acyclic graph can therefore cause exponential work and
allocation. Preserve shared references during conversion, with suitable cycle
and slice-view handling, rather than duplicating ConfigKit's validation or
introducing PromptKit-side raw-source limits. Add focused shared-graph and
deferred-failure regressions for both convenience helpers.
Sources: [Resources 2](05-resources.md#2-convenience-source-conversion-expands-shared-graphs-before-bounded-loading),
[Testing 3](08-testing.md#3-programmatic-provider-value-conversion-lacks-representative-value-cases).
## 4. Resolve The ArtifactReader Buffer-Ownership Contract
**Confirmed race under the documented collaborator contract.** The public
reader contract permits buffer reuse after `Read` returns, before PromptKit
can finish copying it. Concurrent preparation with a compliant custom reader
can race. Clarify ownership/lifetime per invocation and align adapters, GoDoc,
and concurrency tests. This needs a deliberate public-contract decision before
v1.0: an immediate copy alone cannot guarantee safety if the reader is allowed
to mutate the buffer immediately. No default-reader defect was established.
Sources: [Interfaces 1](09-interfaces.md#1-artifactreader-permits-buffer-reuse-before-promptkit-can-copy-it).
## 5. Preserve Exact Numbers In Programmatic Provider Sources
**Confirmed data-integrity defect.** Convenience source conversion falls back
from integer parsing to `float64` without checking precision, silently changing
some accepted `json.Number` values. Preserve an exact ConfigKit-compatible
representation, or defer an unsupported-value error to ConfigKit instead of
rounding or converting the number into a string. Regression coverage should
include exact-number boundaries, nested typed collections, mutation isolation,
and deferred unsupported/cyclic-value errors for both source helpers.
Sources: [Correctness 1](01-correctness.md#1-provider-source-helpers-silently-change-exact-json-numbers),
[Testing 3](08-testing.md#3-programmatic-provider-value-conversion-lacks-representative-value-cases).
## 6. Make Provider-Native Schema Constraints Self-Contained
**Confirmed implementation gap; packaging policy needs selection.** Local
validation freezes referenced schema resources, but the provider receives only
the root document and cannot resolve its local external references. Select
correct resource packaging, or an explicit local-only fallback policy, without
naively inlining recursive schemas or silently dropping native constraints.
Add a wire-level regression demonstrating a usable provider constraint, not
merely successful local compilation. Entry 21 is a separate compilation
refactor and does not solve this packaging problem.
Sources: [Correctness 2](01-correctness.md#2-external-schema-resources-do-not-reach-the-provider-constraint),
[Testing 5](08-testing.md#5-multi-resource-schema-tests-do-not-establish-a-usable-provider-constraint).
## 7. Preserve The Owning Operation's Public Error Category
**Confirmed classification defect.** Public error conversion searches nested
causes in a global priority order, allowing a collaborator's sentinel to hide
the enclosing operation's category. Map the owning boundary independently
while retaining the original cause for `errors.Is`/`errors.As`. Add composed
cause regressions; simply reordering global checks can break legitimate direct
not-found versus load-failure distinctions.
Sources: [Error handling 1](06-error-handling.md#1-nested-collaborator-errors-can-hide-the-owning-operations-public-category),
[Testing 4](08-testing.md#4-error-tests-miss-composed-causes-and-pre-dispatch-diagnostic-failures).
## 8. Distinguish Response Stream Failures From Malformed JSON
**Confirmed error-handling defect.** Non-context I/O failures while decoding a
successful HTTP response or checking its end are reported as malformed JSON,
losing the transport cause; complete JSON followed by a read failure can also
be misreported as trailing data. Retain and classify reader failures separately
using the existing redacted transport boundary. Preserve cancellation
precedence and genuine malformed/truncated/oversized-content handling. Cover
both read locations without adding transport retries.
Sources: [Error handling 2](06-error-handling.md#2-successful-status-body-read-failures-are-misreported-as-malformed-json),
[Testing 4](08-testing.md#4-error-tests-miss-composed-causes-and-pre-dispatch-diagnostic-failures).
## 9. Return Invalid Diagnostic Request Construction As A Go Error
**Confirmed diagnostic-contract defect.** Some invalid inference settings fail
before dispatch yet produce a completed report with a nil Go error. Propagate
local request-construction failures through `ErrInvalidRequest` and add a
no-dispatch regression. Preserve intentionally completed diagnostic outcomes
for missing credentials, capacity outcomes, and unsupported or inapplicable
operations; not every no-dispatch outcome is invalid configuration.
Sources: [Error handling 3](06-error-handling.md#3-inference-diagnostics-swallow-some-invalid-request-construction-errors),
[Testing 4](08-testing.md#4-error-tests-miss-composed-causes-and-pre-dispatch-diagnostic-failures).
## 10. Remove Quadratic Requirement-Evidence Union Work
**Confirmed algorithmic inefficiency.** A left-heavy recognized condition can
repeatedly copy growing missing-key evidence sets, producing quadratic work.
Use operation-local accumulation or ownership-aware merging while preserving
classification and conservative inspection coverage. Do not address this by
adding expression limits or expanding the recognized template language. Entry
23's traversal cleanup is related but is not a substitute for fixing this
specific accumulation algorithm.
Sources: [Efficiency 1](02-efficiency.md#1-condition-evidence-unions-can-require-quadratic-work).
## 11. Exercise RunRequest's Privacy Formatting Boundary
**Missing protection, not a demonstrated leak.** Request formatting methods
have no execution coverage despite owning a privacy-sensitive public boundary.
Add focused tests with distinct synthetic private markers for the relevant
value/pointer formatting paths and JSON omission behavior. Assert privacy and
the documented boundary, not an incidental complete diagnostic or JSON shape.
Sources: [Testing 1](08-testing.md#1-runrequests-privacy-formatting-contract-is-not-exercised).
## 12. Correct Profile Inspection Credential Documentation
**Public-contract documentation defect.** Inspection GoDoc incorrectly makes
required credentials and an environment variable mutually exclusive. Actual
behavior permits an explicit profile environment variable while suppressing
only the backend-derived default when required credentials are selected.
Correct the field/method GoDoc and consumer example, keeping the established
configuration behavior and DTO shape.
Sources: [Interfaces 2](09-interfaces.md#2-profile-inspection-misstates-required-credential-semantics).
## 13. Correct The Configured Provider-Map Wire Documentation
**Integration-contract documentation defect.** The wire document says backend
and profile provider maps replace one another, but configuration recursively
overlays them; runtime replacement is a separate rule. Align the integration
summary with the canonical format reference rather than maintaining a second
conflicting precedence definition. Entry 19 concerns the efficiency of the
existing overlay, not a change to its semantics.
Sources: [Interfaces 3](09-interfaces.md#3-the-wire-contract-incorrectly-says-configured-provider-maps-replace).
## 14. Remove Stale Identity And Version GoDoc
**Public-contract documentation defect.** Inspection comments still describe
trimmed profile IDs, and listing comments describe a removed prompt-version
field. Update them to the implemented literal identity and current collection
version model. Do not normalize meaningful whitespace or reintroduce a field
to match obsolete comments.
Sources: [Interfaces 4](09-interfaces.md#4-identity-godoc-still-describes-removed-normalization-and-version-fields).
## 15. Cover The Public Single-Schema-File Option
**Public wiring coverage gap.** Direct filesystem-validator tests do not
exercise `WithSchemaFile` through engine construction. Add offline temporary
file cases through public preparation/validation, including option precedence
and relevant file errors. This is a supported feature, not dead code; entry
32 removes only an unused private helper result.
Sources: [Testing 2](08-testing.md#2-the-public-single-schema-file-option-has-no-execution-coverage).
## 16. Detach Inert Prepared Handles From The Execution Owner
**Avoidable retention.** Used or discarded prepared handles retain their
runner, and therefore the engine's captured configuration and collaborators.
Use a narrower ownership identity or clear the unnecessary owner reference
safely. Preserve foreign-owner rejection, one-time claim, namespace-view
behavior, and available details. This is unnecessary retention when a handle
outlives its engine, not evidence of an ever-growing global leak.
Sources: [Resources 3](05-resources.md#3-inert-prepared-handles-retain-their-entire-execution-owner).
## 17. Bound Repair Feedback's Temporary Serialization
**Avoidable peak allocation.** Feedback formatting serializes potentially
large diagnostic text before truncating the final message, so its existing
output bound does not bound scratch memory. Produce bounded encoded feedback
incrementally, preserving the existing output semantics and complete stored
validation diagnostics and previous candidate. A new raw-diagnostic input
limit is not implied.
Sources: [Resources 4](05-resources.md#4-repair-feedbacks-output-bound-does-not-bound-its-temporary-serialization).
## 18. Detach Normalized Session IDs From Large Input Strings
**Avoidable retention.** A short trimmed session ID can retain the allocation
of a much larger padded caller string. Clone the accepted normalized value at
its ownership boundary. Preserve trimming, accepted content, code-point
validation, and resulting wire/hash content rather than adding a raw-input
size restriction.
Sources: [Resources 5](05-resources.md#5-short-normalized-session-ids-can-pin-large-input-strings).
## 19. Avoid Repeated Descendant Cloning During Provider Overlay
**Construction-time inefficiency.** Recursive overlays repeatedly clone and
sort overlapping descendant maps, giving work proportional to tree size times
depth. Existing depth bounds mean this is not unbounded quadratic growth.
Clone ownership once or use a single traversal while retaining recursive-map,
null, and array semantics. Coordinate with entry 24 without assuming all JSON
walkers have interchangeable policies.
Sources: [Efficiency 2](02-efficiency.md#2-recursive-provider-overlay-repeatedly-clones-descendant-subtrees).
## 20. Index Filtered Listings By Namespace
**Repeated inspection overhead.** Filtered listing currently copies and scans
the complete inventory even when few or no entries match. Capture namespace
buckets or equivalent ranges so queries operate on the relevant subset.
Preserve ordering, exact identity matching, cancellation, and caller-owned
results. A pagination API or runtime cache is not needed for this improvement.
Sources: [Efficiency 3](02-efficiency.md#3-filtered-listings-copy-and-scan-the-complete-inventory).
## 21. Share Schema Compilation And Declare The Runner's Real Dependency
**Maintainability and interface clarity.** Four host-file/`fs.FS`,
direct/prepared paths repeat the schema compilation lifecycle. The runner also
declares a validator interface while requiring preparation through a runtime
assertion. Share the lifecycle behind source-specific adapters and make that
dependency explicit. Preserve malformed-output short-circuiting before schema
I/O, source containment, exact numbers, cancellation, and frozen plans. Retain
independent cross-source tests; do not force whole entry points to be identical.
Sources: [Complexity 1](03-complexity.md#1-consolidate-schema-compilation-and-express-the-runners-actual-interface),
[Equivalence 1](10-equivalence.md#1-share-the-schema-compilation-lifecycle-across-source-and-execution-paths).
## 22. Decompose Configuration Validation And Materialization
**Maintainability.** Large configuration operations mix record-specific
validation, resource capture, and effective-profile construction. Extract
focused helpers with visible inputs and ownership, rather than another generic
validation framework. Preserve final-handoff validation, presence/null rules,
origins, and meaningful fail-fast/error-precedence behavior. Do not turn
incidental ordering of independent validation failures into a new guarantee. The configuration-boundary ADR remains
authoritative; this is not a proposal to inspect raw ConfigKit sources.
Sources: [Complexity 2](03-complexity.md#2-decompose-configuration-validation-and-resource-materialization).
## 23. Clarify Requirement Traversal And Share Branch Orchestration
**Maintainability.** Separate recognition/evaluation from reachability and
evidence bookkeeping, particularly the repeated `if`/`with` branch protocol.
Keep their scope differences explicit and retain independent comparisons with
the actual Go renderer. Preserve conservative partial coverage and inactive
computed-name behavior. This does not justify a general template interpreter
or stronger execution-readiness promises from requirements inspection.
Sources: [Complexity 3](03-complexity.md#3-untangle-requirement-traversal-expression-evaluation-and-evidence),
[Equivalence 3](10-equivalence.md#3-share-ifwith-requirement-branch-orchestration-not-template-execution).
## 24. Centralize Compatible JSON Copying Without Conflating Policies
**Maintainability and ownership clarity.** The facade maintains a separate
reflection traversal for already-normalized values despite an existing
`internal/jsonvalue` owner. Consolidate genuinely compatible structural
copying while retaining DTO projection at the facade and distinct source
encoding/runtime validation policies. Preserve concrete types, nils, exact
numbers, schema keys, and ownership. Infallible projection must not acquire
new errors, panics, or silent zero-value fallbacks. Entries 3 and 5 remain
separate source-conversion defects, not reasons to reject pre-handoff sources.
Sources: [Complexity 4](03-complexity.md#4-clarify-ownership-and-policies-across-the-json-value-walkers),
[Equivalence 2](10-equivalence.md#2-centralize-structural-copying-of-already-normalized-json-values).
## 25. Separate Attempt Accounting From Generation Control Flow
**Maintainability.** Generation coordination interleaves dispatch/retry policy
with parallel return values and repeated evidence/observation projection.
Introduce a small attempt result and focused accounting helpers. Preserve
built-in versus injected-client differences, permit release before callbacks
or backoff, frozen requests, existing retry classification, private evidence,
and known/unknown usage distinctions. Do not collapse legitimately different
dispatch strategies merely to reduce lines.
Sources: [Complexity 5](03-complexity.md#5-extract-attempt-accounting-from-generation-control-flow).
## 26. Move OpenRouter Authentication Protocol Details Into Its Adapter
**Architectural ownership.** Use-case code currently interprets OpenRouter's
account-response fields and provider-relative endpoint while the outbound
adapter owns HTTP execution. Place protocol construction/recognition with the
outbound integration; keep diagnostic selection, applicability, cancellation,
completion, and neutral reporting in the use case. Preserve credential/body
privacy and anonymous probing. No plugin framework, reverse dependency, or
blanket prohibition on HTTP facts is needed. Coordinate the body-bound fix
in entry 2, which is a distinct behavioral change.
Sources: [Boundaries 2](11-boundaries.md#2-put-openrouter-authentication-protocol-details-behind-the-outbound-adapter).
## 27. Narrow Smoke-Value Copy Helpers To Real Consumers
**Ownership cleanup.** A request cloner has only its self-test as a caller;
the full-report cloner is used to detach only a target. Express that actual
copy operation directly and retire unused broader cloning after checking
callers. Keep facade request/report projection, active backend cloning,
provider-map errors, ownership tests, and redaction. Do not introduce shared
public/internal DTOs or an additional full-report copy.
Sources: [Boundaries 3](11-boundaries.md#3-narrow-smoke-value-copy-helpers-to-their-actual-ownership-boundaries).
## 28. Give Bundled Catalog Expectations One Test Owner
**Test duplication.** Complete bundled profile/model/provider expectations are
repeated across several test layers. Keep one independently specified asset
oracle, with smaller tests for distinct loading, wire, and diagnostic behavior.
Use controlled fixtures where bundled churn is irrelevant and structural
assertions rather than formatted-map strings. Retain meaningful bundled
coverage instead of deriving every expected value from the loader under test.
Sources: [Testing 7](08-testing.md#7-bundled-catalog-expectations-have-multiple-test-owners).
## 29. Stop Pinning The Opaque Artifact Hash Algorithm In Tests
**Over-specified tests.** Artifact tests and a benchmark assert a literal hash
algorithm despite the public value being opaque. Assert the documented
equality/ownership relationships and actual byte/size behavior instead; keep
benchmark setup outside its timed work. Coordinate with entry 31, but do not
turn today's hash representation into a new compatibility guarantee.
Sources: [Testing 6](08-testing.md#6-artifact-tests-and-benchmarks-pin-an-explicitly-opaque-hash-algorithm).
## 30. Remove Unconsumed Backend Presence Bookkeeping
**Low-priority state cleanup.** Backend field-state maps and
`QueueCapacitySet` have no runtime reader; effective values and ConfigKit
already own queue defaulting and provenance. Remove only these unused records
and adjust tests to assert effective behavior or operator inspection. Keep
profile/input presence tracking, explicit zero capacity, and derived defaults.
Sources: [Dead code 1](12-deadcode.md#1-remove-backend-presence-bookkeeping-with-no-runtime-consumer).
## 31. Retire Duplicate Fields In Captured Prompt Definitions
**Low-priority representation cleanup.** Captured definitions duplicate the
output format and carry an unused runtime message-file field after content
materialization. Use the captured output contract and text as their respective
sources of truth. Preserve real YAML `content_file` loading, origins, and the
request-override default rule. Removing serialized internal fields changes
current definition hashes: retain documented opaque equality relationships,
not an invented bit-identical encoding promise.
Sources: [Dead code 2](12-deadcode.md#2-retire-legacy-fields-in-the-captured-prompt-representation).
## 32. Remove Unused Private Diagnostic State And Helper Results
**Low-priority cleanup.** A private reachability outcome echoes an endpoint
that report assembly never reads; the schema-file helper returns a directory
that its only caller discards. Remove the unused state/result while preserving
actual sanitized request/report evidence, directory calculation, file checks,
and public single-file schema support. Keep privacy tests against the values
that real execution consumes.
Sources: [Dead code 3](12-deadcode.md#3-remove-unused-diagnostic-state-and-a-discarded-helper-result).
## 33. Remove Orphan Internal Helpers And Their Self-Tests
**Low-priority cleanup.** `filecatalog.RelativePath` and
`resourceNamespaceFromID` have only their own tests as callers, and the runner
test's `stringPtr` helper is entirely unused. Remove those narrow surfaces
without replacement, preserving real discovery, identity qualification, and
namespace inventory/filtering coverage. Reachability from examples alone is
not a reason to remove optional public APIs.
Sources: [Dead code 4](12-deadcode.md#4-remove-orphan-helpers-whose-only-callers-are-their-own-tests).
## 34. Decide Whether To Retain The Legacy Capacity Client Wrapper
**Decision-dependent architectural cleanup.** The old capacity client wrapper
is used by tests, whereas production dispatch uses the generation coordinator.
It is also explicitly retained in current internal documentation; the dead-code
audit does not classify it as obsolete functionality. If that retention decision
is revised, exercise the production coordinator in runner tests and move any
needed one-shot capacity scaffolding into tests before retiring the wrapper.
Preserve FIFO, cancellation, independent-pool, and panic-release coverage.
This is not a second scheduling algorithm or a confirmed concurrency defect.
Sources: [Boundaries 1](11-boundaries.md#1-retire-the-test-only-capacity-client-as-a-production-scheduling-surface),
[Dead-code retention qualification](12-deadcode.md#intentional-retention-and-related-findings).
## Open Decisions
Work through the decisions below in this order. These are unresolved choices,
not additional findings or approved changes to public contracts. A decision
blocks its dependent remediation, not unrelated fixes. In particular, updating
the maintainer toolchain need not wait for the consumer-minimum decision.
Implementation details and optional cleanup do not become release gates merely
by appearing in this catalog.
### Decision 1: ArtifactReader Result Ownership And Lifetime
**Related finding:** [4](#4-resolve-the-artifactreader-buffer-ownership-contract).
**Question:** What ownership does a successful `Read` transfer, and when may
an injected reader mutate or reuse the returned storage?
**Recommendation:** Transfer an independent result to PromptKit on successful
return. The reader must stop mutating the returned artifact and its body,
including through retained aliases. Fresh per-invocation storage is the simplest
implementation; immutable shared data can remain shared if no collaborator
mutates it. This removes the impossible immediate-reuse permission without
adding synchronization or a release protocol to the interface. Explain the
consumer impact as a deliberate pre-1.0 contract correction.
**Alternative:** Permit borrowing until the enclosing preparation/execution
operation returns. This can support application-managed reuse, but a reader
cannot generally observe that lifetime and concurrent calls need independent
storage or caller coordination. If reuse immediately after `Read` returns is
required, an explicit acknowledgement/release API is another viable design;
it enables pooling at the cost of a larger interface and lifecycle change.
**Resolve before implementation:** Select the ownership/lifetime rule and update
GoDoc, adapter assumptions, and the regression scenario together. Another
unsynchronized copy is not a remedy for the current permission.
### Decision 2: Provider Submission Of Multi-Resource Schemas
**Related findings:** [6](#6-make-provider-native-schema-constraints-self-contained)
and [21](#21-share-schema-compilation-and-declare-the-runners-real-dependency).
**Question:** How should native structured output represent a schema whose
local validation depends on external resources?
**Recommendation:** First investigate packaging the operation's captured
resource graph into a self-contained provider constraint. Check identifiers,
relative references, anchors, recursion, and supported dialects before choosing
an encoding. Prefer correct packaging when it fits a proportionate scope,
while keeping resource completeness distinct from any provider's supported
schema subset. A standalone offline validation of the actual emitted constraint
should establish the selected packaging behavior.
**Alternative:** Offer an explicit local-only validation choice when native
submission cannot represent the resource graph. This preserves useful local
validation and avoids building a general schema transformation system, but
forgoes native generation constraints for that selected mode and needs a clear
consumer-facing selection mechanism. Silently dropping native constraints is
not equivalent to an explicit choice.
**Resolve before implementation:** Establish packaging feasibility and select
the submission/fallback policy, including behavior for unsupported cases.
Coordinate the compilation refactor with that result; it does not itself fix
provider packaging or establish universal provider acceptance.
### Decision 3: Authentication Response Budget And Overflow Outcome
**Related findings:** [2](#2-bound-authentication-probe-response-consumption)
and [26](#26-move-openrouter-authentication-protocol-details-into-its-adapter).
**Question:** What finite response budget should the authentication adapter
apply, and how should a probe report exceeding it?
**Recommendation:** Select a conservative fixed budget for this small protocol,
check actual streamed bytes regardless of declared length, and close on overflow
without unbounded draining. Report the diagnostic failure with acceptance
unknown, retaining any independently observed HTTP facts. Agree the exact byte
count and its representation in the existing diagnostic report before coding;
the catalog does not select either implicitly.
**Alternative:** Expose a caller-configurable budget with a finite default.
This accommodates unusual legitimate responses but expands configuration and
validation surface. It is worthwhile if a concrete consumer requirement cannot
be served by an appropriately chosen fixed budget; it is not necessary merely
to address the demonstrated unbounded read.
**Resolve before implementation:** Choose the size, overflow classification,
and whether configurability is warranted. Keep this fix independent of adapter
relocation so architectural cleanup does not delay the resource remedy.
### Decision 4: Exact Numbers And The Source-Conversion Boundary
**Related findings:** [3](#3-avoid-exponential-expansion-during-convenience-source-conversion),
[5](#5-preserve-exact-numbers-in-programmatic-provider-sources), and
[24](#24-centralize-compatible-json-copying-without-conflating-policies).
**Question:** Which exact numeric values can the convenience helpers preserve
through the current ConfigKit mapping boundary, and what happens to others?
**Recommendation:** Preserve supported values through the complete conversion,
load, and outbound-encoding path; defer an unsupported-value error when exact
preservation is unavailable. Compare numeric meaning rather than spelling or
successful parsing alone. Address shared-graph expansion in the same adapter
with completed-conversion memoization, distinct cycle tracking, and correct
slice-view identity, preserving source capture and deferred failures.
**Alternative:** Request upstream support for additional exact numeric/value
forms, then simplify or remove the relevant PromptKit conversion. This may
provide a cleaner long-term boundary and broader preservation, but introduces
an upstream dependency and should not postpone fixing silent rounding or
exponential expansion. Runtime JSON copying remains a separate policy.
**Resolve before implementation:** Establish the supported numeric round-trip
cases and whether broader upstream support is needed now. Do not substitute
numeric-looking strings or introduce a second raw-source validation system.
### Decision 5: Consumer Go Minimum After The Toolchain Update
**Related finding:** [1](#1-update-the-audited-maintainer-toolchain-for-applicable-security-fixes).
**Question:** Should the security fixes also change PromptKit's declared minimum
consumer Go version?
**Recommendation:** Update the maintainer toolchain and repeat the focused
assessment promptly; initially keep the consumer minimum decision separate.
Advise affected consumers to rebuild with a patched toolchain. This recognizes
that PromptKit publishes source and that downstream build/transport choices
control the deployed exposure.
**Alternative:** Raise the declared minimum to the selected patched release.
This makes the supported floor clearer and steers module consumers toward the
fixed toolchain, but changes compatibility and does not repair already-built
applications. Assess it under the existing release policy rather than treating
it as an automatic consequence of the scanner output.
**Resolve before changing the minimum:** Confirm current advisory applicability,
select the patched version, and decide whether changing the supported consumer
floor is justified. No new recurring release gate is implied.
### Decision 6: Retention Of The Legacy Capacity Client Wrapper
**Related finding:** [34](#34-decide-whether-to-retain-the-legacy-capacity-client-wrapper).
**Question:** Does the wrapper still justify a production surface for its
currently documented test-focused purpose?
**Recommendation:** Retire the production wrapper after moving the runner's
integration test to the production generation coordinator. Keep direct manager
coverage and any useful one-shot scaffolding in tests. This reduces maintenance
and makes assembled scheduling tests reflect production while preserving the
actual FIFO, cancellation, independent-pool, and panic-release scenarios.
**Alternative:** Retain it if a concrete internal use justifies the small
adapter. This avoids test migration and preserves that convenient composition,
but keeps a second assembly surface to understand and maintain. Existing
self-tests alone do not establish a future production need.
**Resolve before removal:** Confirm the intended retention decision and test
ownership. This cleanup can be deferred without blocking the behavioral fixes;
it is not evidence of a second scheduler or a concurrency defect.

View File

@@ -38,8 +38,8 @@ consumers.
## Ideas ## Ideas
No ideas are currently awaiting selection. Active feature work belongs in its No unselected ideas are currently recorded here. Deliberately postponed ideas
focused roadmap rather than this catalog. remain in the [deferred catalog](deferred.md).
## Entry Format ## Entry Format

119
engine.go
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
@@ -24,9 +24,8 @@ var (
// ErrInvalidRequest identifies a request whose required values, overrides, // ErrInvalidRequest identifies a request whose required values, overrides,
// credentials, or effective settings are invalid. // credentials, or effective settings are invalid.
ErrInvalidRequest = errors.New("invalid run request") ErrInvalidRequest = errors.New("invalid run request")
// ErrPromptNotFound identifies a requested prompt ID or version that is not // ErrPromptNotFound identifies a requested prompt ID that is not present in
// present in the selected prompt source. It does not also match // the captured configuration. It does not also match ErrPromptLoad.
// ErrPromptLoad.
ErrPromptNotFound = errors.New("prompt not found") ErrPromptNotFound = errors.New("prompt not found")
// ErrProfileNotFound identifies a selected profile ID that is absent from // ErrProfileNotFound identifies a selected profile ID that is absent from
// every configured profile source. It does not also match ErrProfileLoad. // every configured profile source. It does not also match ErrProfileLoad.
@@ -85,20 +84,26 @@ var (
// Engine inspects prompts and profiles, enumerates prompt requirements, and // Engine inspects prompts and profiles, enumerates prompt requirements, and
// prepares and runs Promptkit prompt requests. // prepares and runs Promptkit prompt requests.
// //
// An Engine is safe for concurrent calls to [Engine.InspectConfiguration], // An Engine is safe for concurrent calls to [Engine.WithNamespaces],
// [Engine.InspectConfiguration],
// [Engine.InspectPrompt], // [Engine.InspectPrompt],
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution], // [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
// [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent // [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles],
// backend-capacity pools that coordinate Run and RunPrepared admission and // [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and
// model generation. Injected collaborators may still be invoked concurrently // [Engine.ListCollections]. Engines constructed separately by [NewEngine] own
// across different backend pools or for unlimited backends. // independent backend-capacity pools that coordinate Run, RunPrepared, and
// inference SmokeTest admission and model generation. Namespace views returned
// by [Engine.WithNamespaces] share their underlying engine's pools, clients,
// and prepared-execution owner. Injected collaborators may still be invoked
// concurrently across different backend pools or for unlimited backends.
type Engine struct { type Engine struct {
internal *internalengine.Engine internal *internalengine.Engine
} }
// 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 {
@@ -150,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].
// //
@@ -208,11 +222,13 @@ func WithSchemaFile(path string) Option {
// NewEngine constructs an Engine from configuration and options. // NewEngine constructs an Engine from configuration and options.
// //
// ConfigKit composes maintained catalog inputs, PromptKit-owned definitions, // ConfigKit composes caller-selected underlays in their given order, followed
// caller underlays, and the optional main input in that order. Construction // by the optional main input, after the default-on bundled OpenRouter underlay.
// validates the final effective backend, profile, and prompt definitions and // The bundle adds a root backend and profiles to inventories, but no prompts
// captures referenced prompt content. A missing prompt collection is allowed. // or implicit profile selection. Construction validates the final effective
// Schema documents and request artifacts remain runtime resources. // backend, profile, and prompt definitions and captures referenced prompt
// content. A missing prompt collection is allowed. Schema documents and
// request artifacts remain runtime resources.
// //
// NewEngine returns an error matching ErrInvalidConfig for invalid // NewEngine returns an error matching ErrInvalidConfig for invalid
// configuration or options, preserving a ConfigKit LoadError when loading // configuration or options, preserving a ConfigKit LoadError when loading
@@ -241,14 +257,13 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
return &Engine{internal: assembled}, nil return &Engine{internal: assembled}, nil
} }
// InspectPrompt resolves one explicit prompt definition without selecting a // InspectPrompt resolves one prompt ID without selecting a profile or starting
// profile or starting execution work. // execution work.
// //
// InspectPrompt requires a nonblank promptID. It passes nonblank promptID and // InspectPrompt requires a nonblank promptID and performs an exact,
// promptVersion values unchanged to the engine's ordinary, case-sensitive // case-sensitive lookup in the captured configuration snapshot. A
// prompt selection. An empty version succeeds only when that ID has one // namespace-scoped view qualifies slashless caller IDs before lookup; already
// version; a nonempty version selects one exact ID/version pair from the // qualified IDs pass through.
// captured configuration snapshot.
// //
// 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
@@ -267,21 +282,19 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
// or content files; construct another Engine to observe source changes. // or content files; construct another Engine to observe source changes.
// //
// A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID // A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID
// matches ErrInvalidRequest. An absent exact ID or version matches // matches ErrInvalidRequest. An absent exact ID matches ErrPromptNotFound and
// ErrPromptNotFound and not ErrPromptLoad. An ambiguous omitted version matches // not ErrPromptLoad. Invalid definitions and resource failures reject
// ErrPromptLoad. Invalid definitions and resource failures reject construction // construction instead. Cancellation during lookup preserves the context
// instead. Cancellation during lookup preserves the context error. InspectPrompt // error. InspectPrompt returns no partial result on error.
// returns no partial result on error.
func (e *Engine) InspectPrompt( func (e *Engine) InspectPrompt(
ctx context.Context, ctx context.Context,
promptID string, promptID string,
promptVersion string,
) (*PromptInspection, error) { ) (*PromptInspection, error) {
if e == nil || e.internal == nil { if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
} }
inspection, err := e.internal.InspectPrompt(ctx, promptID, promptVersion) inspection, err := e.internal.InspectPrompt(ctx, promptID)
if err != nil { if err != nil {
return nil, mapPublicError(err) return nil, mapPublicError(err)
} }
@@ -346,6 +359,32 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
// relationship to ctx.Err is promised. Prepare returns no partial result on // relationship to ctx.Err is promised. Prepare returns no partial result on
// error. It validates RunRequest.TransientRetries but makes no model attempts. // error. It validates RunRequest.TransientRetries but makes no model attempts.
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) { func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
prepared, err := e.prepareRequest(ctx, req)
if err != nil {
return nil, err
}
return fromDomainPreparedRun(prepared), nil
}
// ValidateRequest reports whether req passes the same preparation path used by
// [Engine.Prepare] at the time of this call. A nil error means this concrete
// request passed preparation. It checks only this invocation and returns errors
// as encountered rather than aggregating them. All supplied inputs follow the
// same loading behavior as [Engine.Prepare], including inputs unused by the
// selected template branch. The method does not call an LLM or retain a
// prepared snapshot or executable handle, but preparation may still perform
// resource I/O and incur its associated time and memory costs.
//
// Its errors and context handling follow [Engine.Prepare], including an error
// matching ErrInvalidConfig when the Engine is nil. Success does not guarantee
// provider acceptance or later execution success. Use [Engine.PrepareExecution]
// when execution must use the snapshot produced by preparation.
func (e *Engine) ValidateRequest(ctx context.Context, req RunRequest) error {
_, err := e.prepareRequest(ctx, req)
return err
}
func (e *Engine) prepareRequest(ctx context.Context, req RunRequest) (*domain.PreparedRun, error) {
ctx = observation.WithoutExecutionState(ctx) ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil { if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
@@ -360,15 +399,16 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
if err != nil { if err != nil {
return nil, mapPublicError(err) return nil, mapPublicError(err)
} }
return fromDomainPreparedRun(prepared), nil return prepared, nil
} }
// PrepareExecution completely prepares a prompt request without calling the // PrepareExecution completely prepares a prompt request without calling the
// configured LLMClient or reserving backend admission capacity. Validated // configured LLMClient or reserving backend admission capacity. Validated
// RunRequest.AppendedMessages are included in the frozen effective messages. // RunRequest.AppendedMessages are included in the frozen effective messages.
// //
// The returned opaque handle is bound to this Engine and permits one // The returned opaque handle is bound to this Engine's underlying execution
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources, // owner, shared with its namespace views, and permits one [Engine.RunPrepared]
// invocation in total. Preparation freezes the selected sources,
// rendered messages, effective settings, inputs, provider structured-output // rendered messages, effective settings, inputs, provider structured-output
// metadata, and validation resources needed by that invocation. The handle // metadata, and validation resources needed by that invocation. The handle
// retains a direct RunRequest.APIKey only in private execution state; // retains a direct RunRequest.APIKey only in private execution state;
@@ -480,6 +520,11 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
// RunPrepared atomically claims and executes a handle created by // RunPrepared atomically claims and executes a handle created by
// [Engine.PrepareExecution]. // [Engine.PrepareExecution].
// //
// The originating Engine and namespace views sharing its underlying owner may
// claim the handle. Execution uses its captured selections without applying the
// invoking view's namespaces. An Engine constructed separately by [NewEngine]
// is a foreign owner.
//
// A valid owning-Engine invocation consumes the handle's one attempt before // A valid owning-Engine invocation consumes the handle's one attempt before
// credential revalidation, backend admission, generation, or validation. // credential revalidation, backend admission, generation, or validation.
// Cancellation, capacity rejection, generation failure, operational // Cancellation, capacity rejection, generation failure, operational

View File

@@ -5,12 +5,12 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity" "go.maximumdirect.net/promptkit/internal/capacity"
"gitea.maximumdirect.net/eric/promptkit/internal/generation" "go.maximumdirect.net/promptkit/internal/generation"
"gitea.maximumdirect.net/eric/promptkit/internal/llm" "go.maximumdirect.net/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/profile" "go.maximumdirect.net/promptkit/internal/profile"
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef" "go.maximumdirect.net/promptkit/internal/promptdef"
"gitea.maximumdirect.net/eric/promptkit/internal/usecase" "go.maximumdirect.net/promptkit/internal/usecase"
) )
func mapPublicError(err error) error { func mapPublicError(err error) error {

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

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,18 +1,17 @@
prompts: prompts:
example.assessment: - id: example.assessment
"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: - name: proposal
- name: proposal required: true
required: true content_type: application/json
content_type: application/json messages:
messages: - role: system
- role: system content: Return a concise assessment as JSON.
content: Return a concise assessment as JSON. - role: user
- role: user content: '{{input "proposal"}}'
content: '{{input "proposal"}}' output:
output: format: json
format: json validation_mode: json_schema
validation_mode: json_schema schema_path: assessment.schema.json
schema_path: assessment.schema.json

View File

@@ -1,18 +1,17 @@
prompts: prompts:
example.proposal: - id: example.proposal
"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: - name: idea
- name: idea required: true
required: true content_type: text/plain
content_type: text/plain messages:
messages: - role: system
- role: system content: Return a concise proposal as JSON.
content: Return a concise proposal as JSON. - role: user
- role: user content: '{{input "idea"}}'
content: '{{input "idea"}}' output:
output: format: json
format: json validation_mode: json_schema
validation_mode: json_schema schema_path: proposal.schema.json
schema_path: proposal.schema.json

View File

@@ -0,0 +1,17 @@
backends:
- id: offline
endpoint: https://review.example.invalid/v1
profiles:
- id: audit
backend: review/offline
model: review-model
prompts:
- id: check
default_profile: review/audit
description: Inspect a review item without contacting a model provider.
messages:
- role: user
content: Review the supplied synthetic example.
output:
format: text
validation_mode: none

View File

@@ -0,0 +1,10 @@
version: "2"
config:
imports:
- path: fragments/resources.yaml
required: true
- path: fragments/prepare.yaml
destination: [prompts]
placement: sequence-item
required: true
release_version: "1.4.0"

View File

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

View File

@@ -0,0 +1,11 @@
backends:
- id: offline
endpoint: https://example.invalid/v1
profiles:
- id: base
backend: support/offline
model: offline-model
timeout_seconds: 90
- id: fast
extends: {id: base}
timeout_seconds: 60

View File

@@ -0,0 +1 @@
{{input "note"}}

View File

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

View File

@@ -4,74 +4,162 @@ 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"` 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"` ProfileTimeout int `json:"profile_timeout_seconds"`
Namespaces []string `json:"namespaces"`
Collections []collectionSummary `json:"collections"`
ScopedPrompts []string `json:"scoped_prompts"`
ReviewPrompts []string `json:"review_prompts"`
}
type collectionSummary struct {
Namespace string `json:"namespace"`
ReleaseVersion *string `json:"release_version"`
} }
func main() { func main() {
endpoint, model := "https://example.invalid/v1", "offline-model" if err := run(context.Background(), "examples/go-library/prepare", os.Stdout); err != nil {
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ fmt.Fprintln(os.Stderr, err)
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{ os.Exit(1)
"offline-example": {Endpoint: &endpoint, Model: &model}, }
})}}}}, }
Main: &configkit.Input{Source: configkit.File("examples/go-library/prepare/prompt.yaml")},
}}) func run(ctx context.Context, exampleDir string, out io.Writer) error {
support, err := promptkit.CollectionInput(
"support",
configkit.File(filepath.Join(exampleDir, "collections", "support", "collection.yaml")),
)
if err != nil { if err != nil {
exit(err) return err
}
review, err := promptkit.CollectionInput(
"review",
configkit.File(filepath.Join(exampleDir, "collections", "review", "collection.yaml")),
)
if err != nil {
return err
}
override, err := promptkit.CollectionInput(
"support",
configkit.File(filepath.Join(exampleDir, "collections", "support", "operator-overrides.yaml")),
)
if err != nil {
return err
}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{support, review}},
{Inputs: []configkit.Input{override}},
},
}}, promptkit.WithBundledOpenRouter(false)) // Keep this example's inventory caller-owned.
if err != nil {
return err
}
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
Prompt: "support", Profile: "support", Backend: "support",
})
if err != nil {
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("profiles", "offline-example", "model")); !found { if _, found := view.Explain(configkit.Keys("collections", "support", "profiles").Index(1).Key("timeout_seconds")); !found {
exit(fmt.Errorf("configured profile model was not found")) return fmt.Errorf("collection profile override was not found")
} }
requirements, err := engine.InspectRequirements(context.Background(), promptkit.RequirementInspectionRequest{ requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
PromptID: "example.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")
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ request := promptkit.RunRequest{
PromptID: "example.prepare", PromptID: "prepare",
ProfileID: "fast",
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 := scoped.ValidateRequest(ctx, request); err != nil {
return err
}
prepared, err := scoped.Prepare(ctx, request)
if err != nil {
return err
}
profile, err := scoped.InspectProfile(ctx, "fast")
if err != nil {
return err
}
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
return fmt.Errorf(
"later profile override was not applied: timeout is %d seconds",
profile.EffectiveModelParams.TimeoutSeconds,
)
}
namespaces, err := scoped.ListNamespaces(ctx)
if err != nil {
return err
}
collections, err := scoped.ListCollections(ctx)
if err != nil {
return err
}
scopedPrompts, err := scoped.ListPrompts(ctx)
if err != nil {
return err
}
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
Mode: promptkit.NamespaceNamed, Name: "review",
}) })
if err != nil { if err != nil {
exit(err) return err
}
scopedPromptIDs := make([]string, len(scopedPrompts))
for index := range scopedPrompts {
scopedPromptIDs[index] = scopedPrompts[index].ID
}
reviewPromptIDs := make([]string, len(reviewPrompts))
for index := range reviewPrompts {
reviewPromptIDs[index] = reviewPrompts[index].ID
}
collectionSummaries := make([]collectionSummary, len(collections))
for index := range collections {
collectionSummaries[index] = collectionSummary{
Namespace: collections[index].Namespace, ReleaseVersion: collections[index].ReleaseVersion,
}
} }
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),
}); err != nil { ProfileTimeout: profile.EffectiveModelParams.TimeoutSeconds,
exit(err) Namespaces: namespaces,
} Collections: collectionSummaries,
} ScopedPrompts: scopedPromptIDs,
ReviewPrompts: reviewPromptIDs,
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

@@ -1,17 +0,0 @@
prompts:
example.prepare:
"1.0.0":
default_profile: offline-example
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: '{{input "note"}}'
output:
format: text
validation_mode: basic

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,17 +1,16 @@
prompts: prompts:
example.run: - id: example.run
"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: - name: note
- name: note required: true
required: true content_type: text/plain
content_type: text/plain messages:
messages: - role: system
- role: system content: Summarize the note in one sentence.
content: Summarize the note in one sentence. - role: user
- role: user content: '{{input "note"}}'
content: '{{input "note"}}' output:
output: format: text
format: text validation_mode: basic
validation_mode: basic

View File

@@ -18,9 +18,8 @@ func (r RunRequest) GoString() string {
func (r RunRequest) redactedString() string { func (r RunRequest) redactedString() string {
return fmt.Sprintf( return fmt.Sprintf(
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t AppendedMessages:%d}", "promptkit.RunRequest{PromptID:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t AppendedMessages:%d}",
r.PromptID, r.PromptID,
r.PromptVersion,
r.ProfileID, r.ProfileID,
r.APIKey != "", r.APIKey != "",
len(r.Inputs), len(r.Inputs),

View File

@@ -12,22 +12,21 @@ 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
"1": default_profile: offline
default_profile: offline session_id: '{{.session}}'
session_id: '{{.session}}' messages:
messages: - role: user
- role: user content_file: body.tmpl
content_file: body.tmpl output:
output: format: text
format: text validation_mode: none
validation_mode: none
` `
func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine { func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
@@ -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,16 +144,16 @@ 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{"1": map[string]any{ "prompts": []any{map[string]any{"id": "structured",
"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"}}`}},
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"},
}}}, }},
})}, })},
}}, promptkit.WithSchemaFS(schemas, ".")) }}, promptkit.WithSchemaFS(schemas, "."))
if err != nil { if err != nil {

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

12
go.mod
View File

@@ -1,20 +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.7.1
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.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

14
go.sum
View File

@@ -1,22 +1,16 @@
gitea.maximumdirect.net/eric/ConfigKit v0.7.1 h1:q3HrX4WWCPt1WfMydqFEaAmCf9s6Dq+vknJj0C/mdcI=
gitea.maximumdirect.net/eric/ConfigKit v0.7.1/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=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
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=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

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

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

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

View File

@@ -6,14 +6,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "go.maximumdirect.net/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
) )
const ( const (
// OpenRouterID identifies the maintained OpenRouter backend. // OpenRouterID is the conventional OpenRouter backend identifier.
OpenRouterID = "openrouter" OpenRouterID = "openrouter"
// RakestrawHomeID identifies the maintained Rakestrawhome backend. // RakestrawHomeID is the conventional Rakestrawhome backend identifier.
RakestrawHomeID = "rakestrawhome" RakestrawHomeID = "rakestrawhome"
) )
@@ -31,13 +30,12 @@ type Registry struct {
func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) { func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) {
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 {
params, err := jsonvalue.CopyMap(definition.ExtraParams)
if err != nil {
return nil, fmt.Errorf("backend %q extra parameters: %w", id, err)
}
definition.ID = id definition.ID = id
definition.ExtraParams = params cloned, err := domain.CloneBackend(definition)
registry.backends[id] = definition if err != nil {
return nil, fmt.Errorf("backend %q: %w", id, err)
}
registry.backends[id] = cloned
} }
return registry, nil return registry, nil
} }
@@ -51,12 +49,11 @@ 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)
} }
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams) cloned, err := domain.CloneBackend(definition)
if err != nil { if err != nil {
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err) return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
} }
definition.ExtraParams = extraParams return cloned, nil
return definition, nil
} }
// CapacityPolicies returns a copy of the normalized policies for limited // CapacityPolicies returns a copy of the normalized policies for limited

View File

@@ -5,31 +5,67 @@ 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) {
params := map[string]any{"nested": map[string]any{"enabled": true}} params := map[string]any{"nested": map[string]any{"enabled": true}}
defaultProfile := "catalog-profile"
reachabilityEndpoint := "https://probe.example/ready"
authenticationKind := "openrouter_key"
authenticationEndpoint := "https://probe.example/key"
smokeTests := &domain.BackendSmokeTestConfig{
DefaultProfile: &defaultProfile,
Reachability: &domain.ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint},
Authentication: &domain.AuthenticationProbeConfig{
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
},
}
registry, err := backend.NewEffectiveRegistry(map[string]domain.Backend{ registry, err := 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, QueueCapacitySet: true,
SmokeTests: smokeTests,
}, },
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
params["nested"].(map[string]any)["enabled"] = false params["nested"].(map[string]any)["enabled"] = false
defaultProfile = "mutated-profile"
reachabilityEndpoint = "https://mutated.example/ready"
authenticationKind = "none"
authenticationEndpoint = "https://mutated.example/key"
selected, err := registry.GetBackend(" local.v1 ") selected, err := registry.GetBackend(" local.v1 ")
if err != nil || selected.ID != " local.v1 " || selected.ExtraParams["nested"].(map[string]any)["enabled"] != true { if err != nil || selected.ID != " local.v1 " || selected.ExtraParams["nested"].(map[string]any)["enabled"] != true {
t.Fatalf("effective backend was not captured under its literal ID: %+v, %v", selected, err) t.Fatalf("effective backend was not captured under its literal ID: %+v, %v", selected, err)
} }
if selected.SmokeTests == nil || selected.SmokeTests.DefaultProfile == nil ||
*selected.SmokeTests.DefaultProfile != "catalog-profile" || selected.SmokeTests.Reachability == nil ||
selected.SmokeTests.Reachability.Endpoint == nil || *selected.SmokeTests.Reachability.Endpoint != "https://probe.example/ready" ||
selected.SmokeTests.Authentication == nil || selected.SmokeTests.Authentication.Kind == nil ||
*selected.SmokeTests.Authentication.Kind != "openrouter_key" || selected.SmokeTests.Authentication.Endpoint == nil ||
*selected.SmokeTests.Authentication.Endpoint != "https://probe.example/key" {
t.Fatalf("registry did not capture nested diagnostic metadata: %+v", selected.SmokeTests)
}
selected.ExtraParams["nested"].(map[string]any)["enabled"] = false selected.ExtraParams["nested"].(map[string]any)["enabled"] = false
*selected.SmokeTests.DefaultProfile = "mutated-return"
*selected.SmokeTests.Reachability.Endpoint = "https://mutated-return.example/ready"
*selected.SmokeTests.Authentication.Kind = "none"
*selected.SmokeTests.Authentication.Endpoint = "https://mutated-return.example/key"
again, err := registry.GetBackend(" local.v1 ") again, err := registry.GetBackend(" local.v1 ")
if err != nil || again.ExtraParams["nested"].(map[string]any)["enabled"] != true { if err != nil || again.ExtraParams["nested"].(map[string]any)["enabled"] != true {
t.Fatalf("lookup exposed registry values: %+v, %v", again, err) t.Fatalf("lookup exposed registry values: %+v, %v", again, err)
} }
if again.SmokeTests == nil || again.SmokeTests.DefaultProfile == nil ||
*again.SmokeTests.DefaultProfile != "catalog-profile" || again.SmokeTests.Reachability == nil ||
again.SmokeTests.Reachability.Endpoint == nil || *again.SmokeTests.Reachability.Endpoint != "https://probe.example/ready" ||
again.SmokeTests.Authentication == nil || again.SmokeTests.Authentication.Kind == nil ||
*again.SmokeTests.Authentication.Kind != "openrouter_key" || again.SmokeTests.Authentication.Endpoint == nil ||
*again.SmokeTests.Authentication.Endpoint != "https://probe.example/key" {
t.Fatalf("lookup exposed nested diagnostic metadata: %+v", again.SmokeTests)
}
policies := registry.CapacityPolicies() policies := registry.CapacityPolicies()
if !reflect.DeepEqual(policies, map[string]domain.BackendCapacityPolicy{ if !reflect.DeepEqual(policies, map[string]domain.BackendCapacityPolicy{
" local.v1 ": {ConcurrencyLimit: 2, QueueCapacity: 3}, " local.v1 ": {ConcurrencyLimit: 2, QueueCapacity: 3},

View File

@@ -3,8 +3,8 @@ package capacity
import ( import (
"context" "context"
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "go.maximumdirect.net/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm" "go.maximumdirect.net/promptkit/internal/llm"
) )
type client struct { type client struct {

View File

@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain" "go.maximumdirect.net/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm" "go.maximumdirect.net/promptkit/internal/llm"
) )
type generateResult struct { type generateResult struct {

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

@@ -1,52 +0,0 @@
// Package catalog selects the maintained configuration assets supplied with
// PromptKit. ConfigKit owns their composition and effective validation.
package catalog
import (
"context"
"fmt"
"io/fs"
"gitea.maximumdirect.net/eric/ConfigKit"
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3"
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3"
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
)
// Underlays selects external catalogs followed by PromptKit-owned profiles.
// Neither group is interpreted before the final effective configuration loads.
func Underlays() ([]configkit.Underlay, error) {
openrouterInputs, err := assetInputs("OpenRouter catalog", openrouter.FS(), openrouter.Root)
if err != nil {
return nil, err
}
rakestrawhomeInputs, err := assetInputs("Rakestrawhome catalog", rakestrawhome.FS(), rakestrawhome.Root)
if err != nil {
return nil, err
}
catalogInputs := append(openrouterInputs, rakestrawhomeInputs...)
owned := configkit.Input{Source: configkit.Mapping("PromptKit-owned profiles", map[string]any{
"profiles": map[string]any{
"promptkit-openrouter-standard": map[string]any{"extends": "gemini-flash-latest"},
"promptkit-openrouter-auto": map[string]any{"backend": backend.OpenRouterID, "model": "openrouter/auto"},
},
})}
return []configkit.Underlay{{Inputs: catalogInputs}, {Inputs: []configkit.Input{owned}}}, nil
}
func assetInputs(label string, filesystem fs.FS, root string) ([]configkit.Input, error) {
files, err := filecatalog.FindFSYAMLFiles(context.Background(), filesystem, root)
if err != nil {
return nil, fmt.Errorf("discover %s configuration: %w", label, err)
}
namespace := configkit.NewFilesystem(label, filesystem)
inputs := make([]configkit.Input, 0, len(files))
for _, name := range files {
inputs = append(inputs, configkit.Input{
Source: namespace.File(name),
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: root},
})
}
return inputs, nil
}

View File

@@ -1,68 +0,0 @@
package catalog
import (
"context"
"errors"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
)
func TestPublishedCatalogsResolveThroughEffectiveConfiguration(t *testing.T) {
underlays, err := Underlays()
if err != nil {
t.Fatal(err)
}
state, err := configuration.Load(configkit.Input{Source: configkit.NoSource()}, underlays)
if err != nil {
t.Fatal(err)
}
openrouter, err := state.GetBackend("openrouter")
if err != nil || openrouter.Endpoint != "https://openrouter.ai/api/v1" || openrouter.ConcurrencyLimit != 16 || openrouter.QueueCapacity != 1024 {
t.Fatalf("OpenRouter backend: %+v, %v", openrouter, err)
}
rakestrawhome, err := state.GetBackend("rakestrawhome")
if err != nil || rakestrawhome.Endpoint != "https://inference.ai.rakestrawhome.com/v1" || rakestrawhome.ConcurrencyLimit != 4 {
t.Fatalf("Rakestrawhome backend: %+v, %v", rakestrawhome, err)
}
for id, want := range map[string]string{
"promptkit-openrouter-standard": "~google/gemini-flash-latest",
"promptkit-openrouter-auto": "openrouter/auto",
"rakestrawhome-gemma-4-31b": "google/gemma-4-31b-it",
} {
profile, err := state.GetProfile(context.Background(), id)
if err != nil || profile.ResolvedTarget.Model != want {
t.Fatalf("profile %s: %+v, %v", id, profile, err)
}
}
}
func TestConsumerOverlayReplacesCatalogDefaultsBeforeValidation(t *testing.T) {
underlays, err := Underlays()
if err != nil {
t.Fatal(err)
}
underlays = append(underlays, configkit.Underlay{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
"backends": map[string]any{"openrouter": map[string]any{"endpoint": "invalid lower value"}},
})}}})
main := configkit.Input{Source: configkit.Mapping("application", map[string]any{
"backends": map[string]any{"openrouter": map[string]any{"endpoint": "https://alternate.example/v1"}},
"profiles": map[string]any{"gemini-flash-latest": map[string]any{"model": "consumer-model"}},
})}
state, err := configuration.Load(main, underlays)
if err != nil {
t.Fatalf("replaced lower-layer value failed final validation: %v", err)
}
profile, err := state.GetProfile(context.Background(), "promptkit-openrouter-standard")
if err != nil || profile.ResolvedTarget.Model != "consumer-model" || profile.ResolvedTarget.Endpoint != "https://alternate.example/v1" {
t.Fatalf("maintained profile did not observe final configuration: %+v, %v", profile, err)
}
_, err = configuration.Load(configkit.Input{Source: configkit.Mapping("invalid final", map[string]any{
"backends": map[string]any{"openrouter": map[string]any{"endpoint": "invalid final value"}},
})}, underlays[:2])
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("invalid final backend did not produce a ConfigKit load error: %v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More