Compare commits
166 Commits
62a4a20529
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9178f3eb91 | |||
| a70fd7bb65 | |||
| 7091b51c87 | |||
| 8627c751a4 | |||
| 6fff3d822c | |||
| a1f12f3185 | |||
| 165cef7aec | |||
| 0221cd4610 | |||
| dfd8da843e | |||
| 29f104fbc0 | |||
| 8669294cf6 | |||
| c9b623b374 | |||
| 15f79ce7b1 | |||
| aa54b8d1c4 | |||
| 60c58260ec | |||
| 8dfe35a1f0 | |||
| 13b60a72ac | |||
| ee40a1bdd2 | |||
| 473fc1f6d7 | |||
| a10a9053b7 | |||
| ec3245c97e | |||
| 8cbb7c0daf | |||
| d40a010538 | |||
| 0cd833513c | |||
| 5e58b6944b | |||
| 0a85ac8cd1 | |||
| 89af2a1281 | |||
| 683689a4f8 | |||
| ddda2f8767 | |||
| 190ec69345 | |||
| b5866dd749 | |||
| c7490b783b | |||
| c7d1b0dca7 | |||
| 9e5ed08635 | |||
| 4bddbbe8f4 | |||
| 1516631a30 | |||
| cf997c2034 | |||
| a0ec67c256 | |||
| 75891b6561 | |||
| 5dc6370a97 | |||
| 4cda3674c4 | |||
| 5ea6f06a6d | |||
| 116bd0e39b | |||
| 208ff42915 | |||
| 972a103a97 | |||
| 2f735aedf9 | |||
| f4c721d934 | |||
| b7a951685b | |||
| abb2461244 | |||
| b268c55783 | |||
| f0182fb2c9 | |||
| 8d05b594e2 | |||
| edaaecc982 | |||
| 2e81c9e61b | |||
| 2b723760d1 | |||
| c5aed7a451 | |||
| d91afc61a4 | |||
| 6eeb0ee9c0 | |||
| b9c232ee63 | |||
| 7291d23587 | |||
| da1d196dc5 | |||
| b0de49a87a | |||
| 27ff580dae | |||
| ece764f19e | |||
| f91a02548e | |||
| 8fbd058b90 | |||
| c8f9a4def9 | |||
| d8b6c0b471 | |||
| 52cd94fcd1 | |||
| f1f72dfe8f | |||
| d4676bc870 | |||
| 64769883ae | |||
| 635674c066 | |||
| 50bc35516a | |||
| 18309f50db | |||
| 60784e7875 | |||
| f3148bfe52 | |||
| f315620bf7 | |||
| 97357d62b9 | |||
| 0c3651fceb | |||
| 5a5d47d36e | |||
| 143c6e9b3a | |||
| 48e4784fcd | |||
| 78af5069c1 | |||
| 18963266d7 | |||
| d039a019cb | |||
| d1c0281053 | |||
| 870e4ca4e6 | |||
| fd82524977 | |||
| 3f76c75919 | |||
| fe764f72d8 | |||
| 26ab566f38 | |||
| 18ca21fa45 | |||
| e32fdc22f4 | |||
| b02b802427 | |||
| 301192b974 | |||
| f2aeb5eb30 | |||
| a27ee8f7da | |||
| 4d1895c223 | |||
| d7fe70520a | |||
| 7325a34c07 | |||
| 03c44923c8 | |||
| dc0adf5751 | |||
| eae21797f7 | |||
| ea6c7d5f0f | |||
| 389ef26db3 | |||
| 8a5c0de162 | |||
| 80aecb646d | |||
| b7b4000aab | |||
| 11ecb923ba | |||
| f46d5a4591 | |||
| 743e78d514 | |||
| 3743221aa5 | |||
| a8863d49d0 | |||
| c5f733af45 | |||
| c09d2c6e20 | |||
| 6c5dec761c | |||
| 74b91fae99 | |||
| b7ff37118c | |||
| ef012ea1e5 | |||
| 04d5d695e0 | |||
| 78bff91395 | |||
| c63ca5352f | |||
| 3f157150e4 | |||
| f1e1a45a05 | |||
| 996295d5c1 | |||
| 58a5bbe790 | |||
| dd89be3b03 | |||
| ed7c27324d | |||
| 05fe13d80b | |||
| 0eb009ad5a | |||
| 3fde1f6cbb | |||
| 481e2d494c | |||
| eb830bb613 | |||
| dd6d4591ae | |||
| de6443f3ae | |||
| 5a63b649f9 | |||
| c7e22c0dbe | |||
| e69a847cf2 | |||
| 21815ad7c0 | |||
| a051edd82d | |||
| ee0ad99e17 | |||
| bc5a2da0f2 | |||
| 089e824a92 | |||
| 93e8874813 | |||
| cc0b6183bf | |||
| bf472dad8a | |||
| a6e0ffea8f | |||
| e459c7d54b | |||
| 71f6683fda | |||
| 7827ca043b | |||
| b595dca8c7 | |||
| d5f34642d7 | |||
| b1866946d1 | |||
| 05082c6fdf | |||
| 51b072965c | |||
| d5940856be | |||
| 0d163f8165 | |||
| 1889915a81 | |||
| 3ca01ae0b0 | |||
| 6e283d16c3 | |||
| 41b2021d8c | |||
| 3add7024db | |||
| a148a2b1f4 | |||
| 7310ef34a4 | |||
| 4b8169da28 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,6 +22,9 @@
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Local ReleaseKit executable
|
||||
.releasekit/
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
@@ -67,4 +70,3 @@ Icon
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
28
README.md
28
README.md
@@ -21,15 +21,21 @@ Run the maintained offline preparation example from the repository root:
|
||||
go run ./examples/go-library/prepare
|
||||
```
|
||||
|
||||
It loads a repository-local prompt, supplies a programmatic profile and inline
|
||||
input, inspects the captured configuration, and prints deterministic
|
||||
preparation metadata without contacting a provider or requiring credentials.
|
||||
It composes two explicitly selected synthetic collections, imports
|
||||
source-relative prompt content, supplies an inline input, and prepares through
|
||||
a scoped view. The example also demonstrates collection inventories and
|
||||
namespace-filtered discovery, without contacting a provider or requiring
|
||||
credentials.
|
||||
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
||||
ordered file/embedded sources, programmatic mappings, and how to replace an
|
||||
engine when source files change. Read the
|
||||
[example source](examples/go-library/prepare/main.go), the
|
||||
engine when source files change. Applications adapting the prior keyed
|
||||
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
|
||||
[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).
|
||||
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||
@@ -37,6 +43,18 @@ boundary and constraints that framework work must preserve.
|
||||
|
||||
## Release Guidance
|
||||
|
||||
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
||||
diagnostics, configuration collections, scoped engine views, and discovery.
|
||||
Upgrading consumers should review the breaking changes to bundled configuration
|
||||
and prompt version selection and follow the linked migration guide.
|
||||
|
||||
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
|
||||
[v0.12.0 release notes](docs/releases/v0.12.0.md) for the intentional ConfigKit
|
||||
configuration and public construction API migration.
|
||||
|
||||
The [v0.11.2 release notes](docs/releases/v0.11.2.md) cover policy alignment
|
||||
and copied-policy attribution updates.
|
||||
|
||||
|
||||
238
assembled_workflow_benchmark_test.go
Normal file
238
assembled_workflow_benchmark_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
var (
|
||||
assembledPreparedSink *promptkit.PreparedRun
|
||||
assembledResultSink *promptkit.RunResult
|
||||
assembledDetailsSink promptkit.PreparedRun
|
||||
assembledHandleSink *promptkit.PreparedExecution
|
||||
)
|
||||
|
||||
type assembledWorkflowClient struct{ content string }
|
||||
|
||||
func (client assembledWorkflowClient) Generate(
|
||||
context.Context,
|
||||
promptkit.GenerateRequest,
|
||||
) (*promptkit.GenerateResponse, error) {
|
||||
return &promptkit.GenerateResponse{Content: client.content}, nil
|
||||
}
|
||||
|
||||
type assembledWorkflowFixture struct {
|
||||
engine *promptkit.Engine
|
||||
request promptkit.RunRequest
|
||||
expectedMessage string
|
||||
response string
|
||||
}
|
||||
|
||||
func assembledWorkflowSchema(b *testing.B) []byte {
|
||||
b.Helper()
|
||||
properties := make(map[string]any, 256)
|
||||
for index := range 256 {
|
||||
properties[fmt.Sprintf("property-%03d", index)] = map[string]any{"type": "string"}
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func assembledWorkflowExtras(entries int) map[string]any {
|
||||
providers := make(map[string]any, entries)
|
||||
for index := range entries {
|
||||
providers[fmt.Sprintf("provider-%03d", index)] = map[string]any{
|
||||
"enabled": true,
|
||||
"labels": []any{fmt.Sprintf("tier-%d", index%4), fmt.Sprintf("region-%d", index%3)},
|
||||
"limits": map[string]any{"burst": index + 1, "period": "minute"},
|
||||
"routes": []any{map[string]any{"weight": index + 1, "fallback": true}},
|
||||
}
|
||||
}
|
||||
return map[string]any{"provider_options": providers}
|
||||
}
|
||||
|
||||
func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFixture {
|
||||
b.Helper()
|
||||
message := "Hello {{.name}}"
|
||||
response := "ok"
|
||||
output := map[string]any{"format": "text", "validation_mode": "none"}
|
||||
profile := map[string]any{"endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
||||
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
||||
var expectedMessage string
|
||||
|
||||
switch name {
|
||||
case "small":
|
||||
expectedMessage = "Hello Ada"
|
||||
case "large_message":
|
||||
prefix := strings.Repeat("x", 256*1024)
|
||||
message = prefix + "{{.name}}"
|
||||
expectedMessage = prefix + "Ada"
|
||||
case "mutable_payload":
|
||||
message = `{"subject":"{{.name}}"}`
|
||||
expectedMessage = `{"subject":"Ada"}`
|
||||
response = "{}"
|
||||
profile["extra_params"] = assembledWorkflowExtras(64)
|
||||
output = map[string]any{
|
||||
"format": "json", "validation_mode": "json_schema", "schema_path": "workflow-schema.json",
|
||||
}
|
||||
options[0] = promptkit.WithLLMClient(assembledWorkflowClient{content: response})
|
||||
options = append(options, promptkit.WithSchemaFS(fstest.MapFS{
|
||||
"workflow-schema.json": &fstest.MapFile{Data: assembledWorkflowSchema(b)},
|
||||
}, "."))
|
||||
default:
|
||||
b.Fatalf("unknown assembled workflow fixture %q", name)
|
||||
}
|
||||
|
||||
config := map[string]any{
|
||||
"profiles": map[string]any{"profile": profile},
|
||||
"prompts": map[string]any{"workflow": map[string]any{
|
||||
"version": "v1",
|
||||
"default_profile": "profile",
|
||||
"messages": []any{map[string]any{"role": "user", "content": message}},
|
||||
"output": output,
|
||||
}},
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||
Source: configkit.Mapping("assembled workflow benchmark", config),
|
||||
}}}, options...)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
request := promptkit.RunRequest{
|
||||
PromptID: "workflow", Vars: map[string]string{"name": "Ada"},
|
||||
}
|
||||
return assembledWorkflowFixture{
|
||||
engine: engine, request: request, expectedMessage: expectedMessage, response: response,
|
||||
}
|
||||
}
|
||||
|
||||
func verifyAssembledWorkflowFixture(b *testing.B, fixture assembledWorkflowFixture) {
|
||||
b.Helper()
|
||||
ctx := context.Background()
|
||||
prepared, err := fixture.engine.Prepare(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if prepared.PromptID != "workflow" || prepared.PromptVersion == nil || *prepared.PromptVersion != "v1" || len(prepared.Messages) != 1 ||
|
||||
prepared.Messages[0].Content != fixture.expectedMessage {
|
||||
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
||||
}
|
||||
handle, err := fixture.engine.PrepareExecution(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
details := handle.Details()
|
||||
if details.PromptID != "workflow" || len(details.Messages) != 1 || details.Messages[0].Content != fixture.expectedMessage {
|
||||
handle.Discard()
|
||||
b.Fatalf("prepared execution details are invalid: %+v", details)
|
||||
}
|
||||
handle.Discard()
|
||||
result, err := fixture.engine.Run(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if result.RawOutput != fixture.response {
|
||||
b.Fatalf("run output=%q, want %q", result.RawOutput, fixture.response)
|
||||
}
|
||||
if fixture.response == "{}" && result.Validation.Status != promptkit.ValidationPassed {
|
||||
b.Fatalf("structured response validation=%+v, want passed", result.Validation)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEnginePreparation(b *testing.B) {
|
||||
for _, name := range []string{"small", "large_message", "mutable_payload"} {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
fixture := newAssembledWorkflowFixture(b, name)
|
||||
verifyAssembledWorkflowFixture(b, fixture)
|
||||
ctx := context.Background()
|
||||
b.Run("prepare", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
prepared, err := fixture.engine.Prepare(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
assembledPreparedSink = prepared
|
||||
}
|
||||
b.StopTimer()
|
||||
assembledPreparedSink = nil
|
||||
})
|
||||
b.Run("prepared_execution_creation_and_discard", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
handle, err := fixture.engine.PrepareExecution(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
handle.Discard()
|
||||
assembledHandleSink = handle
|
||||
}
|
||||
b.StopTimer()
|
||||
assembledHandleSink = nil
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEngineRun(b *testing.B) {
|
||||
for _, name := range []string{"small", "large_message", "mutable_payload"} {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
fixture := newAssembledWorkflowFixture(b, name)
|
||||
verifyAssembledWorkflowFixture(b, fixture)
|
||||
ctx := context.Background()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
result, err := fixture.engine.Run(ctx, fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
assembledResultSink = result
|
||||
}
|
||||
b.StopTimer()
|
||||
assembledResultSink = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPreparedExecutionDetails(b *testing.B) {
|
||||
for _, name := range []string{"small", "large_message", "mutable_payload"} {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
fixture := newAssembledWorkflowFixture(b, name)
|
||||
verifyAssembledWorkflowFixture(b, fixture)
|
||||
handle, err := fixture.engine.PrepareExecution(context.Background(), fixture.request)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer handle.Discard()
|
||||
initial := handle.Details()
|
||||
if len(initial.Messages) != 1 || initial.Messages[0].Content != fixture.expectedMessage {
|
||||
b.Fatalf("prepared details fixture is invalid: %+v", initial)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
assembledDetailsSink = handle.Details()
|
||||
}
|
||||
b.StopTimer()
|
||||
assembledDetailsSink = promptkit.PreparedRun{}
|
||||
})
|
||||
}
|
||||
}
|
||||
70
backends.go
70
backends.go
@@ -5,10 +5,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
)
|
||||
|
||||
// BackendOpenRouter identifies the maintained OpenRouter backend.
|
||||
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
||||
const BackendOpenRouter = backend.OpenRouterID
|
||||
|
||||
// BackendRakestrawHome identifies the maintained Rakestrawhome backend.
|
||||
// BackendRakestrawHome is the conventional ID for a Rakestrawhome backend.
|
||||
const BackendRakestrawHome = backend.RakestrawHomeID
|
||||
|
||||
// BackendLocal is a conventional ID for a caller-configured local backend.
|
||||
@@ -17,8 +17,9 @@ const BackendLocal = "local"
|
||||
// BackendConfig is a presence-aware convenience value for a keyed backend
|
||||
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
||||
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
||||
// empty map supplies an empty mapping. Use raw ConfigKit Mapping inputs for
|
||||
// explicit null. BackendConfig has no stable JSON representation.
|
||||
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
||||
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
||||
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
||||
type BackendConfig struct {
|
||||
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
||||
Endpoint *string
|
||||
@@ -31,6 +32,43 @@ type BackendConfig struct {
|
||||
QueueCapacity *int
|
||||
// ExtraParams supplies JSON-compatible provider request defaults.
|
||||
ExtraParams map[string]any
|
||||
// SmokeTests supplies optional diagnostic defaults and HTTP probe settings.
|
||||
SmokeTests *BackendSmokeTestConfig
|
||||
}
|
||||
|
||||
// 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 from keyed backend values.
|
||||
@@ -57,6 +95,30 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
||||
if config.ExtraParams != nil {
|
||||
fields["extra_params"] = providerMapForMapping(config.ExtraParams)
|
||||
}
|
||||
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[id] = fields
|
||||
}
|
||||
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
||||
|
||||
@@ -267,7 +267,7 @@ func newBackendCapacityEngine(
|
||||
Source: configkit.Mapping("capacity fixture", map[string]any{
|
||||
"backends": map[string]any{"limited": backend},
|
||||
"profiles": map[string]any{"profile": map[string]any{"backend": "limited", "model": "model"}},
|
||||
"prompts": map[string]any{promptID: map[string]any{"1": definition}},
|
||||
"prompts": map[string]any{promptID: promptDefinitionWithVersion("1", definition)},
|
||||
}),
|
||||
}}}, options...)
|
||||
if err != nil {
|
||||
|
||||
243
configuration_diff_contract_test.go
Normal file
243
configuration_diff_contract_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
||||
left := comparisonInspection(t, "earlier-model")
|
||||
right := comparisonInspection(t, "later-model")
|
||||
|
||||
differences, err := left.Diff(right)
|
||||
if err != nil {
|
||||
t.Fatalf("compare captured configurations: %v", err)
|
||||
}
|
||||
if len(differences) != 1 {
|
||||
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
||||
}
|
||||
difference := differences[0]
|
||||
if got, want := difference.Path.String(), "/profiles/offline/model"; got != want {
|
||||
t.Fatalf("difference path = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
||||
t.Fatalf("difference snapshots = (%q, %q)", difference.Left.YAML, difference.Right.YAML)
|
||||
}
|
||||
|
||||
reversed, err := right.Diff(left)
|
||||
if err != nil {
|
||||
t.Fatalf("compare reversed configurations: %v", err)
|
||||
}
|
||||
if len(reversed) != 1 || reversed[0].Path.String() != difference.Path.String() ||
|
||||
!strings.Contains(reversed[0].Left.YAML, "later-model") ||
|
||||
!strings.Contains(reversed[0].Right.YAML, "earlier-model") {
|
||||
t.Fatalf("reversed difference = %+v", reversed)
|
||||
}
|
||||
|
||||
equal := comparisonInspection(t, "earlier-model")
|
||||
if equal, err := left.Diff(equal); err != nil || len(equal) != 0 {
|
||||
t.Fatalf("compare equal configurations = (%+v, %v), want no differences", equal, err)
|
||||
}
|
||||
if equal, err := left.Diff(left); err != nil || len(equal) != 0 {
|
||||
t.Fatalf("compare view with itself = (%+v, %v), want no differences", equal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionDiffRejectsZeroViews(t *testing.T) {
|
||||
valid := comparisonInspection(t, "current-model")
|
||||
var zero promptkit.ConfigurationInspection
|
||||
|
||||
for _, comparison := range []struct {
|
||||
name string
|
||||
left, right promptkit.ConfigurationInspection
|
||||
}{
|
||||
{name: "zero left", left: zero, right: valid},
|
||||
{name: "zero right", left: valid, right: zero},
|
||||
{name: "both zero", left: zero, right: zero},
|
||||
} {
|
||||
t.Run(comparison.name, func(t *testing.T) {
|
||||
differences, err := comparison.left.Diff(comparison.right)
|
||||
if !errors.Is(err, promptkit.ErrInvalidConfig) || len(differences) != 0 {
|
||||
t.Fatalf("Diff() = (%+v, %v), want ErrInvalidConfig and no records", differences, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionDiffIgnoresSourceHistory(t *testing.T) {
|
||||
left := comparisonInspectionFrom(t, "earlier source", "same-model", nil)
|
||||
right := comparisonInspectionFrom(t, "later source", "same-model", nil)
|
||||
if !hasSourceLabel(left, "earlier source") || !hasSourceLabel(right, "later source") {
|
||||
t.Fatal("comparison fixtures did not retain their distinct source labels")
|
||||
}
|
||||
|
||||
differences, err := left.Diff(right)
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("compare equal effective values with different sources = (%+v, %v), want no differences", differences, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||
"sequence": []any{"earlier", "unchanged"},
|
||||
})
|
||||
right := comparisonInspectionFrom(t, "after", "same-model", map[string]any{
|
||||
"nullable": nil,
|
||||
"sequence": []any{"later", "unchanged"},
|
||||
})
|
||||
|
||||
differences, err := left.Diff(right)
|
||||
if err != nil {
|
||||
t.Fatalf("compare presence and sequence values: %v", err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
||||
}
|
||||
byPath := differencesByPath(differences)
|
||||
nullable, ok := byPath["/backends/local/extra_params/nullable"]
|
||||
if !ok || nullable.Left.Present || !nullable.Right.Present || strings.TrimSpace(nullable.Right.YAML) != "null" {
|
||||
t.Fatalf("absent-to-null difference = (%+v, %t)", nullable, ok)
|
||||
}
|
||||
sequence, ok := byPath["/backends/local/extra_params/sequence"]
|
||||
if !ok || !sequence.Left.Present || !sequence.Right.Present ||
|
||||
!strings.Contains(sequence.Left.YAML, "earlier") || !strings.Contains(sequence.Right.YAML, "later") {
|
||||
t.Fatalf("atomic sequence difference = (%+v, %t)", sequence, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t *testing.T) {
|
||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||
"token": "left-secret-sentinel",
|
||||
"visible": "before-value",
|
||||
})
|
||||
right := comparisonInspectionFrom(t, "after", "same-model", map[string]any{
|
||||
"token": "right-secret-sentinel",
|
||||
"visible": "after-value",
|
||||
})
|
||||
|
||||
differences, err := left.Diff(right)
|
||||
if err != nil {
|
||||
t.Fatalf("compare sensitive values: %v", err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
||||
}
|
||||
byPath := differencesByPath(differences)
|
||||
secret, ok := byPath["/backends/local/extra_params/token"]
|
||||
if !ok || !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||
t.Fatalf("sensitive difference = (%+v, %t)", secret, ok)
|
||||
}
|
||||
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
||||
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
||||
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
||||
}
|
||||
visible, ok := byPath["/backends/local/extra_params/visible"]
|
||||
if !ok || visible.Left.Redacted || visible.Right.Redacted || visible.HiddenChanged ||
|
||||
!strings.Contains(visible.Left.YAML, "before-value") || !strings.Contains(visible.Right.YAML, "after-value") {
|
||||
t.Fatalf("public sibling difference = (%+v, %t)", visible, ok)
|
||||
}
|
||||
|
||||
differences[0].Path = configkit.Keys("caller", "mutation")
|
||||
differences[0].Left.YAML = "caller mutation"
|
||||
differences[0].Right.YAML = "caller mutation"
|
||||
again, err := left.Diff(right)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
||||
}
|
||||
if len(again) != 2 {
|
||||
t.Fatalf("repeat difference count = %d, want 2: %+v", len(again), again)
|
||||
}
|
||||
againByPath := differencesByPath(again)
|
||||
if _, exists := againByPath["/caller/mutation"]; exists {
|
||||
t.Fatal("mutating returned path reached retained inspection")
|
||||
}
|
||||
if _, exists := againByPath["/backends/local/extra_params/token"]; !exists {
|
||||
t.Fatalf("original sensitive path was lost after mutation: %+v", again)
|
||||
}
|
||||
if _, exists := againByPath["/backends/local/extra_params/visible"]; !exists {
|
||||
t.Fatalf("original public path was lost after mutation: %+v", again)
|
||||
}
|
||||
for _, difference := range again {
|
||||
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
||||
t.Fatalf("mutating returned snapshots reached retained inspection: %+v", difference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionDiffMasksSensitiveShapeReplacement(t *testing.T) {
|
||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||
"details": map[string]any{
|
||||
"token": "nested-secret-sentinel",
|
||||
"label": "public detail",
|
||||
},
|
||||
})
|
||||
right := comparisonInspectionFrom(t, "after", "same-model", map[string]any{
|
||||
"details": "replacement",
|
||||
})
|
||||
|
||||
differences, err := left.Diff(right)
|
||||
if err != nil {
|
||||
t.Fatalf("compare sensitive shape replacement: %v", err)
|
||||
}
|
||||
if len(differences) != 1 || differences[0].Path.String() != "/backends/local/extra_params/details" {
|
||||
t.Fatalf("shape replacement differences = %+v", differences)
|
||||
}
|
||||
difference := differences[0]
|
||||
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged {
|
||||
t.Fatalf("shape replacement masking = %+v", difference)
|
||||
}
|
||||
if strings.Contains(difference.Left.YAML, "nested-secret-sentinel") || strings.Contains(difference.Right.YAML, "nested-secret-sentinel") {
|
||||
t.Fatalf("shape replacement disclosed a sensitive value: %+v", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationInspection {
|
||||
t.Helper()
|
||||
return comparisonInspectionFrom(t, "comparison fixture", model, nil)
|
||||
}
|
||||
|
||||
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
||||
t.Helper()
|
||||
backend := map[string]any{"endpoint": "https://example.invalid/v1"}
|
||||
if extraParams != nil {
|
||||
backend["extra_params"] = extraParams
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
||||
"backends": map[string]any{"local": backend},
|
||||
"profiles": map[string]any{"offline": map[string]any{
|
||||
"backend": "local",
|
||||
"model": model,
|
||||
}},
|
||||
})}}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct comparison engine: %v", err)
|
||||
}
|
||||
inspection, err := engine.InspectConfiguration()
|
||||
if err != nil {
|
||||
t.Fatalf("inspect comparison engine: %v", err)
|
||||
}
|
||||
return inspection
|
||||
}
|
||||
|
||||
func differencesByPath(differences []configkit.Difference) map[string]configkit.Difference {
|
||||
byPath := make(map[string]configkit.Difference, len(differences))
|
||||
for _, difference := range differences {
|
||||
byPath[difference.Path.String()] = difference
|
||||
}
|
||||
return byPath
|
||||
}
|
||||
|
||||
func hasSourceLabel(view promptkit.ConfigurationInspection, label string) bool {
|
||||
for _, source := range view.Sources() {
|
||||
if source.Label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -27,6 +27,23 @@ func (e *Engine) InspectConfiguration() (ConfigurationInspection, error) {
|
||||
return ConfigurationInspection{source: e.internal.InspectConfiguration()}, nil
|
||||
}
|
||||
|
||||
// Diff compares this view's captured effective configuration with other. This
|
||||
// view is the earlier (left) value and other is the later (right) value. The
|
||||
// comparison delegates to ConfigKit's frozen effective-value semantics, so
|
||||
// source or resolution history alone does not create differences. It does not
|
||||
// reread external sources or compare referenced file contents, resolved
|
||||
// credential contents, later execution preparation, or request-specific
|
||||
// overrides. Either zero view returns ErrInvalidConfig and no records; other
|
||||
// ConfigKit errors are returned unchanged. An empty diff is not evidence of
|
||||
// equivalent engine behavior or execution. Records can contain ordinary
|
||||
// application data, so callers choose where and to whom to disclose them.
|
||||
func (v ConfigurationInspection) Diff(other ConfigurationInspection) ([]configkit.Difference, error) {
|
||||
if v.source == nil || other.source == nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return configuration.Diff(v.source, other.source)
|
||||
}
|
||||
|
||||
// Metadata returns detached effective presence and history records.
|
||||
func (v ConfigurationInspection) Metadata() configkit.Metadata {
|
||||
if v.source == nil {
|
||||
|
||||
@@ -23,13 +23,19 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
"endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
|
||||
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
||||
}},
|
||||
"profiles": map[string]any{
|
||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
||||
"literal/id": map[string]any{"extends": "base.v1"},
|
||||
},
|
||||
"collections": map[string]any{"literal": map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://example.invalid/v1",
|
||||
"extra_params": map[string]any{"token": "namespaced-private-sentinel"},
|
||||
}},
|
||||
"profiles": map[string]any{
|
||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
||||
"id": map[string]any{"extends": "base.v1"},
|
||||
},
|
||||
}},
|
||||
})}}},
|
||||
{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{"profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}}},
|
||||
})}}},
|
||||
},
|
||||
}})
|
||||
@@ -40,17 +46,17 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("inspect configuration: %v", err)
|
||||
}
|
||||
modelPath := configkit.Keys("profiles", "base.v1", "model")
|
||||
modelPath := configkit.Keys("collections", "literal", "profiles", "base.v1", "model")
|
||||
model, found := view.Explain(modelPath)
|
||||
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 {
|
||||
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
||||
}
|
||||
inheritedPath := configkit.Keys("profiles", "literal/id", "model")
|
||||
inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model")
|
||||
inherited, found := view.Explain(inheritedPath)
|
||||
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
||||
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
||||
}
|
||||
if inheritedPath.String() != "/profiles/literal~1id/model" {
|
||||
if inheritedPath.String() != "/collections/literal/profiles/id/model" {
|
||||
t.Fatalf("literal-key path = %q", inheritedPath)
|
||||
}
|
||||
secretPath := configkit.Keys("backends", "local", "extra_params", "token")
|
||||
@@ -58,6 +64,11 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
||||
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
||||
}
|
||||
namespacedSecretPath := configkit.Keys("collections", "literal", "backends", "local", "extra_params", "token")
|
||||
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)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := view.WriteEffective(&effective); err != nil {
|
||||
t.Fatalf("write effective configuration: %v", err)
|
||||
@@ -66,7 +77,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
t.Fatalf("unexpected effective report: %s", effective.String())
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -93,6 +104,28 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||
Source: configkit.Mapping("main", map[string]any{"version": "1"}),
|
||||
}}})
|
||||
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, "1") {
|
||||
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 }
|
||||
|
||||
func (writer failingInspectionWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
@@ -117,7 +150,13 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
||||
t.Fatalf("zero explanation writer = %v", err)
|
||||
}
|
||||
|
||||
valid, err := promptkit.NewEngine(promptkit.Config{})
|
||||
endpoint, model := "https://configured.example/v1", "configured-model"
|
||||
profile := configkit.Input{Source: promptkit.ProfileSource("inspection test profile", map[string]promptkit.ProfileConfig{
|
||||
"inspection-profile": {Endpoint: &endpoint, Model: &model},
|
||||
})}
|
||||
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
@@ -130,7 +169,7 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
||||
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("effective writer error = %v", err)
|
||||
}
|
||||
if err := view.WriteExplanation(writer, configkit.Keys("profiles", "mistral-small-3", "model")); !errors.Is(err, writeErr) {
|
||||
if err := view.WriteExplanation(writer, configkit.Keys("profiles", "inspection-profile", "model")); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("explanation writer error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -144,9 +183,17 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("select inputs: %v", err)
|
||||
}
|
||||
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||
}}
|
||||
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
||||
if err != nil {
|
||||
t.Fatalf("select comparison inputs: %v", err)
|
||||
}
|
||||
var modelCalls atomic.Int64
|
||||
client := countingLLMClient{calls: &modelCalls}
|
||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
||||
"offline": {Endpoint: &endpoint, Model: &model},
|
||||
@@ -155,36 +202,75 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("comparison profile", map[string]promptkit.ProfileConfig{
|
||||
"offline": {Endpoint: &endpoint, Model: &otherModel},
|
||||
})}}}},
|
||||
}}, promptkit.WithLLMClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("construct comparison engine: %v", err)
|
||||
}
|
||||
source.closed.Store(true)
|
||||
otherSource.closed.Store(true)
|
||||
view, err := engine.InspectConfiguration()
|
||||
if err != nil {
|
||||
t.Fatalf("inspect configuration: %v", err)
|
||||
}
|
||||
if _, found := view.Explain(configkit.Keys("prompts", "captured", "1", "messages").Index(0).Key("content_file")); !found {
|
||||
otherView, err := otherEngine.InspectConfiguration()
|
||||
if err != nil {
|
||||
t.Fatalf("inspect comparison configuration: %v", err)
|
||||
}
|
||||
var expected configkit.Difference
|
||||
for index := 0; index < 3; index++ {
|
||||
differences, err := view.Diff(otherView)
|
||||
if err != nil || len(differences) != 1 {
|
||||
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
||||
}
|
||||
if differences[0].Path.String() != "/profiles/offline/model" ||
|
||||
!strings.Contains(differences[0].Left.YAML, model) ||
|
||||
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
||||
t.Fatalf("captured difference = %+v", differences[0])
|
||||
}
|
||||
if index == 0 {
|
||||
expected = differences[0]
|
||||
} else if differences[0].Path.String() != expected.Path.String() ||
|
||||
differences[0].Left.YAML != expected.Left.YAML || differences[0].Right.YAML != expected.Right.YAML {
|
||||
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
||||
}
|
||||
}
|
||||
if _, found := view.Explain(configkit.Keys("prompts", "captured", "messages").Index(0).Key("content_file")); !found {
|
||||
t.Fatal("captured prompt content path was not inspectable")
|
||||
}
|
||||
var report bytes.Buffer
|
||||
if err := view.WriteEffective(&report); err != nil || !strings.Contains(report.String(), "captured") {
|
||||
t.Fatalf("write captured report = (%q, %v)", report.String(), err)
|
||||
}
|
||||
if source.reads.Load() != 0 || modelCalls.Load() != 0 {
|
||||
t.Fatalf("inspection touched source or provider: reads=%d requests=%d", source.reads.Load(), modelCalls.Load())
|
||||
if source.reads.Load() != 0 || otherSource.reads.Load() != 0 || modelCalls.Load() != 0 {
|
||||
t.Fatalf("inspection or comparison touched source or provider: reads=(%d,%d) requests=%d", source.reads.Load(), otherSource.reads.Load(), modelCalls.Load())
|
||||
}
|
||||
|
||||
const calls = 16
|
||||
const calls = 24
|
||||
var wait sync.WaitGroup
|
||||
errs := make(chan error, calls)
|
||||
for index := 0; index < calls; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
if index%2 == 0 {
|
||||
switch index % 3 {
|
||||
case 0:
|
||||
_, err := engine.InspectConfiguration()
|
||||
errs <- err
|
||||
return
|
||||
case 1:
|
||||
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "captured", SessionID: "direct"})
|
||||
errs <- err
|
||||
case 2:
|
||||
differences, err := view.Diff(otherView)
|
||||
if err == nil && (len(differences) != 1 || differences[0].Path.String() != expected.Path.String() ||
|
||||
differences[0].Left.YAML != expected.Left.YAML || differences[0].Right.YAML != expected.Right.YAML) {
|
||||
err = errors.New("concurrent comparison returned an unexpected difference")
|
||||
}
|
||||
errs <- err
|
||||
}
|
||||
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "captured", SessionID: "direct"})
|
||||
errs <- err
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
@@ -194,7 +280,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if source.reads.Load() != 0 || modelCalls.Load() != calls/2 {
|
||||
t.Fatalf("concurrent inspection/execution = source reads %d, model calls %d", source.reads.Load(), modelCalls.Load())
|
||||
if source.reads.Load() != 0 || otherSource.reads.Load() != 0 || modelCalls.Load() != calls/3 {
|
||||
t.Fatalf("concurrent inspection/comparison/execution = source reads (%d,%d), model calls %d", source.reads.Load(), otherSource.reads.Load(), modelCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,26 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/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
|
||||
// directory as one strict-composition group. The returned order is deterministic;
|
||||
// it does not assign precedence among those files. Discovery is recursive over
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,6 +18,78 @@ type sourceDocument struct {
|
||||
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("configkit:\n imports:\n - path: defaults.yaml\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(defaultsPath, []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
||||
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": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
||||
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.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\n")},
|
||||
"defaults.yaml": {Data: []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
||||
}
|
||||
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": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
||||
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.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryInputsSelectsSortedFilesWithRootContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -72,11 +73,12 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
||||
backendID, model := "workstation", "offline-model"
|
||||
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
||||
"prompts": map[string]any{"hello": map[string]any{"1": map[string]any{
|
||||
"prompts": map[string]any{"hello": map[string]any{
|
||||
"version": "1",
|
||||
"default_profile": "offline",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
}}},
|
||||
}},
|
||||
})}
|
||||
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
@@ -105,6 +107,36 @@ 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", map[string]promptkit.BackendConfig{
|
||||
openRouterID: {Endpoint: &openRouterEndpoint},
|
||||
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
|
||||
})},
|
||||
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
|
||||
"openrouter-profile": {Backend: &openRouterID, Model: &model},
|
||||
"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) {
|
||||
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
||||
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
||||
@@ -114,14 +146,14 @@ profiles:
|
||||
model: default-model
|
||||
prompts:
|
||||
hello:
|
||||
"1":
|
||||
default_profile: application
|
||||
messages:
|
||||
- role: user
|
||||
content: Hello
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
version: "1"
|
||||
default_profile: application
|
||||
messages:
|
||||
- role: user
|
||||
content: Hello
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)},
|
||||
}, "config")
|
||||
if err != nil {
|
||||
@@ -189,43 +221,94 @@ func TestPublicConstructionRejectsSuppliedInvalidMainInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicConstructionCanOverlayMaintainedBackend(t *testing.T) {
|
||||
endpoint := "http://local-openrouter.example/v1"
|
||||
func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T) {
|
||||
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
||||
promptkit.BackendOpenRouter: {Endpoint: &endpoint},
|
||||
})}}}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
||||
backendID: {Endpoint: &endpoint},
|
||||
})},
|
||||
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
|
||||
"application-profile": {Backend: &backendID, Model: &model},
|
||||
})},
|
||||
}}},
|
||||
}})
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("inspect catalog profile: %v", err)
|
||||
t.Fatalf("inspect configured profile: %v", err)
|
||||
}
|
||||
if inspection.EffectiveModelParams.Endpoint != endpoint {
|
||||
t.Fatalf("effective endpoint = %q, want %q", inspection.EffectiveModelParams.Endpoint, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparationRejectsAmbiguousPromptVersion(t *testing.T) {
|
||||
definition := func(content string) map[string]any {
|
||||
return map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
}
|
||||
}
|
||||
func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
||||
backendEndpoint := "https://backend.example/v1"
|
||||
defaultProfile := "not-yet-listed"
|
||||
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
||||
authenticationKind := " OPENROUTER_KEY "
|
||||
authenticationEndpoint := "https://probe.example/key?scope=current"
|
||||
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
|
||||
"local": {
|
||||
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{
|
||||
Main: &configkit.Input{Source: configkit.Mapping("versions", map[string]any{
|
||||
"prompts": map[string]any{"hello": map[string]any{
|
||||
"1": definition("first"), "2": definition("second"),
|
||||
}},
|
||||
})},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: source}}}},
|
||||
}})
|
||||
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) {
|
||||
t.Fatalf("ambiguous preparation = (%#v, %v), want ErrPromptLoad", result, err)
|
||||
inspection, err := engine.InspectConfiguration()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +63,12 @@ func simpleConfig(content string) promptkit.Config {
|
||||
"profiles": map[string]any{"profile": map[string]any{
|
||||
"endpoint": "http://example.test/v1", "model": "model",
|
||||
}},
|
||||
"prompts": map[string]any{"prompt": map[string]any{"1": map[string]any{
|
||||
"prompts": map[string]any{"prompt": map[string]any{
|
||||
"version": "1",
|
||||
"default_profile": "profile",
|
||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
}}},
|
||||
}},
|
||||
})},
|
||||
}}
|
||||
}
|
||||
|
||||
15
convert.go
15
convert.go
@@ -25,7 +25,6 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||
}
|
||||
return domain.RunRequest{
|
||||
PromptID: req.PromptID,
|
||||
PromptVersion: req.PromptVersion,
|
||||
ProfileID: req.ProfileID,
|
||||
SessionID: req.SessionID,
|
||||
APIKey: req.APIKey,
|
||||
@@ -73,7 +72,7 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
||||
}
|
||||
return &PreparedRun{
|
||||
PromptID: prepared.PromptID,
|
||||
PromptVersion: prepared.PromptVersion,
|
||||
PromptVersion: copyStringPtr(prepared.PromptVersion),
|
||||
PromptHash: prepared.PromptHash,
|
||||
SelectedProfileID: prepared.SelectedProfileID,
|
||||
SelectedBackendID: prepared.SelectedBackendID,
|
||||
@@ -100,7 +99,7 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
||||
RawOutput: result.RawOutput,
|
||||
Validation: fromDomainValidationResult(result.Validation),
|
||||
PromptID: result.PromptID,
|
||||
PromptVersion: result.PromptVersion,
|
||||
PromptVersion: copyStringPtr(result.PromptVersion),
|
||||
PromptHash: result.PromptHash,
|
||||
SessionID: result.SessionID,
|
||||
RenderedPromptHash: result.RenderedPromptHash,
|
||||
@@ -211,6 +210,12 @@ func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain
|
||||
}
|
||||
|
||||
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{
|
||||
BackendID: target.BackendID,
|
||||
Endpoint: target.Endpoint,
|
||||
@@ -222,7 +227,7 @@ func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
|
||||
ServiceTier: target.ServiceTier,
|
||||
ReasoningEffort: target.ReasoningEffort,
|
||||
APIKeyEnv: target.APIKeyEnv,
|
||||
ExtraParams: copyAnyMap(target.ExtraParams),
|
||||
ExtraParams: extraParams,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +257,7 @@ func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInsp
|
||||
}
|
||||
return &PromptInspection{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptVersion: copyStringPtr(inspection.PromptVersion),
|
||||
PromptHash: inspection.PromptHash,
|
||||
DefaultProfileID: inspection.DefaultProfileID,
|
||||
Inputs: inputs,
|
||||
|
||||
25
doc.go
25
doc.go
@@ -4,15 +4,16 @@
|
||||
// Applications construct an [Engine] with [NewEngine], select ConfigKit
|
||||
// sources for prompts, profiles, and backends, and
|
||||
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
|
||||
// [Engine.InspectProfile], [Engine.Prepare],
|
||||
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete
|
||||
// registries, repositories, validators, and the built-in OpenAI-compatible
|
||||
// client remain internal implementation details.
|
||||
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.ListProfiles],
|
||||
// [Engine.ListPrompts], [Engine.Prepare], [Engine.PrepareExecution],
|
||||
// [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
|
||||
// validators, and the built-in OpenAI-compatible client remain internal
|
||||
// implementation details.
|
||||
//
|
||||
// # Concurrency and ownership
|
||||
//
|
||||
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
|
||||
// InspectProfile, Prepare,
|
||||
// InspectRequirements, InspectProfile, ListProfiles, ListPrompts, Prepare,
|
||||
// PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
|
||||
// bound admitted Run and RunPrepared calls and model generations where
|
||||
// configured, while different backend pools and unlimited backends continue
|
||||
@@ -20,11 +21,12 @@
|
||||
// still receive concurrent calls and must be safe for that use.
|
||||
//
|
||||
// ConfigKit captures the selected configuration when NewEngine constructs an
|
||||
// Engine. Prepare,
|
||||
// PrepareExecution, and Run copy request maps, slices, pointer values, and
|
||||
// JSON-compatible extra parameters before using them. InspectPrompt and
|
||||
// InspectProfile return copied inspection values. Returned values and values
|
||||
// passed to extension interfaces are likewise isolated from engine state.
|
||||
// Engine. Prepare, PrepareExecution, and Run copy request maps, slices, pointer
|
||||
// values, and JSON-compatible extra parameters before using them. InspectPrompt,
|
||||
// InspectRequirements, and InspectProfile return copied inspection values.
|
||||
// ListProfiles and ListPrompts return caller-owned identity slices.
|
||||
// Returned values and values passed to extension interfaces are likewise
|
||||
// isolated from engine state.
|
||||
// Callers own those copies and may mutate them after the call that supplied or
|
||||
// returned them. [CapacityError] values are caller-owned and may be mutated
|
||||
// without affecting engine state or another error. Immutable [GenerationError]
|
||||
@@ -58,7 +60,8 @@
|
||||
// [ConfigurationSources], [ConfigurationInspection], [BackendConfig],
|
||||
// [ProfileConfig], [RunRequest],
|
||||
// [ArtifactRef], [ExecutionTargetOverride], [ProfileInspection],
|
||||
// [PromptInputDefinition], [PromptInspection], [PreparedExecution],
|
||||
// [PromptIdentity], [PromptInputDefinition], [PromptInspection],
|
||||
// [RequirementInspectionRequest], [RequirementInspection], [PreparedExecution],
|
||||
// [CapacityError], [GenerationError], [RetryExhaustedError], and
|
||||
// [ExecutionEvent], do not have stable JSON
|
||||
// representations. Direct API keys are nevertheless excluded from JSON for
|
||||
|
||||
165
docs/consumers/configuration-migration.md
Normal file
165
docs/consumers/configuration-migration.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Configuration And API Migration
|
||||
|
||||
Promptkit no longer installs a provider catalog or selects prompt definitions
|
||||
by descriptive version labels. Applications now choose configuration inputs
|
||||
explicitly, and execution selects one prompt by its exact ID. This guide
|
||||
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.
|
||||
|
||||
## 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 keys are local keys, while their runtime identities are
|
||||
qualified. For example, `collections.openrouter.profiles.gemini-flash-latest`
|
||||
is selected as `openrouter/gemini-flash-latest`. Stored backend, prompt-default,
|
||||
and other resource references must use their exact intended IDs; Promptkit does
|
||||
not rewrite references when a source is mounted. Profile `extends` remains a
|
||||
sibling local key in the same collection, not a cross-collection reference.
|
||||
Review collisions and all references when moving resources into collections.
|
||||
|
||||
The root `version` is an optional ConfigKit source header. A collection's
|
||||
`release_version` and a prompt's `version` are optional descriptive labels;
|
||||
neither selects a resource or changes execution. Preserve a label only when it
|
||||
is useful to your application. A formerly version-keyed prompt mapping must be
|
||||
reduced to one deliberately chosen definition per prompt ID, with its old label
|
||||
retained as prompt metadata if desired. Promptkit cannot choose a winner from a
|
||||
consumer's version history.
|
||||
|
||||
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 direct definition. Its optional `version` is only a label:
|
||||
|
||||
```yaml
|
||||
prompts:
|
||||
report:
|
||||
version: "2.0"
|
||||
messages:
|
||||
- role: user
|
||||
content: Review this report.
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
```
|
||||
|
||||
## 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:
|
||||
standard:
|
||||
extends: gemini-flash-latest
|
||||
```
|
||||
|
||||
An optional auto profile may instead select the exact backend and model values:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
openrouter:
|
||||
profiles:
|
||||
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, and optionally keep its label in
|
||||
the prompt's `version` field. That label remains descriptive metadata and is
|
||||
reported by inspection or results where applicable; it never chooses the
|
||||
definition.
|
||||
|
||||
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.
|
||||
@@ -15,10 +15,30 @@ Import the package as:
|
||||
import "gitea.maximumdirect.net/eric/promptkit"
|
||||
```
|
||||
|
||||
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,
|
||||
import, and error-handling code. Use the maintained examples for complete
|
||||
programs.
|
||||
|
||||
## Go Compatibility
|
||||
|
||||
The `go` directive in the selected release's [module metadata](../../go.mod)
|
||||
declares its minimum supported consumer Go version. Maintainers build and
|
||||
validate releases with the locally selected Go patch release; the minimum
|
||||
does not simply track that patch version.
|
||||
|
||||
Successful release validation on the selected patch is sufficient to presume
|
||||
support across its Go series, subject to the declared minimum and concrete
|
||||
evidence of incompatibility. Specific compatibility concerns are investigated
|
||||
as they arise. Minor releases may raise the minimum; patch releases preserve
|
||||
it. Release notes explain increases and any required consumer action. See the
|
||||
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain).
|
||||
|
||||
## Construct An Engine
|
||||
|
||||
Create an engine with [`NewEngine`](../../engine.go). Select each directory
|
||||
@@ -49,6 +69,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 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
|
||||
|
||||
Use `FilesystemInputs` for embedded defaults and `DirectoryInputs` for an
|
||||
@@ -95,8 +143,8 @@ input := configkit.Input{Source: document}
|
||||
```
|
||||
|
||||
`ProfileSource` and `BackendSource` are smaller typed conveniences when null
|
||||
is not needed. A caller underlay can overlay catalog backend fields just as it
|
||||
can overlay profiles; there is no registration-only backend namespace.
|
||||
is not needed. Backend and profile definitions are supplied by the caller;
|
||||
there is no registration-only backend namespace.
|
||||
|
||||
When configuration or message files change, build and validate a new engine,
|
||||
then switch the application to that engine for subsequent work. Existing
|
||||
@@ -104,6 +152,53 @@ engines and prepared handles retain their captured definitions; schema and
|
||||
request artifact resources still follow their runtime lifecycle. Inspection
|
||||
does not watch or reload sources automatically.
|
||||
|
||||
## Discover Available Resources And Collections
|
||||
|
||||
Use [`Engine.ListProfiles`](../../listing.go),
|
||||
[`Engine.ListPrompts`](../../listing.go), and
|
||||
[`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
|
||||
profiles, err := engine.ListProfiles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(profiles) > 0 {
|
||||
_, err := engine.InspectProfile(ctx, profiles[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
prompts, err := engine.ListPrompts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(prompts) > 0 {
|
||||
selected := prompts[0]
|
||||
inspection, err := engine.InspectPrompt(ctx, selected.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = inspection
|
||||
}
|
||||
```
|
||||
|
||||
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,
|
||||
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
|
||||
owns ordering, ownership, concurrency, and error behavior; identifiers may be
|
||||
sensitive application data.
|
||||
|
||||
## Inspect The Captured Configuration
|
||||
|
||||
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
||||
@@ -130,6 +225,104 @@ shows backend-completed execution settings for one profile, and `Prepare` or
|
||||
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
||||
and validation resources. None of those views is a substitute for the others.
|
||||
|
||||
To review changes between two independently captured configurations, compare
|
||||
their inspection views:
|
||||
|
||||
```go
|
||||
earlier, err := earlierEngine.InspectConfiguration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
later, err := candidateEngine.InspectConfiguration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
differences, err := earlier.Diff(later)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, difference := range differences {
|
||||
if difference.HiddenChanged {
|
||||
// Report a protected change without inferring its value.
|
||||
}
|
||||
if difference.Left.Redacted || difference.Right.Redacted {
|
||||
// Apply the application's disclosure policy before presenting snapshots.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
||||
and ConfigKit's [v0.8.0 effective-configuration diff guidance](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs)
|
||||
for the exact comparison contract. The operation compares only frozen effective
|
||||
configuration values: it does not reread external files or compare their
|
||||
contents, resolved credential contents, later execution preparation, or
|
||||
request-specific overrides. An empty diff is not proof of equivalent execution.
|
||||
Records can contain ordinary application data, so use the application's
|
||||
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
|
||||
|
||||
Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's
|
||||
@@ -137,7 +330,7 @@ declared inputs and output workflow without creating placeholder inputs or
|
||||
resolving a profile:
|
||||
|
||||
```go
|
||||
inspection, err := engine.InspectPrompt(ctx, "meeting.summary", "")
|
||||
inspection, err := engine.InspectPrompt(ctx, "meeting.summary")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,6 +347,85 @@ rendered messages, and use prepared execution when that work must remain tied
|
||||
to later execution. The method's [GoDoc](../../engine.go) owns exact fields,
|
||||
hash, ownership, and error semantics.
|
||||
|
||||
## Inspect Requirements Before Preparation
|
||||
|
||||
Use [`Engine.InspectRequirements`](../../requirements.go) when the application
|
||||
needs the prompt's recognizable input and caller-variable dependencies for a
|
||||
particular context before it has input artifacts. Unlike `InspectPrompt`, it
|
||||
also walks captured template syntax and selects a profile identity; unlike
|
||||
`Prepare`, it does not load artifacts or render messages.
|
||||
|
||||
```go
|
||||
requirements, err := engine.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||
PromptID: "meeting.summary",
|
||||
Vars: map[string]string{"audience": "project leads"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, input := range requirements.Inputs {
|
||||
if input.Kind == promptkit.RequirementRequired {
|
||||
// Plan to bind this input before preparation.
|
||||
}
|
||||
if input.Kind == promptkit.RequirementOptional && input.Explanation != "" {
|
||||
// A recognizable reference may occur only on a known skipped path.
|
||||
}
|
||||
}
|
||||
for _, variable := range requirements.Variables {
|
||||
if variable.Kind == promptkit.RequirementRequired && !variable.Supplied {
|
||||
// The key is required even though this inspection did not receive it.
|
||||
}
|
||||
}
|
||||
if !requirements.Complete {
|
||||
// Preserve the partial inventory and account for its discovery issues.
|
||||
}
|
||||
```
|
||||
|
||||
Optional entries can include recognizable inputs and variables found only in
|
||||
inactive branches. A missing required variable is reported separately from
|
||||
discovery completeness: missing a value used only for direct text substitution
|
||||
does not by itself make the inventory incomplete. Conversely, an unresolved
|
||||
dependency-bearing branch can make discovery incomplete while retaining known
|
||||
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
|
||||
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
|
||||
|
||||
[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile,
|
||||
@@ -174,8 +446,11 @@ prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||
The maintained
|
||||
[offline preparation example](../../examples/go-library/prepare/main.go)
|
||||
shows a complete runnable setup with a prompt file, in-memory profile, and
|
||||
inline input. Exact request requirements and prepared-result fields belong to
|
||||
the [`RunRequest` and `PreparedRun` GoDoc](../../types.go).
|
||||
inline input. It demonstrates validation and preparation with the same request
|
||||
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
|
||||
|
||||
@@ -362,96 +637,28 @@ are keys in configuration, not registrations. Choose caller underlays in
|
||||
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
||||
highest-priority input.
|
||||
|
||||
Move embedded `id` and `version` fields into keyed `profiles` and `prompts`
|
||||
maps; convert `base_profile` to ConfigKit `extends`. IDs and references are
|
||||
exact literal keys, without trimming. A supplied zero, false, or empty string
|
||||
is now distinguishable from omission; null can clear or suppress an inherited
|
||||
field according to the [field table](../formats.md#defaults-and-overrides).
|
||||
Maps merge recursively across inheritance and layers. `{}` does not clear an
|
||||
inherited map: use null to clear the whole map. At request time, a literal
|
||||
`extra_params["extends"]` is ordinary provider data, not an inheritance
|
||||
instruction; a nonempty request ExtraParams map replaces configured params.
|
||||
Changing a model or backend does not clear unrelated explicit fields, though
|
||||
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.
|
||||
Move embedded IDs into keyed `profiles` and `prompts` maps; each prompt ID now
|
||||
contains one installed definition, with any descriptive `version` label inside
|
||||
that definition. Convert `base_profile` to ConfigKit `extends`. For the current
|
||||
collection identity mapping, ID-only prompt selection, and required
|
||||
caller-owned choices, see the
|
||||
[configuration migration guide](configuration-migration.md). Exact current
|
||||
format, reference, inheritance, and presence semantics belong to the
|
||||
[framework format reference](../formats.md). 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.
|
||||
|
||||
### Choose A Profile Ownership Model
|
||||
|
||||
Choose the profile source that matches the application's continuity needs:
|
||||
|
||||
- Select `promptkit-openrouter-standard` or `promptkit-openrouter-auto` when
|
||||
the maintained literal name is the desired consumer contract. Their current
|
||||
selections are in the [format reference](../formats.md#built-in-profile-catalog).
|
||||
- Select an external catalog profile directly when the application accepts that
|
||||
profile's catalog volatility. The selected external inventories are linked
|
||||
from the [format reference](../formats.md#external-profile-inventories).
|
||||
- Define a complete application-owned profile with an explicit backend or
|
||||
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.
|
||||
Supply backend and profile definitions through the configuration sources your
|
||||
application selects. PromptKit does not install provider catalogs or built-in
|
||||
profile inventories; conventional backend ID constants are names only. Define
|
||||
the model, endpoint, credentials lookup, and settings that your application
|
||||
intends to support. A profile may inherit a sibling profile by its local key in
|
||||
the same root or collection mapping; its backend reference uses the exact
|
||||
configured identity. Caller-selected source layers determine the final values. The
|
||||
[format reference](../formats.md) describes the definition fields and
|
||||
composition rules.
|
||||
|
||||
### Inspect A Profile Before Prompt Work
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Do not create
|
||||
@@ -59,37 +59,43 @@ exist.
|
||||
## Maintainer Validation
|
||||
|
||||
For ordinary work, select validation proportionate to the change. Do not repeat
|
||||
successful checks for unchanged content merely to create a commit. The full
|
||||
release-candidate checks have one executable entry point from the repository root:
|
||||
successful checks for unchanged content merely to create a commit. During
|
||||
development, run relevant Go tests and vet, format changed Go files, and execute
|
||||
the maintained examples when they are affected:
|
||||
|
||||
```sh
|
||||
./scripts/release-check.sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
gofmt -w path/to/changed.go
|
||||
python3 scripts/check-examples.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The [script](../scripts/release-check.sh) owns the executable checks:
|
||||
race-enabled tests, vet, the three offline consumer examples with semantic output
|
||||
assertions, tracked Go formatting, local Markdown links, module/workspace
|
||||
hygiene, whitespace, and the credential-pattern scan. It does not publish,
|
||||
change files, validate release-note structure, or retire roadmaps. It requires Go,
|
||||
Git, a POSIX shell, and Python 3. Tests and examples require no provider
|
||||
credentials or live inference calls.
|
||||
Release candidates use `./scripts/release.sh VERSION`, which runs the selected
|
||||
profile checks and publishes the tag. The [release procedure](release.md)
|
||||
describes its requirements and recovery. For an optional diagnostic of a
|
||||
committed candidate without publication, run `./scripts/releasekit.sh check
|
||||
VERSION`; this is not a required pre-publication step.
|
||||
|
||||
The script can run on intended uncommitted changes during development.
|
||||
Formatting and documentation checks use tracked files; stage intended new
|
||||
files before final validation so those checks include them. The release
|
||||
procedure separately enforces a clean, committed candidate.
|
||||
The standalone `python3 scripts/check-examples.py` command checks semantic
|
||||
expectations for the three offline consumer examples. The focused
|
||||
`python3 scripts/test-release.py` command exercises the release tooling against
|
||||
disposable local repositories; use it when changing that tooling, not as a
|
||||
recurring release gate. Tests and examples require no provider credentials or
|
||||
live inference calls.
|
||||
|
||||
Ordinary validation can run on intended uncommitted work. Release validation
|
||||
uses committed candidate contents and the selected local Go installation.
|
||||
|
||||
### Change Review
|
||||
|
||||
Inspect `git status --short --untracked-files=all` and the complete diff before
|
||||
accepting a change. Reject unintended files, credentials, private data,
|
||||
generated binaries, test output, downloaded assets, and template residue.
|
||||
The automated credential scan does not replace review of the actual change.
|
||||
|
||||
For releases, follow the [release procedure](release.md). Its publication
|
||||
command runs the full local checks once on the final committed candidate and
|
||||
automates release-note structure and Git checks. Do not repeat ordinary change
|
||||
review as a release sign-off. Roadmap retirement remains preparation housekeeping.
|
||||
For releases, follow the [release procedure](release.md). Do not repeat its
|
||||
candidate checks as a separate sign-off. Roadmap retirement remains preparation
|
||||
housekeeping.
|
||||
|
||||
Follow the [agent commit policy](policy/agents.md#git-commit-policy) for local
|
||||
commits. Ordinary commits may leave unrelated work in the workspace; preserve
|
||||
|
||||
263
docs/formats.md
263
docs/formats.md
@@ -10,12 +10,14 @@ owns outbound wire behavior. [ConfigKit](https://gitea.maximumdirect.net/eric/Co
|
||||
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||
This reference describes what Promptkit does with the final effective values.
|
||||
|
||||
A configuration document has keyed top-level `backends`, `profiles`, and
|
||||
`prompts` mappings. Their keys are literal IDs, not filename-derived identities.
|
||||
Prompt versions are a second keyed mapping. Keys and references must be
|
||||
nonblank and remain exact: Promptkit does not trim or re-key them. Old embedded
|
||||
`id`, `version`, and `base_profile` fields are invalid. Unknown fields are
|
||||
errors.
|
||||
A configuration document may contain keyed top-level `backends`, `profiles`,
|
||||
and `prompts` mappings, plus a `collections` mapping of namespaced resource
|
||||
groups. Resource keys are literal IDs, not filename-derived identities. Each
|
||||
prompt ID maps directly to one definition; an optional `version` string is
|
||||
descriptive metadata, not a selection key. Keys and references must be nonblank
|
||||
and remain exact: Promptkit does not trim or re-key them. Old embedded `id` and
|
||||
`base_profile` fields are invalid. Unknown fields are errors. Promptkit installs
|
||||
no provider or profile catalog; callers select the definitions they need.
|
||||
|
||||
```yaml
|
||||
backends:
|
||||
@@ -28,16 +30,74 @@ profiles:
|
||||
model: example-model
|
||||
prompts:
|
||||
meeting.summary:
|
||||
"1.0.0":
|
||||
default_profile: local-summary
|
||||
messages:
|
||||
- role: user
|
||||
content: Summarize this note.
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
version: "1.0.0"
|
||||
default_profile: local-summary
|
||||
messages:
|
||||
- role: user
|
||||
content: Summarize this note.
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
collections:
|
||||
support:
|
||||
release_version: "1.4.0"
|
||||
backends:
|
||||
offline:
|
||||
endpoint: https://example.invalid/v1
|
||||
profiles:
|
||||
base:
|
||||
backend: support/offline
|
||||
model: offline-model
|
||||
fast:
|
||||
extends: base
|
||||
timeout_seconds: 30
|
||||
prompts:
|
||||
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
|
||||
`"1"` or strict decimal integer `1`; null and other values are invalid. This
|
||||
header is distinct from a prompt's nested `version` label and a collection's
|
||||
`release_version`. ConfigKit checks and consumes accepted headers on non-main
|
||||
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.
|
||||
|
||||
The root resource mappings contain unqualified IDs. Each `collections` entry
|
||||
has a namespace key and may contain `release_version` plus the same three
|
||||
resource mappings. Collection resource keys are local keys; their runtime IDs
|
||||
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
||||
namespace and local key must be nonblank and slash-free, and references must
|
||||
name their intended exact ID; mounting a source does not rewrite references.
|
||||
For example, the `support/fast` profile above stores `support/offline` as its
|
||||
backend ID. In `collections.support.profiles.fast`, ConfigKit `extends: base`
|
||||
refers to a sibling profile in that same mapping. It is not a qualified or
|
||||
cross-collection reference.
|
||||
|
||||
`release_version` and prompt `version` are optional opaque labels. Absent or
|
||||
null means no label; an explicitly empty string is retained as a declared
|
||||
label. Neither label selects a resource or changes execution. Runtime lookups
|
||||
use the captured effective resource indexes. `InspectConfiguration` is an
|
||||
explicit operator view of effective settings and provenance; source history is
|
||||
not a second runtime resolver, and the retained effective shape is not flattened
|
||||
or rewritten into those indexes.
|
||||
|
||||
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
|
||||
`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
|
||||
@@ -51,36 +111,37 @@ message files on ordinary runs; construct a new engine to observe edits.
|
||||
Schema and request artifact reads still happen during preparation. Use
|
||||
`InspectConfiguration` for an explicit effective-configuration or provenance
|
||||
report, `InspectProfile` for backend-completed profile settings, and preparation
|
||||
for request-specific rendered details.
|
||||
for request-specific rendered details. For identity discovery before inspection,
|
||||
see the consumer guide's [resource and collection listing](consumers/pkg-promptkit.md#discover-available-resources-and-collections).
|
||||
|
||||
## Prompt Definitions
|
||||
|
||||
The `prompts` mapping selects a literal prompt ID, then a string version key:
|
||||
The `prompts` mapping selects a literal prompt ID whose value is the definition:
|
||||
|
||||
```yaml
|
||||
prompts:
|
||||
meeting.summary:
|
||||
"1.0.0":
|
||||
default_profile: local-summary
|
||||
description: Summarize a synthetic meeting note.
|
||||
session_id: '{{.session}}'
|
||||
inputs:
|
||||
- name: note
|
||||
required: true
|
||||
content_type: text/plain
|
||||
description: Meeting note to summarize.
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise summary.
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
ttl: 1h
|
||||
- role: user
|
||||
content_file: ./summary.user.md
|
||||
output:
|
||||
format: markdown
|
||||
validation_mode: basic
|
||||
repair_attempts: 0
|
||||
version: "1.0.0"
|
||||
default_profile: local-summary
|
||||
description: Summarize a synthetic meeting note.
|
||||
session_id: '{{.session}}'
|
||||
inputs:
|
||||
- name: note
|
||||
required: true
|
||||
content_type: text/plain
|
||||
description: Meeting note to summarize.
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise summary.
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
ttl: 1h
|
||||
- role: user
|
||||
content_file: ./summary.user.md
|
||||
output:
|
||||
format: markdown
|
||||
validation_mode: basic
|
||||
repair_attempts: 0
|
||||
```
|
||||
|
||||
`messages` must be nonempty and `output` must be a valid mapping. Optional
|
||||
@@ -88,9 +149,8 @@ prompts:
|
||||
fields may be absent or cleared with null where appropriate. A null whole
|
||||
definition, required role, output format, or validation mode is invalid.
|
||||
`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
|
||||
prompt ID has exactly one effective version. `InspectPrompt` selects from the
|
||||
same captured definitions without rendering or resolving a profile.
|
||||
are not nullable. `InspectPrompt` selects the same captured definition without
|
||||
rendering or resolving a profile.
|
||||
|
||||
### Inputs
|
||||
|
||||
@@ -124,6 +184,12 @@ Optional `session_id` uses the same template variables and input helper. Its
|
||||
rendered value is trimmed, omitted if empty, and limited to 256 Unicode code
|
||||
points. A nonblank direct request session ID bypasses the template.
|
||||
|
||||
`InspectRequirements` can enumerate recognizable dependencies from captured
|
||||
templates before artifact content is available. Its conservative syntax
|
||||
coverage does not change template validity or rendering; see the [consumer
|
||||
guide](consumers/pkg-promptkit.md#inspect-requirements-before-preparation) and
|
||||
the method's [GoDoc](../requirements.go) for that inspection boundary.
|
||||
|
||||
### Cache Control
|
||||
|
||||
Optional `cache_control` has required `type: ephemeral` and optional `ttl`,
|
||||
@@ -149,28 +215,64 @@ last candidate and its failed validation result. Operational errors remain
|
||||
errors. A request-level `OutputContract` replaces the whole configured one,
|
||||
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
|
||||
earliest underlay. Their settings can be overlaid by caller sources; these IDs
|
||||
are not reserved against configuration changes.
|
||||
|
||||
| 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 |
|
||||
Backend definitions are supplied through caller-selected configuration
|
||||
sources. The conventional backend IDs are names only; PromptKit does not
|
||||
install definitions for them.
|
||||
|
||||
A backend definition may have `endpoint`, `api_key_env`,
|
||||
`concurrency_limit`, `queue_capacity`, and `extra_params`. The final endpoint
|
||||
is required and must be an absolute HTTP(S) URL with a host, no user
|
||||
information, query, or fragment. An omitted or null API-key environment name
|
||||
means no lookup. Omitted/null concurrency is zero (unlimited); a supplied
|
||||
`concurrency_limit`, `queue_capacity`, `extra_params`, and `smoke_tests`. The
|
||||
final endpoint is required and must be an absolute HTTP(S) URL with a host, no
|
||||
user information, query, or fragment. An omitted or null API-key environment
|
||||
name means no lookup. Omitted/null concurrency is zero (unlimited); a supplied
|
||||
negative value is invalid. For positive concurrency, omitted/null queue
|
||||
capacity defaults to 1024. An explicit queue capacity, including zero,
|
||||
requires positive concurrency and must be nonnegative. Unlimited concurrency
|
||||
has no queue. Provider maps contain JSON-compatible values; null entries are
|
||||
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:
|
||||
openrouter:
|
||||
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. A higher-priority mapping can
|
||||
override individual nested fields; an explicit null can clear the whole
|
||||
`smoke_tests` block or one of its nested objects. An endpoint set to null uses
|
||||
its documented fallback, while an explicit blank endpoint is invalid. The
|
||||
metadata does not change ordinary generation credential requirements or
|
||||
provider settings.
|
||||
|
||||
## Profile Definitions
|
||||
|
||||
Profiles are keyed by literal ID and may inherit a named parent with
|
||||
@@ -231,57 +333,36 @@ does not disable the caller context or HTTP transport timeout.
|
||||
|
||||
### Source And Profile Precedence
|
||||
|
||||
`NewEngine` composes catalog definitions first, Promptkit-owned definitions
|
||||
next, then caller underlays in their given order, with an optional main input
|
||||
last. Later tiers overlay earlier ones. Inputs within one underlay group are
|
||||
strictly composed: conflicting contributions are errors, not priority
|
||||
signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
||||
`NewEngine` composes caller underlays in their given order, followed by an
|
||||
optional main input. Later layers overlay earlier ones. Inputs within one
|
||||
underlay group are strictly composed: conflicting contributions are errors,
|
||||
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.
|
||||
An explicitly selected missing/unreadable root fails; an empty root contributes
|
||||
nothing. Imports are disabled by these helpers unless the caller enables them
|
||||
on an input. Single files can use ConfigKit inputs directly.
|
||||
|
||||
Backends, profiles, and prompts all follow these same source rules. A later
|
||||
profile or backend can change individual fields of a lower definition. A
|
||||
request `ProfileID` takes precedence over a prompt `default_profile`; if
|
||||
Root backends, profiles, and prompts, as well as collection declarations and
|
||||
their resource mappings, follow these same source rules. `CollectionInput`
|
||||
mounts one explicitly selected source at a collection destination and enables
|
||||
ConfigKit imports for that input; it does not discover or select sources.
|
||||
A later profile or backend can change individual fields of a lower definition.
|
||||
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
||||
neither is selected, preparation fails. Exact profile inspection selects one
|
||||
explicit ID and does not consult a prompt default.
|
||||
|
||||
### Profile Inheritance
|
||||
|
||||
ConfigKit resolves `extends` on effective profile definitions. One profile
|
||||
may inherit another keyed profile; presence, explicit null, and recursively
|
||||
merged maps determine the final fields. References are literal IDs, including
|
||||
IDs containing punctuation. There is no Promptkit-specific depth limit or
|
||||
second base-chain resolver. Missing references and cycles fail at construction.
|
||||
For generic inheritance and layer mechanics, consult
|
||||
may inherit a sibling profile by local key in the same root or collection
|
||||
mapping; collection inheritance cannot cross namespaces. Presence, explicit
|
||||
null, and recursively merged maps determine the final fields. Other resource
|
||||
references are exact IDs and use their qualified form when they select a
|
||||
collection resource. There is no Promptkit-specific depth limit or second
|
||||
base-chain resolver. Missing references and cycles fail at construction. For
|
||||
generic inheritance and layer mechanics, consult
|
||||
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit).
|
||||
|
||||
## Built-In Profile Catalog
|
||||
|
||||
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 are JSON documents selected by a prompt or request `schema_path`.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The request body always contains `model` and `messages`. The execution
|
||||
@@ -78,9 +117,8 @@ The client conditionally includes:
|
||||
|
||||
The engine resolves backend, profile, and request extra-parameter maps by
|
||||
whole-map replacement rather than key merging. The resulting effective map is
|
||||
then merged directly into the top-level body after JSON serialization is
|
||||
verified. Empty keys and collisions with these reserved fields are rejected
|
||||
before any provider call:
|
||||
merged into the top-level body. Empty keys, values that cannot be encoded, and
|
||||
collisions with these reserved fields are rejected before any provider call:
|
||||
|
||||
- `model`
|
||||
- `session_id`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
||||
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.
|
||||
|
||||
@@ -41,9 +41,22 @@ original error chain. This keeps injected attempt classifications consistent
|
||||
with terminal observations without parsing diagnostic text or retrying injected
|
||||
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
|
||||
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
|
||||
[integration tests](../../reliable_execution_contract_test.go) cover retry,
|
||||
repair, observation, frozen prepared execution, and shared-backend behavior
|
||||
|
||||
@@ -34,9 +34,13 @@ resolved request target may supply the endpoint. Generation preparation then:
|
||||
1. validates shared execution-setting invariants and the final selected base
|
||||
endpoint;
|
||||
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||
3. validates and merges extra parameters;
|
||||
3. checks extra-parameter keys against empty and reserved names, then merges
|
||||
their values into the payload;
|
||||
4. composes `/chat/completions` through parsed URL path operations;
|
||||
5. resolves authentication and freezes the URL, body, and optional credential.
|
||||
5. encodes the complete body once on success. If encoding fails, extra values
|
||||
are checked individually to retain invalid-request classification; otherwise
|
||||
the complete-request failure remains a request failure;
|
||||
6. resolves authentication and freezes the URL, body, and optional credential.
|
||||
|
||||
Each execution of that prepared request creates a fresh HTTP request, body
|
||||
reader, and attempt timeout context. It closes the response body and returns
|
||||
@@ -55,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,
|
||||
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
|
||||
|
||||
For [`RunPrepared`](../../engine.go), the runner supplies the model client with
|
||||
@@ -133,7 +164,10 @@ own configuration, client cloning, deterministic deadline precedence,
|
||||
authentication, request and response mapping, malformed data, error identity,
|
||||
cancellation, endpoint selection and composition, pre-transport rejection, 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)
|
||||
own envelope parsing, normalization, and bounded-reader cases; their
|
||||
[transport tests](../../internal/llm/provider_http_error_transport_test.go)
|
||||
|
||||
@@ -23,6 +23,10 @@ callback. The caller supplies normalized failure metadata; the recorder never
|
||||
parses an error string or receives provider bodies, credentials, headers, or
|
||||
other content. Events are values with no mutable containers. Callback panics
|
||||
propagate to the caller.
|
||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||
the invocation, including namespace qualification when supplied by an engine
|
||||
view. A prompt's optional version label is descriptive metadata, not a
|
||||
selection key.
|
||||
Per-operation callbacks are ordered and synchronous; different operations may
|
||||
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||
end before delivery, so callback work does not inflate provider duration.
|
||||
@@ -41,6 +45,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
|
||||
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
|
||||
presence, phase sums, event ordering, empty-run accounting, identity snapshots,
|
||||
and callback panic behavior. The [generation coordinator](generation.md) owns
|
||||
|
||||
@@ -11,28 +11,27 @@ contributor workflow and validation.
|
||||
|
||||
| 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, explicit inspection, 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) |
|
||||
| `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) |
|
||||
| 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 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/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/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 keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values, captures indexed prompt definitions and referenced message content, constructs backend and presence-aware profile snapshots, and retains an explicit read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) |
|
||||
| `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/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/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, 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), [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. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [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/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 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, 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/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/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
|
||||
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
|
||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.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/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/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/usecase` | Selects captured prompts and profiles, 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), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||
| `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; 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, 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
|
||||
representations. Consumers depend only on the root facade.
|
||||
|
||||
@@ -32,16 +32,36 @@ The runner has no durable run or session store.
|
||||
|
||||
## Shared Prompt Selection
|
||||
|
||||
The runner uses one prompt-selection and hashing boundary for ordinary
|
||||
preparation and exact prompt inspection. Preparation retains its early
|
||||
request-ID check before direct-session normalization; both operations select
|
||||
one captured definition and its construction-time hash. Referenced message
|
||||
content and static template syntax were captured when the engine was built.
|
||||
The internal engine applies each namespace view to slashless caller selections
|
||||
before they reach the runner. The runner therefore selects captured prompts and
|
||||
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
||||
through, while configured defaults and stored references are used as authored
|
||||
and are not requalified. Prompt version labels are returned as optional
|
||||
descriptive metadata; they are not prompt selectors.
|
||||
|
||||
Inspection stops after that structural lookup. It does not render templates or
|
||||
touch profile, artifact, schema, renderer, validator, admission, or model
|
||||
collaborators. The root [`Engine.InspectPrompt`](../../engine.go) GoDoc owns
|
||||
the public operation's exact contract.
|
||||
Ordinary preparation selects the full prompt definition and construction-time
|
||||
hash. It retains its early request-ID check before direct-session
|
||||
normalization. Referenced message content and static template syntax were
|
||||
captured when the engine was built.
|
||||
|
||||
`InspectPrompt` selects captured metadata through an optional repository
|
||||
capability, falling back to the full definition and existing hash resolution
|
||||
for internal repositories without it. It returns owned metadata without
|
||||
rendering templates or touching profile, artifact, schema, renderer, validator,
|
||||
admission, or model collaborators. The root
|
||||
[`Engine.InspectPrompt`](../../engine.go) GoDoc owns the public operation's
|
||||
exact contract.
|
||||
|
||||
`InspectRequirements` uses the same metadata selection to resolve the
|
||||
request-profile/default identity, then obtains the captured requirement
|
||||
snapshot by the selected prompt ID. The analyzer receives only
|
||||
those identity fields rather than an execution definition. It analyzes the
|
||||
snapshot with the supplied caller variables and normalized direct-session
|
||||
bypass; it does not resolve target readiness or enter the preparation
|
||||
pipeline. Missing snapshot capability is a prompt-load failure rather than a
|
||||
source fallback. The root
|
||||
[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public
|
||||
operation's exact contract.
|
||||
|
||||
## Shared Profile Selection
|
||||
|
||||
@@ -57,6 +77,37 @@ artifact, schema, rendering, admission, or model-client work. The root
|
||||
[`Engine.InspectProfile`](../../engine.go) GoDoc owns the public operation's
|
||||
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
|
||||
|
||||
`Prepare` and `Run` share one private preparation pipeline split at the point
|
||||
@@ -92,8 +143,17 @@ one execution-precedence and error-ordering implementation while allowing a
|
||||
full backend pool to reject work before expensive schema, artifact, and
|
||||
rendering operations. `Prepare` discards the plan after returning its public
|
||||
metadata. `Run` retains the plan through initial and repaired-output validation
|
||||
and discards it when the operation ends. Prepared execution stores the same
|
||||
kind of plan only in its private payload.
|
||||
and discards it when the operation ends. Prepared execution transfers the
|
||||
completed preparation's owned message, input-hash, and copied-schema values
|
||||
into its private payload, while deep-copying mutable provider extras and
|
||||
creating an independent details snapshot. Claim completion or discard clears
|
||||
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
|
||||
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
||||
@@ -234,4 +294,8 @@ Changes to orchestration should continue to use the existing package
|
||||
interfaces, keep request state local to an invocation, and preserve the shared
|
||||
resolution and completion pipeline. Source, renderer, validator, or
|
||||
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.
|
||||
|
||||
@@ -14,61 +14,149 @@ source semantics. Internal packages are not consumer APIs.
|
||||
effective configuration to execution-owned definitions. It performs one
|
||||
layered load, captures effective presence (including explicit null), records
|
||||
normalization and defaults through resolution, and validates all effective
|
||||
backend, profile, and prompt values. Replaced lower-layer domain values are
|
||||
not separately validated. It builds an immutable backend index and
|
||||
presence-aware profile targets, applying final backend defaults only after
|
||||
profile inheritance. It retains ConfigKit's result privately for explicit
|
||||
inspection, not for per-run resolution. The runtime/domain packages do not
|
||||
depend on ConfigKit.
|
||||
root and collection backend, profile, and prompt values. Replaced lower-layer
|
||||
domain values are not separately validated. It indexes root resources under
|
||||
their unqualified IDs and collection resources under their full
|
||||
`namespace/local-key` IDs while retaining ConfigKit's effective tree in its
|
||||
authored shape. It builds an immutable backend index and presence-aware profile
|
||||
targets, applying final backend defaults only after profile inheritance. Runtime
|
||||
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 result for explicit operator inspection.
|
||||
Accepted non-main source headers are consumed during composition; the main
|
||||
source declaration remains available in that inspection view. Prompt and
|
||||
collection release labels are descriptive metadata, not selection inputs. The
|
||||
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
||||
owns the exact source-header and collection contracts.
|
||||
|
||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||
once to ConfigKit v0.8.0 and returns its public difference records. Comparison
|
||||
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||
both results; reports may still contain ordinary application data. See the
|
||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||
and ConfigKit's [v0.8.0 comparison contract](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs).
|
||||
|
||||
After resource materialization and effective-profile snapshot construction, the
|
||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
||||
and prompts, per-kind resource namespace sets, and metadata for effective
|
||||
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
|
||||
files under an explicit root, provide resource context, and leave imports
|
||||
disabled. Each returned slice belongs to one strict-composition group; file
|
||||
order does not confer priority. The engine prepends selected catalog and
|
||||
Promptkit-owned definitions, then caller underlays and optional main. The
|
||||
order does not confer priority. Caller underlays are composed in their given
|
||||
order, followed by the optional main input. The
|
||||
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
||||
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
|
||||
|
||||
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
|
||||
origin and resource context, and checks static message/session template syntax
|
||||
at construction. It stores captured content, compiled templates, declared
|
||||
input metadata, and a definition hash. It closes content-file handles before
|
||||
input metadata, and a definition hash. Alongside the unchanged hashed
|
||||
definition, it retains content-type and description presence for each declared
|
||||
input and provides an internal requirement snapshot with those declarations
|
||||
and the captured parses. Metadata values come from the final normalized
|
||||
declaration; absent and null fields remain distinguishable from explicit
|
||||
strings, including empty strings. It closes content-file handles before
|
||||
returning and retains no source capability for public inspection.
|
||||
|
||||
Role and cache-control normalization is applied to execution-owned message
|
||||
copies. The retained ConfigKit configuration keeps effective input message
|
||||
metadata and its resource origins: recording a sequence-wide normalization in
|
||||
ConfigKit v0.5.0 would replace origins of unchanged content-file fields.
|
||||
Prompt materialization shares a lazy metadata snapshot across root and collection
|
||||
resources during construction, avoiding a full metadata copy per collection.
|
||||
|
||||
The runner selects from this index for preparation and `InspectPrompt`.
|
||||
Neither path rescans configuration or reopens message files. `InspectPrompt`
|
||||
Role and cache-control normalization is applied to execution-owned message
|
||||
copies. ConfigKit v0.8.0 retains effective input message metadata and its
|
||||
resource origins; recording a sequence-wide normalization in the retained
|
||||
configuration would replace origins of unchanged content-file fields.
|
||||
|
||||
The runner selects full definitions from this index for preparation and uses
|
||||
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
||||
internal requirement snapshot lookup uses the selected prompt ID alone.
|
||||
These paths do not rescan configuration or reopen message files. `InspectPrompt`
|
||||
returns declared metadata without rendering or resolving profile, input, or
|
||||
schema resources. Runtime rendering still checks request variables and input
|
||||
helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
|
||||
schema resources, and its public shape and prompt hashes do not include the
|
||||
captured presence state. Runtime rendering still checks request variables and
|
||||
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
|
||||
cover resource origins, context containment, capture, and selection.
|
||||
|
||||
## Profiles And Built-Ins
|
||||
The configuration adapter shares ID-only selection across its internal prompt
|
||||
lookups. The execution snapshot lookup returns an owned prompt
|
||||
definition and cached hash; metadata inspection returns owned input metadata
|
||||
without copying execution templates. Profile selection likewise has an
|
||||
identity-only lookup for consumers that do not need the execution target,
|
||||
while the full profile lookup continues to return an owned target and deep copy
|
||||
of provider extras. These lookups use captured indexes and do not query
|
||||
ConfigKit again.
|
||||
|
||||
`internal/catalog` selects the released keyed OpenRouter and Rakestrawhome
|
||||
assets as one ConfigKit underlay and PromptKit-owned profiles as the next.
|
||||
It discovers YAML inputs and supplies passive filesystem context without
|
||||
parsing or pre-validating catalog definitions. The configuration loader
|
||||
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.
|
||||
The `internal/prompt` requirement walker uses the captured parse trees and
|
||||
declaration metadata without executing templates or reading artifacts. It
|
||||
collects literal `input` names, direct root caller keys, and literal-key
|
||||
`index` lookups. For `if` and `with`, it classifies branches when their
|
||||
conditions are simple supported values: direct values, literals, negation, or
|
||||
two-string equality/inequality. It uses Go template truthiness, so a supplied
|
||||
nonempty string such as `false` is true. Missing decision values and
|
||||
content-dependent conditions leave dependency-bearing branches conditional
|
||||
and report incomplete discovery; unresolved conditions with no branch
|
||||
dependencies do not by themselves make discovery incomplete. Recognizable
|
||||
references on known skipped paths remain optional inventory entries with an
|
||||
inactive-use explanation, unless another applicable use establishes a
|
||||
stronger classification. Skipped expressions are scanned only for
|
||||
syntactically recognizable names and are not evaluated. A direct caller value
|
||||
or literal-key lookup can also supply an input name, including through simple
|
||||
parentheses or a direct pipeline to `input`; names are used exactly as
|
||||
supplied. Unresolved names and unsupported dependency-bearing expressions
|
||||
produce structured limitations while retaining recognizable references.
|
||||
Where an unresolved name or scope could affect existing optional entries, those
|
||||
entries are reported as conditional rather than inactive-only or definitively
|
||||
optional. Direct root-map `with` forms retain caller-map identity. Known scalar
|
||||
values remain separate from unknown scopes; accesses through untracked local
|
||||
maps report incomplete discovery rather than silently losing caller keys.
|
||||
Unsupported name expressions reuse value analysis's conservative syntax scan
|
||||
instead of recursively rescanning the same subtree.
|
||||
Invoked named templates are scanned conservatively, and
|
||||
dependency-bearing range scopes make discovery incomplete. Uncalled definitions
|
||||
and bypassed session templates are not traversed. This internal walker does not
|
||||
change rendering behavior.
|
||||
|
||||
ConfigKit resolves profile inheritance before the handoff. The configuration
|
||||
adapter validates effective references and field values, then builds target
|
||||
## Profiles
|
||||
|
||||
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
||||
`extends` names a sibling local key; it is not a cross-collection reference.
|
||||
The configuration adapter validates final effective resource references and
|
||||
field values, then builds target
|
||||
snapshots with backend defaults and presence/suppression state. A profile can
|
||||
remain partial until runtime overrides complete it. `InspectProfile` selects
|
||||
that captured target without prompt, artifact, schema, credential lookup, or
|
||||
provider work. Preparation applies runtime overrides and then checks target
|
||||
completeness. The [target tests](../../internal/configuration/targets_test.go)
|
||||
cover effective target interpretation, while the [catalog tests](../../internal/catalog/catalog_test.go)
|
||||
cover selected released assets.
|
||||
completeness. Identity-only profile selection can identify a partial captured
|
||||
profile without copying its target or requiring readiness. The
|
||||
[target tests](../../internal/configuration/targets_test.go) cover effective
|
||||
target interpretation.
|
||||
|
||||
The internal engine delegates listing through a narrow identity-inventory
|
||||
interface directly to the configuration snapshot. The execution runner has no
|
||||
listing responsibility.
|
||||
|
||||
## Ordinary Artifacts
|
||||
|
||||
@@ -79,10 +167,15 @@ and after opening the file. It copies content into an artifact, records
|
||||
metadata and an opaque content-equality value, and applies a content-type
|
||||
fallback.
|
||||
|
||||
Regular files are read synchronously in bounded chunks. Cancellation is
|
||||
checked before opening, before and after every read, and before publishing the
|
||||
artifact, so a canceled read never publishes partial content. The ordinary
|
||||
reader does not detach file reads into background goroutines.
|
||||
Regular files are read synchronously in bounded chunks. The opened file's size
|
||||
is only an initial-capacity hint, capped at 1 MiB; reads continue to EOF, and
|
||||
the returned body determines artifact size and hash. If an overreported hint
|
||||
leaves unused capacity greater than the actual body length, the completed body
|
||||
is copied to release the excess backing storage, including when the body is
|
||||
empty. Cancellation is checked before opening, before and after every read,
|
||||
and before publishing the artifact, so a
|
||||
canceled read never publishes partial content. The ordinary reader does not
|
||||
detach file reads into background goroutines.
|
||||
|
||||
This ordinary reader does not implement an inbound HTTP security boundary. In
|
||||
particular, it does not constrain files to an application root or impose an
|
||||
@@ -106,7 +199,9 @@ between artifact conversion chunks, around each message, and before publishing
|
||||
the complete prompt. It cannot interrupt template work already in progress and
|
||||
never publishes a partial prompt after observing cancellation. It validates and
|
||||
canonicalizes message roles before carrying roles, session IDs, and cache
|
||||
control into the rendered prompt. The
|
||||
control into the rendered prompt. Message and session templates execute into
|
||||
per-call string builders, so their rendered strings do not require a final
|
||||
buffer-to-string copy. The
|
||||
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
||||
|
||||
## Schemas And Output Validation
|
||||
@@ -114,7 +209,8 @@ control into the rendered prompt. The
|
||||
`internal/validate` provides validators backed by an operating-system
|
||||
filesystem or an `fs.FS`. Invalid generated content is returned as a validation
|
||||
result; inability to load, register, or compile a schema is an operational
|
||||
error.
|
||||
error. Basic validation checks body emptiness on the byte slice directly
|
||||
instead of first making a response-sized string copy.
|
||||
|
||||
Every preparation operation creates one operation-local validation plan. None,
|
||||
basic, and JSON modes retain the effective output contract without source
|
||||
|
||||
@@ -6,59 +6,37 @@ Material: policy documents under `docs/policy/` and the Policy Reading Guide in
|
||||
`docs/development.md`, adapted for this repository; root `AGENTS.md`, copied
|
||||
without modification from upstream `agents/AGENTS.md`.
|
||||
|
||||
The release policy uses upstream commit
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
## ConfigKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.5.0`.
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/LICENSE).
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd).
|
||||
|
||||
## Promptkit OpenRouter Catalog v3
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/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-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0).
|
||||
|
||||
## 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).
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
||||
|
||||
## jsonschema
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ The [internal component overview](../internal/overview.md) owns the inventory
|
||||
of implemented packages and their concrete responsibilities. This policy owns
|
||||
the boundaries and dependency rules those components must preserve.
|
||||
|
||||
The internal engine composes selected catalog and caller ConfigKit inputs once,
|
||||
then assembles the captured definitions, capacity manager, renderer, validator,
|
||||
The internal engine composes caller-selected ConfigKit inputs once, then
|
||||
assembles the captured definitions, capacity manager, renderer, validator,
|
||||
outbound client, and use-case runner. The configuration adapter interprets and
|
||||
validates only the final effective values, including presence, null, and
|
||||
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
|
||||
multiple input and execution boundaries, including execution-setting bounds,
|
||||
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
|
||||
specific to their own boundary.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| 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. |
|
||||
| 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
|
||||
substantive content is available.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 1.4
|
||||
**Base Policy Version:** 1.6
|
||||
|
||||
**Last Updated**: 2026-09-27
|
||||
**Last Updated**: 2026-10-08
|
||||
|
||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -12,6 +12,9 @@ This policy defines a proportionate workflow for validating and publishing an
|
||||
intended source revision under a fresh version. Releases should require little
|
||||
routine intervention, avoid repeated work, and preserve published contents.
|
||||
|
||||
Adopt stronger verification or reproducibility requirements through an explicit
|
||||
project-specific decision justified by the intended audience and support needs.
|
||||
|
||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||
policy and link to it rather than introduce additional release requirements.
|
||||
@@ -59,7 +62,8 @@ requires a deliberate update to the policy and procedure where needed.
|
||||
For releases at or after `v1.0.0`:
|
||||
|
||||
- a major release may make intentional incompatible changes;
|
||||
- a minor release may add backward-compatible behavior; and
|
||||
- a minor release may add backward-compatible behavior or raise the Go consumer
|
||||
minimum as described below; and
|
||||
- a patch release may make backward-compatible fixes.
|
||||
|
||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||
@@ -71,14 +75,44 @@ Additional compatibility promises and deliberate exceptions belong in the
|
||||
adopted policy or canonical contract documentation. Release notes explain
|
||||
changes; they do not redefine current compatibility contracts.
|
||||
|
||||
## Release Documentation
|
||||
## Consumer Minimum And Release Toolchain
|
||||
|
||||
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||
maintainers use to build and validate a release. Record the consumer minimum in
|
||||
the language's standard metadata and explain its support meaning in canonical
|
||||
documentation. Define the selected release toolchain and its setup commands in
|
||||
`docs/release.md`. Toolchain selection and upgrade cadence are repository-specific
|
||||
choices.
|
||||
|
||||
For Go repositories, normally declare the initial stable release of the selected
|
||||
series as the consumer minimum, such as `go 1.26.0`. Use a higher patch minimum
|
||||
when a dependency or an identified correctness fix requires it, and document
|
||||
the reason.
|
||||
|
||||
For release-policy purposes, successful build and release validation on the
|
||||
selected Go patch release are sufficient to presume support across that Go
|
||||
series, subject to the declared consumer minimum and concrete evidence of
|
||||
incompatibility. Investigate specific compatibility concerns as they arise.
|
||||
|
||||
Use the selected release toolchain for candidate validation and release builds,
|
||||
including CI jobs performing those tasks. A newer validation patch version alone
|
||||
leaves the consumer minimum unchanged.
|
||||
|
||||
A minor release may raise the Go consumer minimum, including from one Go series
|
||||
to the next, both before and after `v1.0.0`. This is an explicit exception to the
|
||||
backward-compatibility requirement for minor releases. Patch releases preserve
|
||||
the consumer minimum. Explain an increase's impact and required consumer action
|
||||
in the release note. Other incompatible changes remain subject to the versioning
|
||||
rules above.
|
||||
|
||||
## Release Documentation And Preparation
|
||||
|
||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||
source tree. Use the complete version tag as the filename and begin with a
|
||||
title containing the product name and that exact version.
|
||||
|
||||
Include a short summary and any relevant compatibility impact or upgrade
|
||||
instructions. Use the Promptkit section structure below and keep the note
|
||||
instructions. Choose sections suited to the release and keep the note
|
||||
proportionate to the change.
|
||||
|
||||
Keep exact current contracts in their canonical documents and update affected
|
||||
@@ -186,7 +220,8 @@ release without replacing its contents or reusing its version.
|
||||
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
||||
identify:
|
||||
|
||||
1. prerequisites, primary branch, and authoritative remote;
|
||||
1. prerequisites, selected release toolchain, primary branch, and authoritative
|
||||
remote;
|
||||
2. version and release-note preparation;
|
||||
3. one publication command with its selected local checks and automated guards;
|
||||
4. declared deliverables, required build targets, and automation responsibilities;
|
||||
@@ -210,43 +245,36 @@ annotated stable SemVer tags. It has no binaries, hosted download pages, or
|
||||
asynchronous deliverables. The source commit is pushed through main before the
|
||||
tag. The [release procedure](../release.md) owns commands and recovery.
|
||||
|
||||
The existing publication script requires Summary, Compatibility, Upgrade, and
|
||||
Changes headings beneath a title of `# Promptkit vMAJOR.MINOR.PATCH`. State
|
||||
“No action required” or “None” where appropriate. This retained local structure
|
||||
matches the automated release-note guard; changing it is separate work.
|
||||
Release preparation uses the latest Go installation available in the development
|
||||
environment to build the maintainer release executable and validate the
|
||||
candidate, including the consumer examples. Keep that same local toolchain
|
||||
active throughout those operations.
|
||||
|
||||
The [publication script](../../scripts/release.sh) runs the
|
||||
[local checks](../../scripts/release-check.sh) once for the final committed
|
||||
candidate: race-enabled tests, vet, three offline consumer examples, tracked
|
||||
Go formatting, Markdown links, module/workspace hygiene, whitespace, and a
|
||||
credential-pattern scan. These protect concurrency, assembled library use,
|
||||
source distribution, and documentation integrity. A separate ordinary test run
|
||||
or standalone build is not an additional release gate. Candidate, tag, and
|
||||
synchronized-main checks remain automated. No CI or module-availability wait
|
||||
is required. Licensing footer URLs are preparation work, not a network gate.
|
||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
||||
release of that Go series, retaining a higher patch minimum when required by a
|
||||
dependency or an identified correctness fix. Patch releases preserve the
|
||||
existing consumer minimum. Commit any module update before validating the final
|
||||
candidate. The [release procedure](../release.md#prepare-the-candidate) owns
|
||||
setup commands, and the consumer guide explains
|
||||
[Go compatibility](../consumers/pkg-promptkit.md#go-compatibility).
|
||||
|
||||
## Catalog Profile Compatibility
|
||||
Use a title containing `Promptkit` and the exact version, followed by a short
|
||||
summary and any relevant compatibility impact or upgrade instructions. The
|
||||
note is committed with the candidate and supplies the annotated tag message.
|
||||
No fixed set of section headings is required.
|
||||
|
||||
External catalog profile data may be added, removed, renamed, or modified
|
||||
without deprecation or advance notice. This exception is limited to profile
|
||||
data imported from the maintained catalog modules. It does not weaken ordinary
|
||||
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.
|
||||
The [publication command](../../scripts/release.sh) delegates to the released
|
||||
ReleaseKit tool selected in `go.mod`. The committed
|
||||
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
||||
committed Go formatting, local Markdown links, workspace and filesystem
|
||||
replacement inspection, and the offline semantic checks for the three
|
||||
maintained consumer examples. These are the selected checks for the final
|
||||
candidate; a separate ordinary test run or standalone build is not an
|
||||
additional release gate. The upstream tool owns candidate, remote, and tag
|
||||
publication checks. It returns native status 0 for confirmed publication, 1
|
||||
for a known validation or publication failure, 2 for invalid input or profile,
|
||||
and 3 when the push outcome is unknown. For status 3, resolve the exact
|
||||
captured publication endpoint before retrying; the repository-specific
|
||||
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
||||
instructions. Do not wait for CI or Go module availability. Licensing footer
|
||||
URLs are preparation work, not a network gate.
|
||||
|
||||
@@ -58,17 +58,24 @@ and repository hygiene.
|
||||
Introducing hosted CI later would supplement, not silently redefine, this
|
||||
documented validation model.
|
||||
|
||||
Full release-candidate validation includes race-enabled package tests, explicit
|
||||
static analysis, and execution with automated semantic checks of the three maintained
|
||||
offline consumer examples. The test run builds the packages; the examples
|
||||
also exercise ordinary compilation. Separate ordinary tests and a standalone
|
||||
build are not additional release gates. Ordinary tests remain useful during
|
||||
development. The preparation example protects assembled preparation and
|
||||
The selected ReleaseKit profile runs the race-enabled package suite and full
|
||||
vet, checks committed Go formatting, local Markdown links, and module
|
||||
distribution hygiene, and executes semantic assertions for the three
|
||||
maintained offline consumer examples. ReleaseKit owns this shared validation
|
||||
machinery and publication workflow. The test run builds the packages; the
|
||||
examples also exercise ordinary compilation. Separate ordinary tests and a
|
||||
standalone build are not additional release gates. Ordinary tests remain useful
|
||||
during development. The preparation example protects assembled preparation and
|
||||
inspection behavior. The execution example separately protects assembled
|
||||
`Run`, injected-client, validation, usage, and result behavior. The observation
|
||||
example protects assembled built-in retries, structured proposal and assessment
|
||||
runs, and application-owned interpretation of metadata.
|
||||
|
||||
The standalone `python3 scripts/test-release.py` command exercises Promptkit's
|
||||
ReleaseKit wiring against disposable local repositories. It is focused
|
||||
development validation when release tooling changes, not part of the default
|
||||
Go test suite or a recurring release check.
|
||||
|
||||
Tests in the default suite must be deterministic, offline, and independent of
|
||||
real credentials. They must not invoke paid APIs, use live network
|
||||
dependencies, or depend on mutable external services. Tests that require live
|
||||
|
||||
162
docs/release.md
162
docs/release.md
@@ -1,94 +1,130 @@
|
||||
# Release Procedure
|
||||
|
||||
This runbook implements the [release policy](policy/release.md). A successful
|
||||
push of the selected annotated tag to origin completes the release. Never wait
|
||||
for CI to finish or succeed.
|
||||
This runbook implements the [release policy](policy/release.md). Promptkit
|
||||
publishes source-only Go module tags from `main` to `origin`. A successful push
|
||||
of the selected annotated tag, after its source commit is available on `main`,
|
||||
completes the release. Do not wait for CI or Go module availability.
|
||||
|
||||
## Prepare The Candidate
|
||||
|
||||
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
||||
3. Publication requires push access to origin. Work from the repository root.
|
||||
3. Work from the repository root with push access to `origin`.
|
||||
|
||||
Select an unused version and create `docs/releases/<version>.md` using the
|
||||
[required structure](policy/release.md#release-documentation). Describe
|
||||
compatibility impact and consumer actions and update affected canonical docs.
|
||||
For catalog updates, record selected catalog versions and any consumer action,
|
||||
linking to the catalog policy rather than repeating its contracts.
|
||||
Use the latest Go installation available locally for the release tool, the
|
||||
candidate validation, and the maintained examples. Make it the active `go`
|
||||
command and record its version:
|
||||
|
||||
Follow the [OSS policy](policy/oss.md) when preparing licensing and attribution
|
||||
changes. Each new note must end with the following navigation, replacing
|
||||
`vMAJOR.MINOR.PATCH` with the selected release tag:
|
||||
|
||||
```markdown
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/docs/oss/attribution.md)
|
||||
```sh
|
||||
export GOTOOLCHAIN=local
|
||||
go version
|
||||
```
|
||||
|
||||
Check these file paths against the candidate; a future tag need not resolve
|
||||
remotely during preparation. The annotated tag derives from this same note,
|
||||
including its footer. Promptkit publishes no separate hosted release body or
|
||||
download page. If such a channel is deliberately introduced, reuse the note
|
||||
and preserve its footer. No extra network checks, artifacts, or approval gates
|
||||
are introduced, and published notes are not retrofitted.
|
||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
||||
release of that Go series before committing the candidate:
|
||||
|
||||
Retire completed roadmaps, preserving planned or deferred work and repairing
|
||||
links. This is housekeeping, not a separate approval gate. Review changes
|
||||
through the ordinary development workflow, commit them, and push main to
|
||||
origin. No repeated release-specific diff review is required.
|
||||
```sh
|
||||
release_go_version=$(go env GOVERSION)
|
||||
release_go_series=${release_go_version#go}
|
||||
release_go_series=${release_go_series%.*}
|
||||
go mod edit "-go=${release_go_series}.0"
|
||||
```
|
||||
|
||||
For example, Go 1.26.8 normally results in `go 1.26.0`. Keep a higher patch
|
||||
minimum if a dependency or identified correctness fix requires it. For a patch
|
||||
release, leave the consumer minimum unchanged; using a newer validation
|
||||
toolchain does not itself raise that minimum. If the candidate needs a higher
|
||||
minimum, prepare a minor release instead. See the
|
||||
[consumer-minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||
|
||||
Prepare module caches before validation; this may update `go.sum` and belongs
|
||||
before the candidate commit:
|
||||
|
||||
```sh
|
||||
GOTOOLCHAIN=local go mod download
|
||||
```
|
||||
|
||||
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
||||
title containing `Promptkit` and that exact version, a short summary, and any
|
||||
relevant compatibility impact or upgrade instructions. No fixed section
|
||||
headings are required. Describe a raised Go minimum and its compatibility
|
||||
impact under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
||||
End each new note with the licensing footer below, using the selected version:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
Check the footer paths against the candidate; the future tag need not resolve
|
||||
remotely during preparation. Follow the [OSS policy](policy/oss.md) for these
|
||||
links. Promptkit publishes no binaries, hosted download page, or other
|
||||
asynchronous deliverables.
|
||||
|
||||
Retire completed roadmaps while preserving deferred work and repairing incoming
|
||||
links. Review the intended changes through the ordinary development workflow,
|
||||
commit the candidate on `main`, and push `main` to `origin` before publication.
|
||||
Preparation may update module metadata; the release bootstrap and validation
|
||||
preserve the committed candidate.
|
||||
|
||||
## Validate And Publish
|
||||
|
||||
Run the following with the chosen unused version (replace the placeholder):
|
||||
For an optional local diagnostic of a committed candidate, run:
|
||||
|
||||
```sh
|
||||
./scripts/releasekit.sh check vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
This is not a required step before publication. The upstream
|
||||
[candidate-check contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/consumers/check.md)
|
||||
describes its requirements.
|
||||
|
||||
Run the single publication command with the chosen version:
|
||||
|
||||
```sh
|
||||
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
The [publication script](../scripts/release.sh) checks a clean, synchronized
|
||||
main candidate, the version and unused tag, and the release note's title and
|
||||
required headings. It runs the [local checks](../scripts/release-check.sh)
|
||||
once, rechecks candidate identity and publication state, generates the
|
||||
annotation from the committed note plus the validated commit, and pushes only
|
||||
the selected annotated tag.
|
||||
The familiar wrapper delegates to the native ReleaseKit executable selected in
|
||||
`go.mod`. The committed [profile](../releasekit.json) selects the race-enabled
|
||||
Go suite, full vet, committed Go formatting, local Markdown links, workspace
|
||||
and filesystem replacement inspection, and semantic checks for the three
|
||||
maintained offline consumer examples. ReleaseKit also verifies the committed
|
||||
candidate and publication state, then publishes only the selected tag. Its
|
||||
checks run once for this candidate; do not repeat them as a separate sign-off.
|
||||
|
||||
There is no separate tag-message authoring, example-output inspection,
|
||||
metadata inspection, or human sign-off. Compatibility and release-note content
|
||||
remain ordinary editorial responsibilities. Development validation is useful
|
||||
but does not require a second full run before invoking the publication script.
|
||||
|
||||
The script stops successfully after the tag push. CI completion, CI success,
|
||||
remote read-back, and Go module availability are not release completion gates.
|
||||
Do not wait or poll for them. Provider probes and inference calls are never
|
||||
part of release validation.
|
||||
|
||||
## Catalog Profile Compatibility
|
||||
|
||||
The [release policy](policy/release.md#catalog-profile-compatibility) owns the
|
||||
catalog compatibility boundary. This heading preserves older incoming links.
|
||||
The native command returns status 0 for confirmed publication, 1 for a known
|
||||
validation or publication failure, 2 for invalid input or profile, and 3 when
|
||||
the push outcome remains unknown. Successful publication needs no remote
|
||||
read-back, CI wait, or installation check. Provider probes and inference calls
|
||||
are not part of validation.
|
||||
|
||||
## Failure Recovery
|
||||
|
||||
If local checks fail, fix the candidate, commit and push changes, then rerun
|
||||
the publication command. It never automatically removes a tag.
|
||||
For status 1, use the diagnostic to distinguish a validation failure from a
|
||||
known publication conflict. Correct and commit candidate defects, push `main`,
|
||||
and rerun publication with an unused version when appropriate. Status 2 means
|
||||
the invocation or profile must be corrected.
|
||||
|
||||
If a push fails or its result is ambiguous, set `RELEASE_VERSION` to the
|
||||
attempted version and inspect origin before retrying:
|
||||
For status 3, do not retry or remove the local tag until the remote outcome is
|
||||
resolved. ReleaseKit reports the attempted version, tag object, and recovery
|
||||
state, including the captured publication endpoint when available. Use that
|
||||
endpoint—not merely the current `origin` URL—to query the exact tag; follow the
|
||||
upstream
|
||||
[failure-recovery procedure](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/release.md#resolve-failures):
|
||||
|
||||
```sh
|
||||
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
|
||||
git rev-parse "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
|
||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
||||
releasekit_version='vX.Y.Z'
|
||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
||||
```
|
||||
|
||||
Matching remote tag and peeled commit values confirm publication, even if the
|
||||
push command reported an error. If the remote query fails, publication status
|
||||
is still unknown. Do not delete or recreate the local tag until absence on
|
||||
origin is confirmed. Once confirmed unpublished, an unsuccessful attempt's
|
||||
local tag can be removed and the publication command rerun. Never overwrite
|
||||
a published tag.
|
||||
|
||||
CI or module-resolution problems may be investigated separately; temporary
|
||||
service, network, or authentication failures do not require another release.
|
||||
Only an actual defect requiring changes to published contents warrants a new
|
||||
appropriate version. See the [recovery policy](policy/release.md#immutability-and-failure-recovery).
|
||||
Compare the returned object ID with the reported tag object. An exact match
|
||||
confirms publication. A missing ref confirms absence only when the endpoint
|
||||
identity check and query both succeed. If either check fails, the outcome
|
||||
remains unknown; correct the transport configuration and resolve it before
|
||||
retrying. Only after authoritative absence is confirmed may an unpublished
|
||||
local tag be removed before correcting the candidate or retrying. Never move,
|
||||
overwrite, or delete a published tag.
|
||||
|
||||
## Policy Changes
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ the existing Go API. This refactor requires no consumer code changes.
|
||||
|
||||
This release selects OpenRouter `v2.20260922.1` and Rakestrawhome
|
||||
`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
|
||||
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
|
||||
aliases and need not preserve the previous settings.
|
||||
|
||||
See the [release policy](../release.md#catalog-profile-compatibility) for the
|
||||
compatibility boundary, the [format reference](../formats.md#built-in-profile-catalog)
|
||||
for current Promptkit-owned definitions and selected external inventories, and
|
||||
See the [release policy](../policy/release.md#versioning-and-compatibility) for the
|
||||
compatibility boundary, the [migration identity mapping](../consumers/configuration-migration.md#identity-mapping)
|
||||
for current resource mappings, and
|
||||
the [consumer guide](../consumers/pkg-promptkit.md#choose-a-profile-ownership-model)
|
||||
for configuration choices.
|
||||
|
||||
@@ -72,5 +72,5 @@ for a structured proposal-and-assessment workflow.
|
||||
|
||||
Backend catalog selections are unchanged from v0.10.0: OpenRouter
|
||||
`v2.20260922.1` and Rakestrawhome `v2.20260922.0`. See the
|
||||
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility)
|
||||
and [profile inventories](../formats.md#external-profile-inventories).
|
||||
[release compatibility policy](../policy/release.md#versioning-and-compatibility)
|
||||
and [migration identity mapping](../consumers/configuration-migration.md#identity-mapping).
|
||||
|
||||
@@ -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.
|
||||
See the [project grant](../../COPYRIGHT),
|
||||
[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
|
||||
|
||||
|
||||
74
docs/releases/v0.12.0.md
Normal file
74
docs/releases/v0.12.0.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Promptkit v0.12.0
|
||||
|
||||
This release replaces Promptkit's configuration loader with ConfigKit and
|
||||
migrates maintained catalogs to keyed `/v3` assets. It intentionally changes
|
||||
the public construction API and configuration format.
|
||||
|
||||
## Summary
|
||||
|
||||
Backends, profiles, and versioned prompts now share one layered configuration
|
||||
snapshot built when `NewEngine` is called. ConfigKit supplies composition,
|
||||
inheritance, presence, and source history; Promptkit validates the final
|
||||
effective definitions and captures prompt content and templates. Consumers can
|
||||
inspect the captured configuration explicitly without triggering a reload.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is an incompatible minor release under Promptkit's pre-1.0
|
||||
[versioning policy](../policy/release.md#versioning-and-compatibility). The
|
||||
former source options, `Config.PromptDir`, `Config.ProfileDir`, and the old
|
||||
profile/backend convenience values have been removed. Configuration documents
|
||||
now use literal keys beneath `backends`, `profiles`, and `prompts`; embedded
|
||||
`id`/`version` fields and `base_profile` are no longer accepted. Source groups
|
||||
compose strictly within a group and overlay in the supplied order. Explicit
|
||||
zero, false, empty, and null values can now differ from omission; maps overlay
|
||||
recursively, and an empty map does not clear an inherited map.
|
||||
|
||||
All final definitions, including unused ones, are validated during engine
|
||||
construction. Prompt text and static templates are captured then; an existing
|
||||
engine does not automatically reload changed configuration or message files.
|
||||
Request artifacts, schemas, credentials, and provider operations retain their
|
||||
runtime boundaries. See the [format reference](../formats.md) for the current
|
||||
configuration contract and the [consumer migration guide](../consumers/pkg-promptkit.md#migrate-from-the-previous-configuration-api)
|
||||
for API and source-selection changes.
|
||||
|
||||
Maintained OpenRouter and Rakestrawhome catalogs are selected as `/v3` modules,
|
||||
both at `v3.20260930.0`. Their asset format changed; consumers that read catalog
|
||||
assets directly must adopt the keyed format. The maintained Promptkit profile
|
||||
IDs remain available under the [release compatibility policy](../policy/release.md#versioning-and-compatibility),
|
||||
which does not promise fixed profile data.
|
||||
|
||||
## Upgrade
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/promptkit@v0.12.0
|
||||
```
|
||||
|
||||
Replace removed source and backend registration options with
|
||||
`Config.Sources`, explicitly ordered ConfigKit underlays, and optionally a
|
||||
highest-priority main input. Use `DirectoryInputs` or `FilesystemInputs` for
|
||||
selected directories, `ProfileSource` and `BackendSource` for typed
|
||||
programmatic values, or `configkit.Mapping` for explicit null and combined
|
||||
documents. Move profile `base_profile` to `extends` and embedded identities to
|
||||
mapping keys. Review zero/false/null values and any assumptions that replacing
|
||||
a model or backend discards unrelated explicit settings. Rebuild the engine
|
||||
when definitions or referenced message files change. The
|
||||
[consumer guide](../consumers/pkg-promptkit.md#migrate-from-the-previous-configuration-api)
|
||||
provides the migration details and maintained examples.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add ConfigKit v0.5.0 as the configuration boundary and consume released
|
||||
OpenRouter and Rakestrawhome `/v3` catalog modules.
|
||||
- Add keyed backend, profile, and prompt definitions with final-value domain
|
||||
validation, explicit presence/null behavior, and source-relative content
|
||||
capture.
|
||||
- Add `ConfigurationSources`, directory discovery helpers, programmatic
|
||||
profile/backend source builders, and read-only configuration inspection.
|
||||
- Preserve execution workflows while rendering captured templates, applying
|
||||
backend defaults after inheritance, and honoring explicit suppression of
|
||||
provider settings.
|
||||
- Remove superseded loader, catalog preflight, and inheritance machinery;
|
||||
update examples, format guidance, and internal documentation.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.12.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.12.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.12.0/docs/oss/attribution.md)
|
||||
69
docs/releases/v0.13.0.md
Normal file
69
docs/releases/v0.13.0.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Promptkit v0.13.0
|
||||
|
||||
This release adds prompt requirement discovery and profile/prompt listing,
|
||||
reduces avoidable configuration and execution allocations, and standardizes
|
||||
maintainer publication through ReleaseKit. The minimum supported Go version
|
||||
increases to 1.26.0.
|
||||
|
||||
## Discover Requirements And Available Definitions
|
||||
|
||||
- `Engine.InspectRequirements` reports recognizable input and caller-variable
|
||||
dependencies for a selected prompt and supplied caller context before input
|
||||
artifacts exist. It classifies dependencies as required, conditional, or
|
||||
optional and reports structured limitations when discovery is incomplete.
|
||||
Discovery coverage is not proof that requirements are satisfied or execution
|
||||
will succeed. See the [requirement inspection guide](../consumers/pkg-promptkit.md#inspect-requirements-before-preparation)
|
||||
and [GoDoc](../../requirements.go).
|
||||
- `Engine.ListProfiles` returns effective profile IDs, and `Engine.ListPrompts`
|
||||
returns exact ID/version pairs as `PromptIdentity` values. Lists come from the
|
||||
engine's captured configuration, are lexically ordered and caller-owned, and
|
||||
include consumer definitions and maintained profiles that remain effective.
|
||||
Listing performs no provider or execution work. See the
|
||||
[discovery guide](../consumers/pkg-promptkit.md#discover-available-resources-and-collections)
|
||||
and [GoDoc](../../listing.go).
|
||||
|
||||
## Efficiency And Tooling
|
||||
|
||||
- Reduce response-sized copies in basic validation and rendered-message output,
|
||||
and encode valid provider extra parameters only as part of the complete
|
||||
request rather than serializing them twice.
|
||||
- Use bounded initial file-size reservations without limiting accepted content,
|
||||
releasing excess backing storage for small or empty completed artifacts.
|
||||
- Reuse owned preparation values when creating prepared-execution snapshots,
|
||||
while preserving independent inspection details and caller-owned data.
|
||||
- Use lightweight captured profile identities and prompt metadata for inspection
|
||||
instead of copying execution targets and definitions unnecessarily.
|
||||
- Upgrade ConfigKit to v0.6.0 for more efficient loading, resolution, metadata,
|
||||
and inspection processing. Configuration semantics remain unchanged; these
|
||||
improvements primarily benefit construction and explicit operator inspection,
|
||||
not upstream inference latency.
|
||||
- Adopt module-selected ReleaseKit v0.3.0 for maintainer validation and tag
|
||||
publication. The [release procedure](../release.md) owns the current commands
|
||||
and recovery workflow; successful tag publication remains the completion
|
||||
point, without waiting for CI.
|
||||
|
||||
These changes preserve existing execution and ownership contracts. Benefits
|
||||
depend on workload; this release introduces no fixed performance guarantee.
|
||||
|
||||
## Compatibility And Upgrade
|
||||
|
||||
Consumers upgrading from v0.12.0 need **Go 1.26.0 or newer**, rather than the
|
||||
previous 1.25.5 minimum. Update application build environments and any CI images
|
||||
still using Go 1.25 before upgrading:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/promptkit@v0.13.0
|
||||
```
|
||||
|
||||
No application-code or configuration migration is required from v0.12.0 beyond
|
||||
the Go toolchain update. The new discovery methods are optional additions;
|
||||
existing construction, inspection, preparation, and execution workflows remain
|
||||
available. See [Go compatibility](../consumers/pkg-promptkit.md#go-compatibility)
|
||||
for the distinction between the consumer minimum and maintainer toolchain.
|
||||
|
||||
Maintainers should follow the current release runbook rather than the retired
|
||||
`scripts/release-check.sh`. The publication entry point remains
|
||||
`scripts/release.sh`; ReleaseKit now owns its shared validation and publication
|
||||
mechanics.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.13.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.13.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.13.0/docs/oss/attribution.md)
|
||||
48
docs/releases/v0.14.0.md
Normal file
48
docs/releases/v0.14.0.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Promptkit v0.14.0
|
||||
|
||||
This release adds comparison of captured configuration inspections and updates
|
||||
ConfigKit and the maintainer release tool. The minimum supported Go version
|
||||
remains 1.26.0.
|
||||
|
||||
## Compare Captured Configurations
|
||||
|
||||
`ConfigurationInspection.Diff` compares the effective configuration captured by
|
||||
two engines. Consumers can use it to review a candidate configuration, inspect
|
||||
an overlay's effect, or present configuration changes to operators.
|
||||
|
||||
Comparison delegates to ConfigKit and returns its difference records, including
|
||||
presence information, paired redaction, and protected-change indicators. It
|
||||
does not reload sources or contact providers. Source-history differences alone
|
||||
do not produce value differences.
|
||||
|
||||
An empty diff is not proof of equivalent execution: referenced file contents,
|
||||
resolved credentials, later execution preparation, and request-specific
|
||||
overrides are outside the comparison. Records can contain ordinary application
|
||||
data; callers retain responsibility for their disclosure.
|
||||
|
||||
See the [configuration comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||
and [GoDoc](../../configuration_inspection.go) for usage and the exact contract.
|
||||
|
||||
## Dependency And Tooling Updates
|
||||
|
||||
- Upgrade ConfigKit from v0.6.0 to v0.7.1, providing the upstream comparison API.
|
||||
v0.7.1 also updates ConfigKit's maintainer tooling without changing its runtime
|
||||
behavior from v0.7.0.
|
||||
- Upgrade the module-selected ReleaseKit tool from v0.3.0 to v0.4.1. Promptkit
|
||||
retains its existing root-module profile, publication entry point, and release
|
||||
gates. See the [release procedure](../release.md) for maintainer commands.
|
||||
|
||||
## Compatibility And Upgrade
|
||||
|
||||
No application-code, configuration, or Go-toolchain migration is required from
|
||||
v0.13.0. Configuration comparison is an optional API addition; existing
|
||||
construction, inspection, preparation, and execution workflows remain available.
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/promptkit@v0.14.0
|
||||
```
|
||||
|
||||
See [Go compatibility](../consumers/pkg-promptkit.md#go-compatibility) for the
|
||||
distinction between the consumer minimum and maintainer toolchain.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.14.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.14.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.14.0/docs/oss/attribution.md)
|
||||
34
docs/releases/v0.15.0.md
Normal file
34
docs/releases/v0.15.0.md
Normal 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
86
docs/releases/v0.16.0.md
Normal 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)
|
||||
@@ -63,8 +63,8 @@ Every engine now includes the reserved `rakestrawhome` backend, identified by
|
||||
selects that backend. Consumers can use the maintained endpoint, credential,
|
||||
capacity, and model defaults without registering either definition themselves.
|
||||
|
||||
See the [built-in backend and profile catalogs](../formats.md#built-in-backends)
|
||||
and the [consumer adoption example](../consumers/pkg-promptkit.md#use-the-rakestrawhome-built-in-profile)
|
||||
See the [backend format reference](../formats.md#backend-definitions)
|
||||
and the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping)
|
||||
for the current contracts.
|
||||
|
||||
## Structured Generation Errors
|
||||
@@ -127,7 +127,7 @@ freezes the fully resolved target. The selected leaf ID remains public while
|
||||
effective execution settings reflect the resolved chain.
|
||||
|
||||
See the [profile inheritance format reference](../formats.md#profile-inheritance),
|
||||
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
|
||||
behavior.
|
||||
|
||||
|
||||
@@ -96,9 +96,8 @@ precedence rules. Promptkit no longer keeps duplicate embedded profile assets
|
||||
or hard-coded definitions for those maintained backends.
|
||||
|
||||
The module versions in Promptkit's `go.mod` identify the catalog releases
|
||||
tested with this release. The [built-in backend and profile format
|
||||
reference](../formats.md#built-in-backends) remains the canonical consumer
|
||||
contract, while the [internal source documentation](../internal/sources.md#profiles-and-built-ins)
|
||||
tested with this release. The [backend format reference](../formats.md#backend-definitions)
|
||||
remains the canonical consumer contract, while the [internal source documentation](../internal/sources.md#profiles)
|
||||
describes the dependency boundary.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
# ConfigKit Adoption Assessment
|
||||
|
||||
Status: assessment aligned to released ConfigKit v0.5.0. Upstream capability
|
||||
prerequisites are available; PromptKit integration and migration decisions remain
|
||||
for the feature roadmap and implementation plan. No runtime changes are part of
|
||||
this assessment.
|
||||
|
||||
## Goal And Recommendation
|
||||
|
||||
Use ConfigKit to standardize PromptKit's configuration semantics and simplify
|
||||
its loading machinery, improving ConfigKit where a reusable capability is
|
||||
justified. Preserve substantive consumer capabilities, not historical loading
|
||||
sequences, file syntax, or Go signatures. Document intentional breaking changes
|
||||
and their migration paths.
|
||||
|
||||
Recommend adoption through an eager, immutable definition-loading boundary.
|
||||
ConfigKit should own YAML interpretation, source-tier combination, strict typed
|
||||
decoding, sibling inheritance, presence, and source diagnostics. PromptKit should
|
||||
own definition selection, LLM-specific rules, and execution. Do not retain the lazy loading
|
||||
architecture merely to minimize migration, or introduce a second generic
|
||||
configuration framework inside PromptKit.
|
||||
|
||||
The expected benefits are consistent configuration semantics for applications
|
||||
using both libraries and removal of repeated discovery, partial metadata parsing,
|
||||
lazy error arbitration, and repeated profile-chain resolution. Some of those
|
||||
savings come from the loading redesign rather than ConfigKit itself. ConfigKit
|
||||
adds ongoing value by owning the generic decoding behavior and its tests.
|
||||
|
||||
## Released ConfigKit Capabilities
|
||||
|
||||
ConfigKit v0.5.0 satisfies the two requested prerequisites and also implements
|
||||
sibling inheritance. Adopt the released public contracts rather than waiting for
|
||||
the earlier proposals to be implemented:
|
||||
|
||||
1. **Filesystem sources:** `NewFilesystem(label, fsys).File(name)` borrows an
|
||||
`fs.FS` with reusable namespace identity. Imports and resource contexts retain
|
||||
that namespace. `Result.SourceContext` and `Resolution.SourceContext` expose
|
||||
passive `ResourceContext` values with `FS` and `BaseDir`.
|
||||
2. **Source-backed underlays:** `LoadLayered` accepts a main `Input`
|
||||
and ordered `Underlay` groups. Each group composes strictly; groups overlay
|
||||
recursively from lowest to highest priority. `Input{Source: NoSource()}` omits
|
||||
the main input. `Mapping(label, map[string]any)` snapshots supported built-in
|
||||
scalar/list/map values with explicit presence; it does not ingest arbitrary
|
||||
typed structs. Generic programmatic input is not represented as CLI overrides.
|
||||
3. **Sibling inheritance:** the reserved `extends` key names one immediate sibling
|
||||
mapping. Relationships resolve after precedence assembly and before typed
|
||||
decoding, with inherited origins, history, and resource context preserved.
|
||||
This is built-in behavior, not the opt-in/registered-collection API originally
|
||||
suggested by PromptKit.
|
||||
|
||||
Adopt ConfigKit's recursive combination model rather than preserving PromptKit's
|
||||
whole-profile shadowing or atomic provider-map replacement across source tiers.
|
||||
No hidden replacement or inheritance pass should run after ConfigKit loading.
|
||||
Use released sibling inheritance for profiles, leaving domain validation in
|
||||
PromptKit. This assessment does not authorize code changes in either repository.
|
||||
|
||||
## Evidence And Scope
|
||||
|
||||
PromptKit's runtime baseline remains v0.11.2; this assessment has since received
|
||||
documentation-only revisions. Updated upstream review on 2026-09-30:
|
||||
|
||||
- PromptKit: `12d6d80024e80c552d122a076c22429bf6f2f6c2` (v0.11.2).
|
||||
- ConfigKit: `dc09959cca4e105f49ec316c6d4ba585bd2340bd` (v0.5.0), verified
|
||||
against the published tag. Its clean checkout passed `go test ./...` during
|
||||
the release review. This replaces the earlier v0.4.2 and uncommitted-roadmap
|
||||
comparison as the upstream basis for adoption.
|
||||
|
||||
PromptKit evidence: [consumer workflows](../consumers/pkg-promptkit.md),
|
||||
[file formats](../formats.md), [source implementation](../internal/sources.md),
|
||||
[runner](../internal/runner.md), and the implementation owners identified below.
|
||||
ConfigKit evidence at that release: `docs/config.md`, `docs/releases/v0.5.0.md`,
|
||||
`layered.go`, `source.go`, `resource_context.go`, and ADR 0009 in the sibling
|
||||
checkout. That checkout is review context, not a build dependency or required
|
||||
directory layout. The released configuration contract owns exact upstream behavior;
|
||||
the summaries here identify its consequences for the proposed PromptKit design.
|
||||
|
||||
This is a source-and-contract assessment, not a performance benchmark or full
|
||||
correctness audit. It does not claim measured allocation, latency, or line-count
|
||||
savings. Current code and canonical contracts remain authoritative until an
|
||||
approved implementation changes them.
|
||||
|
||||
## 1. Capability Inventory
|
||||
|
||||
| Preserve the ability to… | Replaceable mechanics and representation |
|
||||
| --- | --- |
|
||||
| Define named, versioned prompts, inspect their declared inputs, and select a version explicitly | Directory scans on each operation, YAML metadata prepasses, filename layout, constructor signatures |
|
||||
| Use inline and file-backed message templates, variables, input helpers, session templates, roles, and cache control | Timing of definition/content reads and private repository interfaces |
|
||||
| Configure named profiles, alias/inherit another profile, and override selected settings | Zero-as-absence fields, per-call chain traversal, raw YAML decoding types |
|
||||
| Supply embedded application defaults, operator definitions, maintained catalogs, and programmatic profiles | Repository-wrapper stacks and exact source-option API |
|
||||
| Register custom/local backends, use built-ins or endpoint-only profiles, and control backend capacity | Configuration DTO shapes and registry assembly order |
|
||||
| Prepare, inspect, execute, or freeze an execution for later single-claim execution | Source reloading during ordinary preparation; exact preparation error ordering |
|
||||
| Override per-run settings, credentials, reasoning, session, and output contract; append rendered messages | Public value signatures if presence or clearer configuration separation requires revision |
|
||||
| Supply inline/file artifacts and inject artifact readers, model clients, and HTTP clients | Assembly APIs; runtime collaborators need not be YAML-decodable |
|
||||
| Validate output, use provider-native structured output, bounded repair, transport retries, cancellation, and observation | Internal wiring, not the substantive execution behaviors |
|
||||
| Receive results, usage, identities, hashes, validation outcomes, and inspectable failures | Configuration error types and when definition failures appear |
|
||||
|
||||
Preserve independent profile and prompt inspection as useful operations. They
|
||||
need not retain the old guarantee that engine construction has never examined
|
||||
other definitions. Preserve custom backend identity and capacity behavior,
|
||||
optional upstream credentials, and the distinction between failed generated
|
||||
content and operational errors. This project does not authorize silently
|
||||
dropping execution features to simplify the configuration layer.
|
||||
|
||||
Add explicit configuration inspection for consumers implementing operator-facing
|
||||
CLI commands or debug output. This is a new capability motivated by this adoption,
|
||||
not an assertion that the current prompt/profile inspection methods already
|
||||
provide configuration provenance.
|
||||
|
||||
## 2. Responsibility Mapping
|
||||
|
||||
| Concern | Recommended owner and disposition |
|
||||
| --- | --- |
|
||||
| YAML grammar, scalar interpretation, one-document enforcement, unknown fields, typed ranges, null legality | Delegate to ConfigKit; remove corresponding PromptKit parser logic and redundant parser tests |
|
||||
| Field presence, source coordinates, and configuration provenance | Delegate to ConfigKit; retain its inspection state with the loaded snapshot for explicit operator queries, separate from execution-ready values |
|
||||
| Operator-facing configuration inspection | Provide a PromptKit consumer API reusing ConfigKit inspection facilities; applications own CLI commands, presentation, and disclosure policy |
|
||||
| File/directory/embedded source selection and definition ID indexing | Retain a small PromptKit collection loader; ConfigKit loads documents, not a prompt catalog |
|
||||
| Configuration filesystem sources, relative imports, and generic layer origins | Delegate to the prerequisite ConfigKit APIs; do not maintain parallel reader-based loading machinery |
|
||||
| Definition discovery and relative message-content references | Retain PromptKit's domain resource adapters, using ConfigKit source context and standard `io/fs` facilities where sufficient |
|
||||
| Profile-source priority and combination | PromptKit selects tiers; ConfigKit owns strict within-tier composition and recursive cross-tier overlay; eliminate repository fallback and whole-definition shadowing |
|
||||
| Named profile inheritance | Delegate sibling graph resolution, combination, and derivation to ConfigKit's `extends`; remove the custom profile resolver |
|
||||
| Backend selection and domain normalization | Retain PromptKit domain rules; use recorded application resolution for configuration transformations not already handled by structural inheritance |
|
||||
| LLM setting bounds, role/session normalization, output rules, reserved provider fields | Retain shared PromptKit domain owners; ConfigKit validation callbacks may invoke them |
|
||||
| Definition defaults | Use ConfigKit defaults where they do not erase inheritance presence; apply effective execution defaults after inheritance |
|
||||
| Programmatic configuration | Prefer labeled mapping input with explicit presence; evaluate whether typed PromptKit convenience constructors justify conversion code, without YAML round trips or zero-value inference |
|
||||
| Runtime clients, observers, artifact readers, request data | Retain normal Go injection and request APIs; do not load them through ConfigKit |
|
||||
| Credentials | Retain optional environment-name and direct request-key semantics; do not replace these with ConfigKit's required typed secret resolution |
|
||||
| Catalog manifest, ownership, reserved names, secret-safety checks | Retain the catalog adapter; reuse the common ConfigKit profile decoder |
|
||||
| JSON Schema resources and generated-output validation | Retain JSON Schema implementation; these are not application configuration documents |
|
||||
| Application imports, CLI overrides, deployment overlays, secrets-directory bootstrap | Leave with consuming applications unless a concrete PromptKit need is accepted |
|
||||
| Generic source/decoding diagnostics | Reuse ConfigKit; add only PromptKit identity/context and domain relationship diagnostics |
|
||||
|
||||
## 3. Recommended Target Loading Model
|
||||
|
||||
### Definition Snapshot
|
||||
|
||||
Create one immutable definition snapshot during engine construction. Discover
|
||||
the explicitly supplied sources, assemble their contributions through ConfigKit,
|
||||
decode the effective configuration, normalize domain values, and resolve relationships
|
||||
before publishing the engine. No provider call or credential availability check
|
||||
belongs in this phase. Publish no partially initialized engine on failure.
|
||||
|
||||
Prefer keyed collections, such as `profiles.<name>`, assembled before one typed
|
||||
loading lifecycle instead of independently decoding each profile and recombining
|
||||
the results. Multiple files can contribute to those collections; this does not
|
||||
require a monolithic file. Reconsider the previous one-typed-document-per-definition
|
||||
recommendation. Settle prompt ID/version representation and catalog format migration
|
||||
in the feature roadmap. Support OS directories, individual files, embedded/custom
|
||||
`fs.FS` sources, and programmatic input through the new source model; source selection
|
||||
and definition discovery remain PromptKit concerns, not a second composition engine.
|
||||
|
||||
ConfigKit expands structural inheritance before typed defaults/decoding. Perform
|
||||
any remaining domain-specific resolution through its recorded lifecycle before
|
||||
semantic validation and result publication. Reuse its presence and provenance;
|
||||
do not add a generic merge/defaulting pass or reconstruct origins after loading.
|
||||
Runtime client assembly and resource interpretation remain outside typed decoding.
|
||||
|
||||
Load and freeze referenced message text with definitions. Validate static prompt
|
||||
structure and template syntax at loading, using the same template function setup
|
||||
as execution; render only when request variables and artifacts exist. Preserve
|
||||
source-relative content resolution and containment without expanding the threat
|
||||
model. A snapshot is a set of captured reads, not an atomic filesystem transaction.
|
||||
|
||||
Resolve each effective content reference using its contributing source's context,
|
||||
not the highest tier's directory. Use ConfigKit's released direct context
|
||||
query rather than a consumer-maintained filesystem registry. Composite origins
|
||||
must not be assigned an arbitrary directory; ambiguous or absent context needs
|
||||
an actionable resource error or explicit consumer-supplied context. ConfigKit
|
||||
does not snapshot external text: PromptKit opens it explicitly and freezes it
|
||||
before engine publication. Caller-owned filesystem lifetimes remain caller-owned;
|
||||
close resource handles acquired by PromptKit without retaining ConfigKit load handles.
|
||||
|
||||
Keep request artifacts and schema compilation at preparation. A request can
|
||||
replace the output contract, so an unused default schema should not need eager
|
||||
compilation. Prepared execution continues to freeze its effective resources.
|
||||
The snapshot covers definitions/message text, not environment values, request
|
||||
files, every schema file, or execution state.
|
||||
|
||||
### Composition And Validation
|
||||
|
||||
Use explicit source categories in the existing useful priority order:
|
||||
programmatic profiles, operator source, application fallback, PromptKit-owned
|
||||
profiles, then external catalogs. Express these as explicit ordered tiers, not
|
||||
source discovery order. Within a tier, mappings compose recursively and overlapping
|
||||
non-mapping contributions conflict, even when equal. Disjoint contributions to
|
||||
the same profile may therefore combine; do not preserve a blanket duplicate-ID
|
||||
rejection. Across tiers, mappings overlay recursively, retaining omitted lower
|
||||
children. Effective prompt identities/versions must remain addressable, with an
|
||||
omitted version ambiguous when multiple versions exist; finalize their keyed
|
||||
representation without reintroducing whole-definition shadowing.
|
||||
|
||||
All sources undergo syntax, acquisition, and composition checks; higher tiers
|
||||
cannot hide those failures. Decode and validate effective values after assembly,
|
||||
not every intermediate tier against the final typed schema. A replaced lower
|
||||
value may consequently have a type that would not fit the final schema. An unused
|
||||
invalid effective definition still fails construction. Catalog ownership,
|
||||
reserved-name, and secret-safety checks remain distinct source-policy requirements,
|
||||
not a reason to impose intermediate typed validation on ordinary inputs.
|
||||
|
||||
Preserve profile templates that become usable through descendants or runtime
|
||||
overrides: eager loading must not require every consumer profile to be an
|
||||
independently runnable endpoint/model pair. Validate supplied fields and graph
|
||||
integrity eagerly (ConfigKit handles missing bases and cycles), and check final execution
|
||||
requirements after runtime selection/overrides. Prompt default-profile references
|
||||
can be checked against the assembled collection; a prompt with no default remains
|
||||
valid for callers that supply a profile explicitly.
|
||||
|
||||
ConfigKit's ordering is typed defaults, source-underlay tiers, strictly composed
|
||||
main input, selected overlay, then CLI overrides. PromptKit's API must map its
|
||||
source priorities explicitly onto supported roles; do not mislabel programmatic
|
||||
input as CLI flags. Underlay-only loading avoids inventing a main document.
|
||||
Imports remain opt-in, and the named-overlay convenience need not be exposed by
|
||||
PromptKit merely because ConfigKit supports it.
|
||||
|
||||
A prompt's `version` is a domain identifier, not automatically a ConfigKit
|
||||
application-schema version. Do not configure `VersionRule` for prompt versions.
|
||||
If a separate configuration schema version is later needed, respect ConfigKit's
|
||||
designated main-input ownership and pre-underlay check timing; do not assume
|
||||
last-wins version selection across catalogs.
|
||||
|
||||
### Source Presence And Released Sibling Inheritance
|
||||
|
||||
Adopt ConfigKit source-layer semantics: absence retains lower input; explicit
|
||||
zero, false, and empty strings replace it; mappings combine recursively; empty
|
||||
mappings retain lower children; sequences replace as units; null explicitly
|
||||
replaces, subject to final schema legality. No reset/delete operator is assumed.
|
||||
Preserve presence through domain resolution, backend/framework
|
||||
default application, request overrides, and outbound request construction.
|
||||
Otherwise accepting `temperature: 0` during decoding would accomplish nothing.
|
||||
|
||||
Use ConfigKit metadata for both file and programmatic input. A final Go value
|
||||
alone does not distinguish absent from explicit zero. Avoid eagerly defaulting
|
||||
profile fields in ways that erase that distinction before relationship resolution.
|
||||
|
||||
Provider `extra_params` maps combine recursively across source tiers too: `{}`
|
||||
does not clear lower keys. Retire the proposed atomic source-map replacement and
|
||||
null-as-unspecified conventions; do not emulate them after loading. An operator
|
||||
overriding only `model` also retains a lower `extends` declaration and other
|
||||
omitted settings. Diagnose and document these consequences explicitly.
|
||||
|
||||
Replace `base_profile` with `extends` on sibling profile mappings. A string names
|
||||
one exact immediate sibling; null cancels the relationship while preserving the
|
||||
child's own settings. Omission retains a lower-tier directive. There is no ancestor
|
||||
scope search or cross-collection reference. Superseded references are history,
|
||||
not active relationships to validate; every effective relationship resolves,
|
||||
including profiles not selected for execution.
|
||||
|
||||
Children recursively overlay their resolved parents with the same value rules:
|
||||
zero/false/empty strings and sequences/null replace, mappings combine, and `{}`
|
||||
does not clear inherited keys. Inherited values count as explicit input for
|
||||
presence queries and absence-based defaults, even when false, zero, or null.
|
||||
Do not repeat inheritance in PromptKit's application-resolution callback. Use
|
||||
`Origin.Inheritance` and retained history for explicit operator inspection.
|
||||
Inherited resource references retain their declaring source's context.
|
||||
|
||||
Enclosing inheritance resolves before nested relationships; nested inherited
|
||||
directives resolve at each destination against its siblings. Defaults and
|
||||
application-derived values do not trigger another inheritance pass. Model this
|
||||
deliberately in PromptKit configuration rather than promising arbitrary graph
|
||||
resolution over runtime values. Partial bases must fit the typed schema and
|
||||
PromptKit's validation; ConfigKit grants them no special validation exemption.
|
||||
|
||||
`extends` is reserved in every input mapping, including provider-parameter maps,
|
||||
across all source kinds. There is no opt-in switch or ordinary-data escape in the
|
||||
released contract. Audit catalog and consumer payload needs before finalizing
|
||||
their format; a literal provider field with that name cannot silently pass through
|
||||
the configuration loader. Do not invent an encoding workaround in this assessment.
|
||||
|
||||
The existing request-level override API remains a separate runtime boundary.
|
||||
The feature roadmap must review its interaction with configured values without
|
||||
silently subjecting runtime request data to configuration inheritance directives.
|
||||
|
||||
### Reloading, Errors, And Application Integration
|
||||
|
||||
Later definition edits take effect through a newly constructed engine. Do not add
|
||||
watchers, mutable registry reloads, or a second lazy compatibility mode. Applications
|
||||
choose how to swap engines and let in-flight work finish. Old prepared handles
|
||||
remain associated with their original engine. Simultaneously active engines have
|
||||
separate backend capacity pools; migration guidance must not imply a global limit
|
||||
across a reload overlap.
|
||||
|
||||
Return configuration failures from construction with file/path/location and
|
||||
useful definition identity where available. Recommend preserving ConfigKit's
|
||||
public `LoadError` through wrapping for `errors.As`, together with a PromptKit
|
||||
configuration category; do not copy its entire diagnostic API into the facade.
|
||||
Domain-only relationship failures remain PromptKit errors with relevant source
|
||||
locations. Start with deterministic fail-fast loading across files and ConfigKit's
|
||||
existing multi-diagnostic behavior within a load, rather than inventing a batch
|
||||
diagnostic subsystem. Do not expose raw credentials or add implicit logging.
|
||||
|
||||
Runtime failures continue to distinguish invalid requests, missing selections,
|
||||
rendering/artifact/schema errors, provider/transport errors, capacity rejection,
|
||||
and content-validation results. Cancellation remains effective at
|
||||
PromptKit-controlled boundaries; ConfigKit's current synchronous `Load` has no
|
||||
context argument, so do not promise interruption inside it or create goroutines
|
||||
to simulate that guarantee.
|
||||
|
||||
Applications using ConfigKit can present the same underlying loading diagnostics
|
||||
for their application settings and PromptKit files. They can also translate
|
||||
application-owned typed settings into PromptKit profiles/backends. Do not require
|
||||
applications to encode Go clients into YAML or adopt ConfigKit merely to call
|
||||
PromptKit's runtime API. The operator-inspection capability below supplies the
|
||||
concrete consumer purpose for exposing effective configuration and its provenance.
|
||||
|
||||
### Explicit Configuration Inspection
|
||||
|
||||
Provide a consumer-facing API for inspecting effective configuration and its
|
||||
provenance so applications can implement targeted CLI commands or debug output.
|
||||
Keep three activities distinct:
|
||||
|
||||
- **Construction:** load and resolve configuration, retain ConfigKit's provenance
|
||||
and inspection state, and publish execution-ready definitions. Resolve any
|
||||
presence and origin information needed for definition construction here.
|
||||
- **Ordinary execution:** use the ready definitions directly. Do not analyze
|
||||
configuration provenance, traverse origin/history records, reconstruct
|
||||
inheritance explanations, or render effective-configuration reports as part of
|
||||
a prompt run. Carry execution-relevant presence explicitly in resolved values
|
||||
rather than querying configuration metadata during each run.
|
||||
- **Explicit inspection:** query effective settings and their contributing
|
||||
sources or explanations only when a consumer invokes the inspection API.
|
||||
Inspection uses the same loaded configuration snapshot as the engine; it does
|
||||
not reread configuration files, open referenced resources, or contact providers.
|
||||
|
||||
Reuse ConfigKit's inspection and redaction facilities rather than maintaining a
|
||||
second provenance system. The API should make targeted queries practical; exact
|
||||
signatures, query scope, and safe result ownership belong in the feature roadmap.
|
||||
Do not imply that configuration inspection predicts all runtime request overrides
|
||||
or reveals the values of execution-time credentials. Existing prompt/profile
|
||||
inspection, prepared-run details, and execution observations have distinct purposes;
|
||||
configuration inspection does not replace them or add provenance to run events.
|
||||
|
||||
Applications decide whether and how to expose inspection through operator tools.
|
||||
PromptKit adds no CLI, implicit debug logging, or automatic explanation output.
|
||||
Redacted configuration can still contain sensitive application content, so callers
|
||||
retain responsibility for access control and disclosure. Retention has a memory
|
||||
cost, but explanation generation and provenance analysis stay off the run path.
|
||||
|
||||
## 4. Simplification Accounting
|
||||
|
||||
| Current implementation | Expected disposition |
|
||||
| --- | --- |
|
||||
| `internal/profile/filesystem_repository.go`: metadata scans, selected strict decode, stream-end checks, malformed-file selection arbitration | Replace with source acquisition, ConfigKit assembly/decoding, and indexed effective values; remove the metadata prepass and per-lookup filesystem path |
|
||||
| `internal/promptdef/filesystem_repository.go`: metadata selection and repeated decoding/content opening | Replace with eager prompt loads and an ID/version index; keep domain normalization and contained content reading |
|
||||
| Profile filesystem, `fs.FS`, eager, memory, and overlay repository variants | Collapse loading into source adapters plus immutable collections; preserve only interfaces that still serve a real boundary |
|
||||
| `internal/profile/resolving_repository.go` | Remove custom graph traversal, cycle detection, and zero-sentinel merging in favor of ConfigKit sibling inheritance; retain only PromptKit domain validation |
|
||||
| `internal/engine` raw repository-stack assembly | Replace with ConfigKit source tiers and one effective typed collection; engine capacity/client assembly remains |
|
||||
| `internal/catalog` profile metadata extraction | Obtain explicit field information from ConfigKit; retain manifests, ownership, namespace, connection-metadata and secret checks |
|
||||
| `internal/filecatalog` | Retain/reduce discovery and containment helpers actually needed by the eager loader; do not claim the entire package disappears |
|
||||
| `internal/domain` source YAML tags and runtime/source coupling | Separate configuration DTOs from execution values where presence requires it; reuse domain validation rather than duplicating it |
|
||||
| Direct `gopkg.in/yaml.v3` use for definition loading | Remove once all such consumers migrate; ConfigKit brings its own private YAML dependency |
|
||||
|
||||
New integration work is real: collection DTOs, ConfigKit calls and error wrapping,
|
||||
source-context access, collection indexing, programmatic mapping ergonomics, and
|
||||
conversion to runtime definitions. Catalog documents may need new keyed shapes;
|
||||
do not hide that migration behind a permanent second loader.
|
||||
Some defensive copies remain necessary at public/injected-client boundaries;
|
||||
ConfigKit's detached load result is not permission to expose shared engine maps.
|
||||
`internal/jsonvalue` still serves programmatic values, request parameters, and
|
||||
prepared schema ownership, so adoption does not eliminate it.
|
||||
|
||||
Maintenance savings depend on actually deleting the old lazy paths and their
|
||||
special-case tests. Move generic YAML/scalar behavior coverage to ConfigKit's
|
||||
ownership; PromptKit retains representative adapter tests plus domain, precedence,
|
||||
snapshot, inheritance, embedded-source, error, and execution integration tests.
|
||||
Do not duplicate ConfigKit's entire parser suite or discard execution regression
|
||||
coverage because construction fixtures need migration.
|
||||
|
||||
Runtime benefit: ordinary runs no longer rediscover definition directories,
|
||||
redecode definitions, reread template text, or traverse inheritance chains.
|
||||
Cost: construction loads even unused definitions and retains their text and
|
||||
resolved values. Retain ConfigKit inspection state needed by the explicit operator
|
||||
API without duplicating its history in runtime definitions or per-run values.
|
||||
These are reasoned tradeoffs, not measured performance
|
||||
claims. Adoption is not justified by the decoder replacement alone.
|
||||
|
||||
### Algorithmic Efficiency
|
||||
|
||||
Efficiency is an explicit design criterion for both the upstream work and this
|
||||
shared-library refactor. Prefer optimal or near-optimal algorithms relative to
|
||||
input and produced-data size, not a blanket O(n) claim with an undefined n.
|
||||
|
||||
- Acquire/parse each accepted source occurrence once, assemble input, and run
|
||||
one typed loading lifecycle rather than decoding intermediate tiers. Reuse
|
||||
captured content instead of reparsing it for metadata, selection, and validation.
|
||||
- Build identity indexes in one pass, with expected constant-time map lookup.
|
||||
Do not scan whole collections for every lookup or precedence decision.
|
||||
- Delegate inheritance expansion to ConfigKit once during loading rather than
|
||||
rebuilding its graph in PromptKit. Assess scaling against input, materialized
|
||||
inherited values, nested destination resolution, and retained history; do not
|
||||
claim unconditional O(V + E) total work from the count of profiles and edges.
|
||||
ConfigKit bounds expansion and retained evidence through its existing budgets;
|
||||
representative catalog sizes must fit them without new per-run processing.
|
||||
- Avoid whole-collection copying per layer, repeated serialization, and repeated
|
||||
prefix/history traversal. Analyze layer processing against total input nodes
|
||||
and produced values/provenance, including intentionally retained history.
|
||||
- Keep configuration discovery, decoding, and provenance analysis out of ordinary
|
||||
execution. Perform explanation work only on explicit inspection calls. Copy only
|
||||
where ownership requires it; do not trade caller isolation for speed.
|
||||
|
||||
These are design targets, not promised complexity for every operation. Sorting
|
||||
for deterministic discovery may require O(F log F) for F files, and processing
|
||||
message text costs work proportional to its bytes. Construction time, retained
|
||||
memory, and frequent-call costs all matter. Prefer straightforward indexes and
|
||||
clear immutable ownership over speculative global caches or intricate optimizers.
|
||||
The implementation plan should include focused benchmarks for consequential
|
||||
repeated-work risks, such as shared-ancestor profiles and many layers, rather than
|
||||
machine-dependent timing gates or benchmarks for every helper.
|
||||
|
||||
## 5. Upstream Readiness And Remaining Opportunities
|
||||
|
||||
Filesystem sources, underlays, programmatic mappings, source contexts, and sibling
|
||||
inheritance are released in v0.5.0. No additional upstream feature is currently a
|
||||
demonstrated prerequisite. Context-aware loading remains a possible future request
|
||||
if concrete consumers need it, not a reason to delay adoption. Existing loading
|
||||
is synchronous; do not simulate cancellation with detached background work.
|
||||
|
||||
Do not propose a public raw AST, permissive identity-only decoder, or dynamic
|
||||
subtree conversion solely to preserve PromptKit's current scanning design.
|
||||
Do not upstream backend/model selection, credential timing for inference,
|
||||
profile catalog reservations, or prompt-template functions.
|
||||
|
||||
ConfigKit and PromptKit both declare LGPL-3.0-or-later. Adoption must still update
|
||||
dependency notices under the OSS policy and inspect the exact selected release
|
||||
and its transitive notices. Their current Go minimums do not require a PromptKit
|
||||
toolchain increase. Use a released module dependency, not a sibling-directory
|
||||
replacement. Select v0.5.0 as the assessed adoption baseline; any newer selection
|
||||
needs its own review. This assessment changes neither dependencies nor licenses.
|
||||
|
||||
## 6. Decisions And Migration
|
||||
|
||||
The agreed direction now incorporates ConfigKit's v0.5.0 contracts. This supersedes
|
||||
the earlier recommendations for independently typed documents, whole-definition
|
||||
shadowing, intermediate typed validation, and atomic maps/null-as-unspecified at
|
||||
the source boundary.
|
||||
|
||||
1. **Loading lifetime and failure scope.** Keep eager construction and frozen
|
||||
definitions/content. Syntax/acquisition/composition failures remain unmaskable;
|
||||
typed and domain validation apply to effective values. A separate reusable
|
||||
public snapshot lifecycle remains possible but is not currently needed.
|
||||
2. **Source organization.** Prefer keyed collections composed across files and
|
||||
tiers before typed loading. Settle exact profile and prompt/version shapes,
|
||||
source-role mapping, and catalog migration in the feature roadmap. Independent
|
||||
typed loads followed by custom combination are not the selected direction.
|
||||
3. **Presence and relationships.** Accept recursive source-tier overlay, retained
|
||||
children for empty mappings, explicit null replacement, and effective metadata.
|
||||
Adopt built-in sibling inheritance, including reserved `extends`, recursive
|
||||
mapping combination, null cancellation, and inherited explicit presence. Do
|
||||
not preserve a parallel PromptKit structural resolver.
|
||||
4. **Diagnostic boundary.** Keep wrapping ConfigKit's public loading errors rather
|
||||
than mirroring them. Retain actionable domain relationship and resource errors
|
||||
without duplicating ConfigKit's generic diagnostic/provenance system.
|
||||
5. **Operator inspection.** Expose effective configuration and provenance through
|
||||
an explicit consumer API backed by the engine's loaded snapshot. Retain the
|
||||
required ConfigKit inspection state at construction; never analyze it during
|
||||
ordinary prompt execution. Finalize targeted query shapes and ownership in the
|
||||
feature roadmap, with presentation remaining application-owned.
|
||||
|
||||
Use released mapping-based programmatic input and direct filesystem resource-context
|
||||
queries. If real consumer examples show
|
||||
mapping construction to be repetitive, consider small typed PromptKit conveniences
|
||||
with explicit presence rather than requiring an upstream typed-ingestion API.
|
||||
|
||||
Remaining PromptKit decisions are the exact collection/version format, catalog
|
||||
migration, programmatic ergonomics, operator-inspection surface, and validation
|
||||
of partial base profiles. Also settle how operators discard unwanted provider
|
||||
settings or old relationships using the released null semantics without a hidden
|
||||
replacement pass. Check the reserved-key impact on arbitrary provider maps. These
|
||||
are integration decisions, not unresolved ConfigKit API proposals.
|
||||
|
||||
Expected consumer migration:
|
||||
|
||||
- Update definitions for ConfigKit's strict scalar rules, rejected YAML
|
||||
anchors/aliases/tags, input limits, and null/type checks. Quote prompt version
|
||||
strings rather than introducing a special numeric-version conversion merely
|
||||
for historical YAML coercion.
|
||||
- Move standalone definitions into the agreed keyed contribution format. Review
|
||||
same-name contributions: disjoint fields within a tier can combine, and higher
|
||||
tiers retain omitted lower fields, including `extends` references. Convert
|
||||
`base_profile` to sibling `extends`; use `extends: null` to cancel a lower-tier
|
||||
relationship, not to erase all settings already supplied to the child.
|
||||
- Review zero/false/empty/null values explicitly. Provider maps merge across
|
||||
tiers, `{}` does not clear them, and null replaces rather than means omission.
|
||||
Use labeled programmatic mappings or approved presence-aware conveniences.
|
||||
Apply those same combination rules to inherited settings. Rename ordinary
|
||||
configuration keys named `extends`; examine provider maps for cases where
|
||||
renaming would change their substantive meaning and resolve those explicitly.
|
||||
- Handle effective-definition errors at construction, fix syntax/composition
|
||||
failures even in overridden inputs, and deliberately rebuild/swap engines
|
||||
after definition changes.
|
||||
- Keep runtime credential lookup optional, avoid baking resolved secrets into
|
||||
definition snapshots, and preserve request-scoped key overrides.
|
||||
- Update maintained catalog data for collection shapes and any incompatible syntax;
|
||||
coordinate catalog releases before the PromptKit dependency update. Preserve
|
||||
maintained profile names and catalog ownership rules, not every old scalar
|
||||
spelling. Do not validate live provider/model availability.
|
||||
- Update error inspection, constructor/source options, and programmatic profile
|
||||
literals as required. Keep representative preparation, execution, inspection,
|
||||
custom backend, fallback, and embedded workflows in the migration guidance.
|
||||
|
||||
The subsequent feature roadmap should carry forward these choices, resolve the
|
||||
remaining PromptKit integration decisions, and specify the target end state.
|
||||
The staged implementation plan can now adopt released ConfigKit support without
|
||||
waiting for upstream feature work, coordinating any catalog format releases before
|
||||
their consumption. Cover deletions/replacements, contract tests, canonical documentation,
|
||||
examples, attribution, and minor-release migration notes. Neither document should
|
||||
become a reason to preserve obsolete compatibility machinery.
|
||||
@@ -1,339 +0,0 @@
|
||||
# ConfigKit Adoption
|
||||
|
||||
Status: agreed feature roadmap. ConfigKit v0.5.0 supplies the required upstream
|
||||
capabilities. The [implementation plan](implementation.md) owns execution steps.
|
||||
The [configuration-boundary ADR](../adr/0001-configuration-boundary.md) records
|
||||
the handoff decision. This document owns the intended end state, not
|
||||
implementation authorization.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use ConfigKit to standardize PromptKit's configuration semantics and simplify
|
||||
its loading machinery, replacing custom logic wherever ConfigKit is the
|
||||
appropriate owner. Preserve substantive consumer capabilities, not historical
|
||||
file syntax, loading order, or Go signatures. Pre-1.0 breaking changes are
|
||||
acceptable and must have migration guidance.
|
||||
|
||||
**This is an intentional breaking change. We are intentionally replacing PromptKit's configuration handling code in favor of adopting ConfigKit's simpler, standardized API. Our goal is to arrive at a new version of PromptKit that is leaner, simpler, and uses a ConfigKit-standard configuration syntax. We MUST NOT give any weight to preserving arbitrary implementation details from PromptKit's current codebase--those should be discarded in favor of the simplest viable implementation that supports PromptKit's substantive LLM-focused consumer capabilities.**
|
||||
|
||||
Applications using both libraries should share configuration interpretation,
|
||||
layering, inheritance, presence, and inspection semantics. PromptKit should
|
||||
concentrate on LLM-specific validation, resource interpretation, and execution.
|
||||
The [adoption assessment](configkit-assessment.md) records the supporting analysis;
|
||||
this roadmap owns the agreed feature end state. Where the earlier assessment
|
||||
differs, this roadmap and the maintainer's direction above take precedence.
|
||||
|
||||
Do not preserve source-option wrappers, repository interfaces, incidental error
|
||||
ordering, or old field shapes solely because they exist today. Reuse existing
|
||||
code only when it remains the simplest appropriate owner of a substantive
|
||||
capability. Migration documentation replaces compatibility machinery, not vice versa.
|
||||
|
||||
## Baseline And Dependency
|
||||
|
||||
The current PromptKit runtime baseline is v0.11.2. Its
|
||||
[source implementation](../internal/sources.md), [format contract](../formats.md),
|
||||
and [consumer guide](../consumers/pkg-promptkit.md) describe existing behavior,
|
||||
not the target described here.
|
||||
|
||||
The reviewed ConfigKit release is v0.5.0 at
|
||||
`dc09959cca4e105f49ec316c6d4ba585bd2340bd`. It provides native `fs.FS` sources,
|
||||
`LoadLayered`, source-backed underlays, labeled programmatic mappings, passive
|
||||
resource contexts, and built-in sibling inheritance. Its released configuration
|
||||
contract and GoDoc own exact upstream semantics. No further upstream feature is
|
||||
currently a demonstrated prerequisite. Review any newer version before selection.
|
||||
|
||||
Adopt a released Go module, not a local `replace` or workspace requirement.
|
||||
Update attribution and transitive license notices under the OSS policy. This
|
||||
feature does not change either project's license or require a Go minimum increase
|
||||
relative to the reviewed versions.
|
||||
|
||||
## Substantive Capabilities To Preserve
|
||||
|
||||
- Named/versioned prompts, declared-input inspection, templates, inline and
|
||||
referenced message content, variables, input helpers, roles, and cache control.
|
||||
- Maintained catalogs, application defaults, operator configuration, programmatic
|
||||
profiles, aliases/inheritance, built-in/custom/local backends, and endpoint-only
|
||||
profiles.
|
||||
- Preparation without generation, ordinary execution, and frozen single-claim
|
||||
prepared execution; independent prompt and profile inspection.
|
||||
- Runtime execution-setting, reasoning, session, credential, and output-contract
|
||||
overrides; appended already-rendered messages.
|
||||
- Inline/file input artifacts and injected artifact, model, and HTTP collaborators.
|
||||
- Backend capacity management, cancellation, transient retries, structural output
|
||||
validation, provider-native structured output, bounded repair, and observations.
|
||||
- Results, effective identities/settings, usage, hashes, and meaningful errors.
|
||||
|
||||
Retain optional environment credentials and direct request credentials. ConfigKit
|
||||
secret resolution must not introduce a new requirement that an upstream API key
|
||||
exist. Domain validation must not probe provider/model availability.
|
||||
|
||||
## Target Configuration Model
|
||||
|
||||
### One Shared Loading Lifecycle
|
||||
|
||||
Construction supplies selected configuration inputs to ConfigKit, which composes,
|
||||
overlays, expands inheritance, and decodes them. PromptKit interprets and validates
|
||||
the final effective configuration at the ConfigKit handoff and produces
|
||||
execution-ready definitions. ConfigKit's effective-value resolution and validation
|
||||
hooks may implement that handoff. Publish no partially initialized engine on failure.
|
||||
|
||||
ConfigKit owns generic parsing, combination, presence, origins, and inheritance.
|
||||
PromptKit owns input selection/discovery, definition indexing, LLM-specific rules,
|
||||
and translation of final values into runtime settings. Input adapters select or
|
||||
encode contributions; they do not inspect or validate raw source contents.
|
||||
Effective presence/null information and resource context supplied by ConfigKit
|
||||
support interpretation at handoff. Historical inspection remains an explicitly
|
||||
invoked operator capability, separate from domain enforcement.
|
||||
Runtime clients and callbacks remain normal Go dependencies, not YAML types.
|
||||
|
||||
Use `backends.<id>`, `profiles.<id>`, and `prompts.<id>.<version>` keyed mappings
|
||||
assembled before typed decoding rather than independently decoding definitions and combining them
|
||||
afterward. Keys own identity; do not duplicate `id`/`version` fields. These paths
|
||||
describe mapping levels, not dot-splitting of literal IDs or version keys.
|
||||
Multiple files can contribute to a collection; a monolithic file is not required.
|
||||
When a prompt ID has multiple versions, selection without a version remains
|
||||
ambiguous. This shape fits sibling inheritance without extra identity reconciliation.
|
||||
|
||||
Supply explicit source groups through a small construction surface compatible
|
||||
with ConfigKit's `Input`/underlay concepts. Offer convenient file/directory/embedded
|
||||
entry points where useful, not a parallel set of historical loading semantics.
|
||||
|
||||
Accept ConfigKit's labeled mapping sources for programmatic configuration. Add
|
||||
only small typed conveniences justified by the minimal local-backend and
|
||||
application-profile workflows. Helpers encode explicit presence and feed the same
|
||||
loading lifecycle; they neither merge configurations nor infer omission from zeros.
|
||||
Backend helpers also produce inputs to this lifecycle. Backend definitions,
|
||||
including catalog connection and capacity settings, cross the same ConfigKit
|
||||
handoff as prompts and profiles. The immutable runtime registry is built from
|
||||
effective backend values. HTTP/model clients and other runtime collaborators
|
||||
remain outside typed configuration.
|
||||
|
||||
Keep the public package a deliberate facade and implementation private. Do not
|
||||
expose internal engine/domain types or introduce a parallel generic loader merely
|
||||
to preserve old source options. ConfigKit's supported public types may be used
|
||||
where intentional integration is simpler than maintaining equivalent wrappers.
|
||||
|
||||
### Sources, Tiers, And Presence
|
||||
|
||||
Support ordinary files/directories, embedded or caller-supplied `fs.FS`, and
|
||||
programmatic definitions. Explicitly selected directories may be discovered by
|
||||
PromptKit; ConfigKit handles their configuration contributions. Do not infer
|
||||
priority from traversal order.
|
||||
|
||||
Retain the useful priority categories, lowest to highest: external
|
||||
catalogs, PromptKit-owned profiles, application fallback, operator configuration,
|
||||
and programmatic configuration. Express intentional priority as ordered tiers.
|
||||
Within each tier, independent contributions compose strictly; overlapping
|
||||
non-mapping values conflict, even when equal. Disjoint fields under the same
|
||||
identity can combine. Across tiers, mappings recursively overlay.
|
||||
These rules also govern backend configuration; consumers can add backend IDs or
|
||||
overlay existing definitions through their selected tiers. Registry construction
|
||||
validates the resulting values, not the identity of their contributors.
|
||||
|
||||
Use ConfigKit's source roles honestly: underlay groups are ordered defaults,
|
||||
main input overlays underlays, and optional named overlay/CLI facilities retain
|
||||
their upstream meaning. Underlay-only loading is supported; do not manufacture
|
||||
a main document or disguise programmatic values as CLI flags. Exposing every
|
||||
ConfigKit convenience through PromptKit is not a requirement.
|
||||
|
||||
Adopt the shared value rules: omission retains lower input; explicit zero, false,
|
||||
empty string, sequence, and null replace; mappings combine recursively; empty
|
||||
mappings do not clear lower children. Null remains explicit and subject to the
|
||||
final schema. Provider-parameter maps obey these rules too. No hidden whole-profile
|
||||
replacement, arbitrary merge callback, or new reset/delete operator belongs here.
|
||||
|
||||
Preserve presence into execution-ready settings so an explicit zero does not
|
||||
disappear during backend/default resolution or outbound request construction.
|
||||
Use ConfigKit metadata at construction, not as a per-run lookup mechanism.
|
||||
|
||||
### Sibling Inheritance
|
||||
|
||||
Use ConfigKit's reserved `extends` rather than PromptKit's `base_profile` and
|
||||
custom resolver. A string identifies an immediate sibling mapping; null cancels
|
||||
the relationship without deleting the child's own settings. Layering completes
|
||||
before inheritance. Only effective relationships resolve, but all of them do,
|
||||
including entries never selected for execution.
|
||||
|
||||
Children recursively overlay resolved parents. Inherited values count as explicit
|
||||
input. Origins and resource context remain attached to their declaring source.
|
||||
Nested relationships follow ConfigKit's destination-local rules; defaults and
|
||||
application-derived values do not trigger another inheritance pass.
|
||||
|
||||
The keyword is reserved throughout configuration, including dynamic provider
|
||||
maps; it is not opt-in. Keep runtime request provider-parameter maps ordinary
|
||||
JSON-compatible data outside configuration parsing. Consumers needing a literal
|
||||
provider field named `extends` supply it at that runtime boundary. Do not rename
|
||||
the outbound key or introduce a second opaque configuration format to escape it.
|
||||
Document and test this distinction, including any affected maintained payloads.
|
||||
|
||||
### Validation And Errors
|
||||
|
||||
ConfigKit owns acquisition, syntax, composition, inheritance, and typed-decoding
|
||||
failures. PromptKit preserves those errors and validates final effective domain
|
||||
values. An unused invalid effective definition fails construction; a lower-layer
|
||||
value legitimately replaced by ConfigKit is not a separate validation target.
|
||||
This boundary applies equally to application and maintained catalog configuration.
|
||||
PromptKit does not perform catalog preflight, raw-source or intermediate-state
|
||||
validation, or history-based enforcement, including equivalent test-only checks.
|
||||
Catalog maintainers own publishing checks in their respective repositories.
|
||||
|
||||
Domain validators remain shared with runtime request validation where appropriate.
|
||||
Allow schema-valid partial profiles without an `abstract` flag. Validate supplied
|
||||
domain values at loading; final runnable-target requirements are checked after
|
||||
profile selection and runtime overrides at execution preparation.
|
||||
Do not require credentials, artifacts, or provider connectivity during construction.
|
||||
|
||||
Preserve absence versus explicit null in resolved settings. Null on an optional
|
||||
provider setting deliberately suppresses it; it must not restore the same setting
|
||||
from backend defaults. A null provider map suppresses the configured lower map,
|
||||
while null entries inside a provider map remain literal JSON null values. This
|
||||
supports reusable bases and deliberate removal of stale settings without changing
|
||||
ConfigKit's merge rules. Framework-required settings such as timeouts have explicit
|
||||
field-specific null semantics rather than blanket nil defaulting.
|
||||
|
||||
Preserve ConfigKit public loading errors through wrapping for `errors.As`, with a
|
||||
PromptKit configuration category. Use source-aware domain/resource failures where
|
||||
ConfigKit is not the owner. Do not mirror its diagnostic framework or promise
|
||||
aggregation beyond what the underlying operation provides. Runtime operational
|
||||
errors remain distinct from generated-content validation failures.
|
||||
|
||||
Prompt versions are domain identifiers, not ConfigKit `VersionRule` declarations.
|
||||
Do not introduce an application schema-version policy merely to adopt ConfigKit.
|
||||
|
||||
## Snapshot, Resource, And Execution Boundaries
|
||||
|
||||
The engine captures effective definitions and referenced message text during
|
||||
construction. Validate static template syntax then; render with request data
|
||||
later. Resolve content references from the effective field's contributing source
|
||||
context, not the last layer's directory. Ambiguous/contextless resource references
|
||||
need an explicit source context or an actionable error, not an invented directory.
|
||||
|
||||
ConfigKit returns passive resource contexts, not snapshots of referenced text.
|
||||
PromptKit explicitly opens and closes its resource handles. Caller filesystem
|
||||
ownership remains unchanged. Preserve appropriate source containment without
|
||||
claiming that every `fs.FS` is a security sandbox. Captured reads are not an atomic
|
||||
transaction over a concurrently changing filesystem.
|
||||
|
||||
Definition changes require new engine construction. No watcher, background
|
||||
refresh, or lazy compatibility mode is added. Applications own engine replacement;
|
||||
prepared handles remain bound to their original engine and overlapping engines
|
||||
retain separate capacity pools.
|
||||
|
||||
Request artifacts and schema preparation remain invocation work. A request can
|
||||
replace the output contract, so construction need not compile unused schemas.
|
||||
Prepared execution continues to freeze its effective resources. Environment
|
||||
credential lookup remains at the established execution/preparation boundary,
|
||||
not frozen into the configuration snapshot.
|
||||
|
||||
Ordinary execution uses indexed ready definitions and resolved presence values.
|
||||
It performs no configuration discovery, parsing, inheritance traversal, or
|
||||
provenance analysis. Context cancellation remains cooperative at controlled
|
||||
boundaries; do not add detached work to pretend synchronous ConfigKit loading is
|
||||
instantly interruptible.
|
||||
|
||||
## Explicit Operator Inspection
|
||||
|
||||
Add an API consumers can use to implement targeted CLI commands or debug output
|
||||
for effective configuration, contributing sources, and explanations. Retain
|
||||
ConfigKit inspection state with the engine's configuration snapshot and reuse
|
||||
its redaction and provenance facilities.
|
||||
|
||||
Inspection is explicitly invoked and uses that snapshot without reloading files,
|
||||
opening resources, or contacting providers. It does not predict every runtime
|
||||
override, reveal execution-time credential values, or replace prepared-run details
|
||||
and execution observations. No provenance traversal, explanation generation, or
|
||||
configuration-report rendering occurs during ordinary prompt runs.
|
||||
|
||||
Expose a read-only inspection view backed by retained ConfigKit state, with
|
||||
targeted path queries, source/history information, and explicit redacted effective
|
||||
and explanation output. Reuse suitable public ConfigKit inspection values rather
|
||||
than mirroring them. Do not expose mutable runtime definitions, resource capabilities
|
||||
merely for diagnostics, or request credentials. The scope is generic targeted
|
||||
inspection, not just narrow profile/prompt origin summaries.
|
||||
|
||||
PromptKit ships no CLI and emits no automatic debug reports. Applications own
|
||||
presentation, access control, and disclosure; redaction does not make all prompt
|
||||
or configuration content safe to publish.
|
||||
|
||||
## Required Refactoring And Coordination
|
||||
|
||||
- Replace profile/prompt metadata prepasses, selected-file decoding, and per-call
|
||||
scans with ConfigKit collection loading and immutable indexed definitions.
|
||||
- Remove the custom profile inheritance resolver and error-driven repository
|
||||
overlay stack. Do not retain an old-format compatibility engine alongside the
|
||||
new semantics.
|
||||
- Route catalog and programmatic backend definitions through ConfigKit; retire
|
||||
the separate backend-manifest decoder and pre-handoff registration validation.
|
||||
Retain backend endpoint, capacity, and provider-parameter domain validation on
|
||||
the final effective values.
|
||||
- Retain only needed discovery and message-resource adapters; remove direct YAML
|
||||
dependency usage once no remaining owner needs it. Preserve JSON Schema,
|
||||
runtime JSON-value ownership, domain validation, and execution collaborators.
|
||||
- Adapt maintained catalogs and PromptKit-owned definitions to the agreed keyed
|
||||
format and released inheritance semantics, including keyed backend assets.
|
||||
Preserve maintained name commitments and the intended shipped backend settings.
|
||||
Catalog source-policy enforcement belongs to catalog maintainers. Coordinate
|
||||
compatible catalog releases before consuming the changed asset format.
|
||||
Publish new keyed assets directly in the existing data-only catalog projects,
|
||||
recording the format transition rather than maintaining duplicate legacy assets
|
||||
or a permanent conversion adapter. Catalog maintainers select release/version
|
||||
handling under their own policies; old consumers must not be assumed capable
|
||||
of decoding the new shape. Pin the compatible released versions in PromptKit.
|
||||
This document requests no immediate changes in those repositories.
|
||||
- Keep configuration conversion, normalization, and inspection connected to
|
||||
ConfigKit's lifecycle instead of reconstructing effective values/history after
|
||||
loading. Translate once into execution-owned values with necessary copying.
|
||||
- Update canonical formats, consumer guidance, GoDoc, internal inventory, examples,
|
||||
architecture boundaries, dependencies, and OSS notices when implementation lands.
|
||||
|
||||
## Efficiency And Validation Expectations
|
||||
|
||||
Target linear or near-linear work relative to actual input, output, and retained
|
||||
history, accounting separately for sorting, resource reads, and domain work.
|
||||
Acquire/parse each accepted source occurrence once, avoid decoding intermediate
|
||||
tiers, and index definition selection. Do not duplicate inheritance expansion or
|
||||
copy complete accumulated collections for every added layer.
|
||||
|
||||
Materialized inherited data and provenance can exceed source size. Exercise real
|
||||
catalog shapes within ConfigKit's budgets; do not claim unconditional O(n) runtime
|
||||
or memory. Prefer clear ownership and straightforward indexes over global caches
|
||||
or elaborate optimization. Operator inspection may perform work proportional to
|
||||
the requested report; that work remains off the execution path.
|
||||
|
||||
Tests should use real ConfigKit with offline sources and synthetic provider
|
||||
collaborators. PromptKit owns its adapter, domain, lifecycle, and execution tests,
|
||||
not a duplicate ConfigKit parser/graph suite. Retire tests whose only purpose was
|
||||
the removed loading machinery or source-policy enforcement. Exercise catalog
|
||||
integration through the final assembled configuration and resulting behavior.
|
||||
Preserve meaningful execution regressions.
|
||||
|
||||
Representative acceptance evidence must cover:
|
||||
|
||||
- Minimal local-backend construction and execution without elaborate configuration.
|
||||
- Embedded defaults plus operator and programmatic contributions, including
|
||||
accurate inherited relative-resource origins.
|
||||
- Profile inheritance, changing model/backend, canceling old relationships, and
|
||||
discarding unwanted settings with documented semantics.
|
||||
- Explicit zero/false/null behavior reaching execution, construction failures,
|
||||
independent snapshots, and unchanged substantive preparation/execution features.
|
||||
- Targeted inspection with useful redacted origins and no provenance work during
|
||||
ordinary runs.
|
||||
|
||||
Use focused scaling/allocation benchmarks for large collections, shared bases,
|
||||
and many tiers where they expose repeated-work risks. Do not add timing-based
|
||||
release gates or provider-network tests.
|
||||
|
||||
## Migration And Completion
|
||||
|
||||
Ship as an intentionally incompatible minor release with concrete migration
|
||||
guidance. Cover keyed files, `base_profile` to `extends`, reserved-key behavior,
|
||||
recursive maps, explicit null versus omission, changed error timing, programmatic
|
||||
construction, backend layering in place of registration-only restrictions, and
|
||||
explicit engine replacement. Explain that overriding a model
|
||||
does not discard lower endpoint, reasoning, provider, or inheritance settings.
|
||||
|
||||
The end state is one shared configuration model with the old loading/resolution
|
||||
machinery removed, useful operator inspection, and all substantive execution
|
||||
workflows still available. The six previously open design choices have been
|
||||
approved and incorporated into the owning sections above. This is not a
|
||||
compatibility-preserving reinterpretation of the existing configuration engine.
|
||||
@@ -42,20 +42,21 @@ checkpoint equality or when a broader semantic identity design is selected.
|
||||
### Eager Source Validation
|
||||
|
||||
**Reason for deferral:** Exact prompt and profile inspection may already
|
||||
provide a sufficiently small validation surface. Experience from downstream
|
||||
adoption should establish whether an engine-wide operation would add enough
|
||||
value to justify its broader contract.
|
||||
provide a sufficiently small validation surface alongside construction-time
|
||||
definition validation. Experience from downstream adoption should establish
|
||||
whether a separate operation would add enough value to justify its broader
|
||||
contract.
|
||||
|
||||
Promptkit could provide an explicit offline operation that discovers and
|
||||
structurally validates configured prompt, profile, and schema sources without
|
||||
model generation. The normal `NewEngine` path would remain lazy.
|
||||
Promptkit could provide an explicit offline operation that also checks schema
|
||||
resources without model generation. `NewEngine` already validates effective
|
||||
definitions and captures prompt content; schema resources remain
|
||||
request-dependent.
|
||||
|
||||
An eager operation would need coherent handling for duplicate prompt IDs and
|
||||
versions, strict YAML decoding, referenced content files, profile/backend
|
||||
membership, schema syntax and transitive references, context cancellation,
|
||||
and source-specific public errors. Credential declarations must remain
|
||||
separate from credential values; checking current environment availability,
|
||||
if supported at all, should be an explicit option and must not expose secrets.
|
||||
Such an operation would need coherent handling for schema syntax and transitive
|
||||
references, context cancellation, and source-specific public errors. Credential
|
||||
declarations must remain separate from credential values; checking current
|
||||
environment availability, if supported at all, should be an explicit option
|
||||
and must not expose secrets.
|
||||
|
||||
Reconsider this idea after downstream use of `InspectPrompt`,
|
||||
`InspectProfile`, and fixture-based preparation demonstrates a concrete gap.
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
# ConfigKit Adoption Implementation Plan
|
||||
|
||||
Status: ready for staged implementation under the agreed configuration boundary.
|
||||
This is a planning document; writing it does not implement the feature or
|
||||
authorize publishing dependencies.
|
||||
|
||||
## Authority And Working Method
|
||||
|
||||
The [ConfigKit feature roadmap](configkit.md) owns the intended end state and
|
||||
approved policy. Its intentional-breaking-change direction is authoritative:
|
||||
preserve substantive prompting capabilities, not the old loader, signatures,
|
||||
source wrappers, incidental error ordering, or configuration syntax.
|
||||
The [configuration-boundary ADR](../adr/0001-configuration-boundary.md) records
|
||||
the ownership decision: PromptKit interprets and validates the final effective
|
||||
configuration supplied by ConfigKit. Presence/null information and effective
|
||||
resource context are part of that handoff. Source history serves explicit
|
||||
operator inspection, not validation or catalog enforcement. This applies to
|
||||
tests as well as production code.
|
||||
|
||||
Implement the stages below in order, one stage per coding-agent prompt. Each
|
||||
stage includes its tests and affected current-state documentation. Read
|
||||
[development](../development.md) and the applicable policies before starting;
|
||||
in particular, follow [agents](../policy/agents.md),
|
||||
[architecture](../policy/architecture.md), [testing](../policy/testing.md),
|
||||
[documentation](../policy/documentation.md), and [OSS](../policy/oss.md).
|
||||
Catalog work also follows each catalog repository's own instructions and release
|
||||
procedure. Do not turn this plan's internal organization into new public promises.
|
||||
|
||||
Begin each stage by inspecting the worktree and earlier stages. Commit completed
|
||||
units under the agent policy. Keep stages buildable; temporary private wiring
|
||||
needed to connect successive stages must be removed by Stage 11. Do not ship a
|
||||
legacy mode, dual loader, or old-format conversion layer. Do not add prototype
|
||||
applications or a generic configuration framework of our own.
|
||||
|
||||
Planning baselines:
|
||||
|
||||
- PromptKit runtime v0.11.2, commit
|
||||
`12d6d80024e80c552d122a076c22429bf6f2f6c2`; approved roadmap at `521291d`.
|
||||
- ConfigKit v0.5.0, commit
|
||||
`dc09959cca4e105f49ec316c6d4ba585bd2340bd`, module
|
||||
`gitea.maximumdirect.net/eric/ConfigKit`.
|
||||
- Both catalog dependencies currently select `/v2` at `v2.20260926.0`.
|
||||
|
||||
Use ConfigKit v0.5.0 for this work. Do not silently upgrade to a different
|
||||
contract. The catalog asset migration needs coordinated releases, not a committed
|
||||
workspace or local replacement. Changing the backend asset contract is broader
|
||||
than the catalog profile-data compatibility exception: migrate the catalogs to
|
||||
`/v3` with `v3.YYYYMMDD.N` tags, preserving their date/revision convention and
|
||||
updating their own release documentation accordingly. Select exact dates and
|
||||
unused revisions at publication; record versions and full commits consumed.
|
||||
Publishing any repository requires separate authority.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Construction Surface
|
||||
|
||||
Keep `NewEngine(Config, ...Option)` and the execution/injection surface where it
|
||||
still serves its substantive purpose. Replace profile/prompt source options with
|
||||
these fields and helpers; this is an intentional API break:
|
||||
|
||||
```go
|
||||
type ConfigurationSources struct {
|
||||
Underlays []configkit.Underlay
|
||||
Main *configkit.Input
|
||||
}
|
||||
|
||||
// Add Sources; remove PromptDir and ProfileDir.
|
||||
// Retain SchemaDir, Timeout, HTTPClient, and TransientRetries.
|
||||
type Config struct { /* existing non-definition settings; Sources ConfigurationSources */ }
|
||||
|
||||
func DirectoryInputs(directory string) ([]configkit.Input, error)
|
||||
func FilesystemInputs(label string, filesystem fs.FS, root string) ([]configkit.Input, error)
|
||||
```
|
||||
|
||||
The pointer distinguishes omitted main input without inspecting ConfigKit's
|
||||
opaque Source. Nil main becomes `configkit.NoSource()`; a supplied invalid Input
|
||||
is not silently ignored. Prepend two private underlays: external catalogs, then
|
||||
PromptKit-owned definitions. Append caller underlays in their supplied order;
|
||||
main is last and keeps its ConfigKit meaning. Imports follow each Input's flag.
|
||||
Do not expose named overlays, CLI overrides, or VersionRule through another
|
||||
PromptKit abstraction in this feature.
|
||||
|
||||
Show the usual application categories as caller underlays ordered fallback,
|
||||
operator, programmatic, with main omitted. For a single highest-priority document,
|
||||
main is also useful. These are explicit selections, not directory-name heuristics
|
||||
or a second automatic precedence algorithm. Each directory helper returns inputs
|
||||
for one strict-composition group, recursively finding `.yaml` and `.yml` files;
|
||||
sorted discovery makes diagnostics reproducible, not one file higher priority.
|
||||
An explicitly selected missing/unreadable root fails; an empty directory supplies
|
||||
no contributions. Helpers set imports false; callers may enable them explicitly.
|
||||
Use one ConfigKit filesystem namespace per helper invocation and root resource
|
||||
contexts at the supplied directory. Single files use ConfigKit directly.
|
||||
|
||||
Remove `WithPromptFS`, `WithPromptFile`, `WithProfileFS`, `WithProfileFile`,
|
||||
`WithFallbackProfileFS`, `WithProfiles`, and `WithBackend` at public cutover.
|
||||
Retain schema source options and runtime collaborator injection. Backend
|
||||
configuration moves into ConfigKit inputs as specified below. Do not require any
|
||||
prompts to exist merely to construct an engine for profile inspection.
|
||||
|
||||
Programmatic configuration uses labeled `configkit.Mapping` inputs. Replace the
|
||||
old zero-sentinel `Profile`/`OpenAICompatibleProfileConfig` pair with one small
|
||||
`ProfileConfig` convenience and a source builder:
|
||||
|
||||
```go
|
||||
type ProfileConfig struct {
|
||||
Extends, Backend, Endpoint, Model *string
|
||||
Temperature, TopP *float64
|
||||
MaxTokens, TimeoutSeconds *int
|
||||
ServiceTier, ReasoningEffort, APIKeyEnv *string
|
||||
APIKeyRequired *bool
|
||||
ExtraParams map[string]any
|
||||
}
|
||||
func ProfileSource(label string, profiles map[string]ProfileConfig) configkit.Source
|
||||
```
|
||||
|
||||
Nil convenience fields mean omitted; nonnil pointers preserve zero/false/empty.
|
||||
Nil ExtraParams omits the map; a nonnil empty map supplies an empty mapping, not a
|
||||
clear operation. Use raw Mapping for explicit null, prompt definitions, or richer
|
||||
configuration. Do not introduce a public generic optional-value type just for
|
||||
this helper. Convert fields explicitly to ConfigKit-supported built-in values,
|
||||
then call Mapping for snapshot ownership and load-time errors. Do not marshal
|
||||
through YAML or infer absence from a value. Helpers encode values; domain
|
||||
validation occurs on the final configuration at handoff.
|
||||
|
||||
Replace the registration-only Backend value with a presence-aware convenience:
|
||||
|
||||
```go
|
||||
type BackendConfig struct {
|
||||
Endpoint, APIKeyEnv *string
|
||||
ConcurrencyLimit, QueueCapacity *int
|
||||
ExtraParams map[string]any
|
||||
}
|
||||
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source
|
||||
func LocalBackend(endpoint string, concurrencyLimit int) BackendConfig
|
||||
```
|
||||
|
||||
Use the same omission/pointer/map conventions as ProfileSource. LocalBackend
|
||||
supplies endpoint and concurrency pointers and leaves the remaining fields
|
||||
omitted; callers choose the ID as the map key. Neither helper validates settings
|
||||
before ConfigKit processes them. For example, place BackendSource with
|
||||
`map[string]BackendConfig{"local": LocalBackend(endpoint, 4)}` in an application
|
||||
underlay. Raw Mapping supports explicit null and combined backend/profile/prompt
|
||||
documents. Remove the old Backend registration value and its reservation rules;
|
||||
retain useful built-in ID constants. All backend contributions follow the same
|
||||
strict-composition and overlay rules as other configuration.
|
||||
|
||||
### Typed Configuration And Execution Values
|
||||
|
||||
Add `internal/configuration` as the cohesive ConfigKit adapter. Its private
|
||||
document DTO has `backends: map[string]backendConfig`,
|
||||
`profiles: map[string]profileConfig`, and
|
||||
`prompts: map[string]map[string]promptConfig`. Collection entries are mappings,
|
||||
not nullable definition pointers. No duplicate identity fields; ConfigKit consumes
|
||||
`extends`, so no DTO field or second resolver handles it. Use `configkit.Keys`,
|
||||
`Key`, and `Index` for semantic paths, never split IDs on dots.
|
||||
|
||||
Profile DTO fields match ProfileConfig's YAML spellings (`backend`,
|
||||
`reasoning_effort`, etc.). Nullable pointers plus ConfigKit presence metadata
|
||||
distinguish absent, value, and explicit null. Store that distinction in small
|
||||
private execution-owned fields or presence bits, not retained metadata queries.
|
||||
Runtime/domain packages must not import ConfigKit. Do not duplicate its tree,
|
||||
merge engine, inheritance graph, diagnostics, or presence framework.
|
||||
|
||||
Backend DTO fields match BackendConfig using `endpoint`, `api_key_env`,
|
||||
`concurrency_limit`, `queue_capacity`, and `extra_params`. Validate the final
|
||||
endpoint as required, absolute HTTP(S) with the existing URL rules. An absent or
|
||||
null API-key environment name means no lookup; a supplied name follows existing
|
||||
normalization and naming rules. Absent/null concurrency means zero (unlimited);
|
||||
supplied values must be nonnegative. Absent/null queue capacity selects the
|
||||
existing default of 1024 for positive concurrency and no queue for unlimited
|
||||
concurrency. Explicit queue capacity, including zero, requires positive
|
||||
concurrency and must satisfy existing nonnegative/overflow checks. Null provider
|
||||
maps mean no configured extra parameters; entries may contain JSON null.
|
||||
Required endpoint failures, unknown fields, and domain errors apply to final
|
||||
definitions, including unused ones. Registry construction consumes these final
|
||||
normalized values; it does not replay registration or source-reservation checks.
|
||||
|
||||
Use ConfigKit's Resolve/Validate callbacks only on its effective post-layering,
|
||||
post-inheritance typed values. These public lifecycle hooks implement the domain
|
||||
handoff and record normalization; they are not hooks for inspecting earlier
|
||||
contributions. Final presence lookup and resource-context lookup are sufficient
|
||||
for interpretation. History/Explain/HistoricalPaths are operator inspection
|
||||
facilities, not inputs to construction-time policy checks.
|
||||
|
||||
Use the existing prompt fields under each keyed version, without `id`/`version`.
|
||||
Use pointers for `content`, `content_file`, and optional clearable strings;
|
||||
preserve existing input, cache-control, output-contract, and role rules.
|
||||
`messages` must be nonempty; each message has exactly one nonnull content source.
|
||||
An explicitly supplied empty inline string is a valid template. Null content
|
||||
removes that selection; null content_file removes its reference. Output remains a
|
||||
required mapping with valid format and validation mode. Optional description,
|
||||
session template, default profile, cache control, inputs, and schema path can be
|
||||
cleared where their domain permits absence. Null required role/format/mode or a
|
||||
null whole definition is invalid. Scalar `required` and `repair_attempts` retain
|
||||
their false/zero defaults and are not nullable. Null inputs means no declarations.
|
||||
Unknown fields are ConfigKit errors, including old `id`, `version`, and
|
||||
`base_profile`. Prompt versions must be string mapping keys.
|
||||
|
||||
Literal definition keys remain exact; require nonblank identity but do not
|
||||
normalize/rekey maps after ConfigKit has resolved relationships. Update selection
|
||||
to use exact nonblank IDs/versions rather than silently trimming them into a
|
||||
different identity. Scalar normalization such as endpoint trimming is recorded
|
||||
through ConfigKit's resolution callbacks when
|
||||
mutating its typed configuration. Message roles and cache control are normalized
|
||||
on execution-owned copies instead: ConfigKit v0.5.0 treats sequence mutations as
|
||||
whole-value replacements, losing unchanged content_file origins. Inspection
|
||||
retains the effective input message metadata and its source context. Field
|
||||
references to definition IDs remain exact as well.
|
||||
|
||||
Profile field rules after configuration overlay/inheritance:
|
||||
|
||||
| Fields | Absent | Explicit value | Explicit null |
|
||||
| --- | --- | --- | --- |
|
||||
| `backend` | No selected backend | Exact registered ID; blank means none | No backend |
|
||||
| `endpoint` | Selected backend endpoint, if any | Normalize/validate nonblank URL; empty clears | Clear, do not recover backend endpoint |
|
||||
| `model` | Unspecified | Nonblank supplied model; empty clears | Unspecified |
|
||||
| `temperature`, `top_p`, `max_tokens` | Unspecified | Validate existing bounds; zero is explicit and sent | Suppress outbound control |
|
||||
| `reasoning_effort`, `service_tier` | Unspecified | Existing normalization; blank suppresses | Suppress outbound control |
|
||||
| `timeout_seconds` | Framework 600-second baseline | Nonnegative; zero disables generation deadline | Reset to framework baseline, not lower profile timeout |
|
||||
| `api_key_env` | Selected backend's optional lookup name | Trimmed value; blank disables lookup | Disable lookup, do not recover backend name |
|
||||
| `api_key_required` | False | Explicit boolean, including false overriding true | False |
|
||||
| `extra_params` | Backend defaults | ConfigKit-composed profile map, recursively overlaid on backend map | Suppress entire configured provider map |
|
||||
|
||||
Profile API-key environment names follow the existing lookup contract, including
|
||||
host-supported non-shell-identifier names. Do not extend the backend's naming
|
||||
regular expression to profiles. Trimming, explicit clearing, optional lookup,
|
||||
and direct request-key precedence remain unchanged.
|
||||
|
||||
Backend registry defaults are applied once during snapshot construction, after
|
||||
effective profile inheritance. Keep them out of the inheritance input: a parent's
|
||||
derived endpoint/key/defaults must not follow a child that changes backend.
|
||||
Provider map null entries are literal JSON null; empty mappings retain children.
|
||||
For backend-to-profile provider-map overlay, use a narrow JSON-value merge helper
|
||||
with the same recursive-map/value-replacement semantics; it is not a configuration
|
||||
loader. Both backend and profile configuration maps have already passed through
|
||||
ConfigKit's reserved-key semantics; only request-level maps bypass that lifecycle.
|
||||
|
||||
Record any defaults/normalizations written into the typed configuration via
|
||||
ConfigKit Resolve, with dependencies on relevant configuration paths. Do not
|
||||
project runtime collaborators or credentials into the configuration tree just to
|
||||
report them. Inspection includes effective backend configuration. It describes
|
||||
resolved configuration, while InspectProfile and preparation describe
|
||||
backend-completed execution settings. Make this boundary
|
||||
explicit rather than inventing synthetic provenance for registry defaults.
|
||||
|
||||
Retain APIKeyRequired's direct-key workflow: true suppresses a backend-derived
|
||||
environment source. An explicitly configured profile environment source, or a
|
||||
request environment override, remains usable; false restores ordinary optional
|
||||
credential semantics. No credential values belong in the DTO, inspection, or
|
||||
construction-time environment resolution. Raw profile `api_key` remains invalid.
|
||||
|
||||
Validate all effective supplied values and referenced backend IDs at construction.
|
||||
Do not require a complete model/endpoint there or reject unused partial profiles.
|
||||
Do not require a prompt default_profile to be runnable before a request can
|
||||
override it. Final target completeness is checked after runtime overrides.
|
||||
InspectProfile can report a partial target without credential or provider access.
|
||||
Keep request override contracts otherwise unchanged, including complete nonempty
|
||||
runtime ExtraParams replacement and reasoning clearing. A runtime literal
|
||||
`extra_params["extends"]` remains ordinary provider data. Do not expand this work
|
||||
into a redesign of every request override.
|
||||
|
||||
### Snapshot And Inspection Design
|
||||
|
||||
The loader returns indexed, execution-owned definitions plus a private retained
|
||||
ConfigKit result. An adapter may implement the runner's narrow lookup interfaces
|
||||
over maps during migration; no repository chain, fallback-on-error, or per-call
|
||||
configuration load survives. Preserve defensive ownership across injected clients
|
||||
and public results. Cache prompt definition hashes at construction; hash effective
|
||||
captured content, not a stale resource path. Hash bytes need not match the old
|
||||
configuration format. Preserve rendered-prompt hashing semantics.
|
||||
|
||||
Resolve each effective content_file using its exact field origin and ConfigKit
|
||||
SourceContext. Join its relative name to BaseDir inside the returned rooted FS;
|
||||
reject absolute/outside-root paths and close every opened handle. Preserve path
|
||||
text, including meaningful whitespace, after separately checking nonblankness.
|
||||
Do not choose the child's source for an inherited reference. If there is no unique
|
||||
contributing source with context, fail with an actionable path/source error; the
|
||||
caller can supply an explicit Input filesystem context or override the field.
|
||||
Retain no resource capability for the purpose of public diagnostics.
|
||||
|
||||
Capture message text and parse message/session template syntax at construction
|
||||
with the same function names/options used for runtime rendering. Do not invoke
|
||||
input helpers or require Vars/artifacts while parsing. Reuse compiled templates
|
||||
where practical, binding request-specific input functions on safe per-call clones;
|
||||
never mutate a shared template function closure with request state. Runtime
|
||||
missing-variable/input failures remain runtime errors.
|
||||
|
||||
Expose a concrete read-only facade value:
|
||||
|
||||
```go
|
||||
func (e *Engine) InspectConfiguration() (ConfigurationInspection, error)
|
||||
func (v ConfigurationInspection) Metadata() configkit.Metadata
|
||||
func (v ConfigurationInspection) Sources() []configkit.SourceInfo
|
||||
func (v ConfigurationInspection) Diagnostics() []configkit.Diagnostic
|
||||
func (v ConfigurationInspection) Explain(path configkit.Path) (configkit.Explanation, bool)
|
||||
func (v ConfigurationInspection) WriteEffective(w io.Writer) error
|
||||
func (v ConfigurationInspection) WriteExplanation(w io.Writer, path configkit.Path) error
|
||||
```
|
||||
|
||||
Store an unexported narrow implementation reference; do not expose Result.Config,
|
||||
ConfigKit SourceContext, mutable runtime values, or internal types. Delegate
|
||||
queries and redaction to the retained result. Nil Engine returns ErrInvalidConfig;
|
||||
the zero inspection view has empty query results and writer operations return
|
||||
ErrInvalidConfig. Writer errors remain inspectable. Mark credential-shaped dynamic
|
||||
keys sensitive using Definition.Secret; the existing secret-key vocabulary can
|
||||
serve as a redaction predicate, not a source-rejection rule. Do not redact
|
||||
`api_key_env` names as credential values. This is not a
|
||||
promise that arbitrary prompt/provider content is safe to disclose.
|
||||
|
||||
Keep the runner independent of this view. Inspection calls perform no discovery,
|
||||
resource reads, environment credential lookup, or provider operations. Schemas,
|
||||
artifacts, preparation ownership, generation admission, retry/repair, observations,
|
||||
and cancellation remain at their established runtime boundaries.
|
||||
|
||||
## Stage 1 — Add The Dependency And Typed Loading Core
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: module metadata, `internal/configuration` DTOs, real ConfigKit loading,
|
||||
and its focused tests. Leave engine assembly on the old path for this stage.
|
||||
|
||||
1. Add ConfigKit v0.5.0; update go.sum and factual OSS notices for it and its
|
||||
selected transitive dependencies. Preserve project licenses and the Go floor.
|
||||
2. Add keyed backend/profile/prompt DTOs and a private load entry point accepting
|
||||
ConfigKit Inputs and underlays. Call LoadLayered once, with no VersionRule or
|
||||
secret resolution bindings for API credentials. Use callback-scoped
|
||||
Resolve/Validate correctly.
|
||||
3. Implement effective presence extraction and the field rules above. Validate
|
||||
final domain values through existing helpers, resolving backend references
|
||||
against the final backend map; profiles have no final target requirement yet.
|
||||
Record normalization/default mutations, not arbitrary DTO bookkeeping fields.
|
||||
4. Preserve `*configkit.LoadError` through wrapping. Domain callback messages
|
||||
identify paths/problems without interpolating raw values or credentials.
|
||||
5. Add representative real-loader tests: keyed literal IDs, strict group conflict,
|
||||
recursive cross-tier overlay, a shared base with explicit zero/null/false, an
|
||||
unused invalid effective definition, and preservation of a ConfigKit load
|
||||
error. Cover an invalid lower-layer domain value replaced by a valid final
|
||||
value: construction succeeds. Do not reproduce ConfigKit's parser/inheritance
|
||||
matrix or add preflight/source-policy tests.
|
||||
|
||||
Exit: focused tests and `go test ./...` pass; old engine behavior is not presented
|
||||
as migrated. Internal inventory documents only the loader now actually present.
|
||||
|
||||
## Stage 2 — Add Source Discovery And Prompt Resource Capture
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: source selection helpers and construction-time prompt materialization.
|
||||
|
||||
1. Implement private directory/FS discovery behind the chosen helper signatures;
|
||||
wrap discovered paths as ConfigKit sources, retaining explicit root context.
|
||||
Reuse useful filecatalog path handling without carrying profile metadata scans.
|
||||
2. Materialize effective prompt message text using field origins and passive
|
||||
contexts. Move reusable prompt/input/cache/output validation into the loader
|
||||
or shared domain owner; do not copy entire legacy parsers.
|
||||
3. Build immutable prompt indexes by exact ID/version and an ambiguity indicator
|
||||
for omitted versions. Capture declared-input inspection data and prompt hashes.
|
||||
4. Validate static template syntax using the renderer's grammar/function setup.
|
||||
Refactor that setup into one private owner if needed; do not render at load.
|
||||
5. Test native and embedded sources, imported/inherited relative content from a
|
||||
different directory, explicit mapping context, contextless failure, handle
|
||||
closure, malformed static syntax, and resource mutation after capture. Use
|
||||
minimal fixtures, sharing scenarios where the same adapter is exercised.
|
||||
|
||||
Exit: loader creates usable frozen prompt definitions without request data.
|
||||
`go test ./internal/configuration ./internal/prompt ./internal/promptdef` passes;
|
||||
existing execution tests still work through the old assembly until cutover.
|
||||
|
||||
## Stage 3 — Make Execution Settings Presence-Aware
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: domain settings, target resolution, wire-presence propagation, profile
|
||||
inspection. No new source discovery in execution packages.
|
||||
|
||||
1. Build the immutable backend registry from the final loaded backend map. Add
|
||||
execution-owned presence/suppression state and construct indexed profiles
|
||||
from loader output plus that registry. Apply registry-derived
|
||||
values after inheritance, using the field table above.
|
||||
2. Replace scalar-zero/blank merge assumptions in target resolution. Configuration
|
||||
explicit zero reaches injected clients and the built-in request body; null does
|
||||
not resurrect a provider setting. Extend existing TargetPresence plumbing
|
||||
through preparation/results as necessary rather than creating a parallel path.
|
||||
3. Preserve request-level override precedence, API-key availability behavior,
|
||||
endpoint-only profiles, backend identity/capacity, and partial-profile runtime
|
||||
completion. Move final completeness checks after overrides. InspectProfile
|
||||
returns partial settings without treating missing model/endpoint as load errors.
|
||||
4. Test explicit numeric zero versus omission, map overlay and null, backend
|
||||
change without derived defaults leaking from parent, timeout null/zero,
|
||||
credential-source suppression, and completion through request overrides.
|
||||
Include one built-in-client wire test, not just an injected-client assertion.
|
||||
|
||||
Exit: focused domain/usecase/client tests pass. Any temporary adaptation for the
|
||||
old assembly is private, clearly limited to reaching Stage 7, and does not become
|
||||
a compatibility promise. Update affected target/presence GoDoc with this change.
|
||||
|
||||
## Stage 4 — Migrate The OpenRouter Catalog Assets
|
||||
|
||||
**Completed:** 2026-09-30. Published OpenRouter catalog `v3.20260930.0`
|
||||
from `f17d208cb7ad1fb043cda2266cd3011d11b40aa0`.
|
||||
|
||||
Scope: `../promptkit-backend-openrouter` only. Catalog maintainers own source and
|
||||
publishing checks here; PromptKit consumes its configuration through ConfigKit.
|
||||
|
||||
1. Read that repository's instructions; preserve unrelated work. Convert each
|
||||
profile asset to `profiles: {literal-id: {settings...}}`, preserving inventory
|
||||
and values, changing `base_profile` to `extends`, and removing embedded ID.
|
||||
Keep per-profile files and the exported FS/Root package; no dependency on
|
||||
PromptKit and no second legacy asset tree.
|
||||
2. Audit zero/empty values that previously meant unspecified. Remove those when
|
||||
omission was intended; retain intentional explicit values. Review any dynamic
|
||||
provider `extends` keys rather than silently changing outbound payloads.
|
||||
3. Replace `catalog/backend.json` with `catalog/backend.yml` containing a
|
||||
`backends: {openrouter: {settings...}}` mapping. Preserve endpoint, API-key
|
||||
environment name, concurrency, queue, and extra-parameter values; remove the
|
||||
duplicate id and old manifest schema_version. The file is an ordinary ConfigKit
|
||||
input, not a separately decoded manifest. Update embedded assets, module path
|
||||
and imports to `/v3`, tests, and catalog format/release documentation, with an
|
||||
incompatible-format migration note. Keep FS/Root as the minimal export surface.
|
||||
Catalog-local publishing checks stay in that repository. No provider probes.
|
||||
4. Validate under its procedure and commit. If publication is authorized, publish
|
||||
the next unused `v3.YYYYMMDD.N`; otherwise report the exact commit awaiting
|
||||
publication. The date/revision is selected then, not guessed in this plan.
|
||||
|
||||
Exit: one reviewed migrated catalog commit; existing immutable tags still serve
|
||||
old consumers. The new major module path identifies the changed asset contract;
|
||||
old consumers retain the published `/v2` assets.
|
||||
|
||||
## Stage 5 — Migrate The Rakestrawhome Catalog Assets
|
||||
|
||||
**Completed:** 2026-09-30. Published Rakestrawhome catalog `v3.20260930.0`
|
||||
from `993443d88c451901f4c917fa00c204fd3b54d28d`.
|
||||
|
||||
Scope: `../promptkit-backend-rakestrawhome` only. Apply the same keyed-format,
|
||||
presence, notice, validation, and publication treatment as Stage 4 to its smaller
|
||||
inventory, including the `/v3` transition and keyed `backends.rakestrawhome`
|
||||
definition in `catalog/backend.yml`. Preserve backend endpoint, credentials
|
||||
metadata, and capacity policy.
|
||||
Do not couple its date/revision to OpenRouter's release.
|
||||
|
||||
Exit: both compatible catalog versions are published and ordinarily resolvable
|
||||
before Stage 6 pins them. If permission or publication is unavailable, stop at
|
||||
this external dependency checkpoint; do not commit a local replace, guessed
|
||||
version, or legacy-to-new runtime converter to bypass it.
|
||||
|
||||
## Stage 6 — Integrate Catalog Sources And Maintained Profiles
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: `internal/catalog`, maintained definitions, catalog integration tests,
|
||||
dependency pins/notices. Keep this separate from public API migration.
|
||||
|
||||
1. Pin the published compatible catalog versions and exact attribution commits.
|
||||
Update imports to `/v3`. Replace manifest decoding with ConfigKit file Inputs.
|
||||
2. Supply backend and profile assets as ConfigKit Inputs in the first
|
||||
underlay. Supply PromptKit-owned keyed Mapping definitions as the next group:
|
||||
standard extends the selected catalog's current maintained base; auto retains
|
||||
its current direct model/backend definition. Preserve both literal names.
|
||||
3. Retire source-layout allowlists, catalog preflight, source ownership/reserved
|
||||
prefix checks, connection-field prohibitions, and secret-key source rejection.
|
||||
The adapter selects known asset entry points and discovers configuration files;
|
||||
ConfigKit acquires their contents. Domain validation sees only the final
|
||||
assembled configuration. Consumers may overlay catalog backends, profiles,
|
||||
and their bases through the same configuration model.
|
||||
4. Test real imported assets offline with the new loader, maintained-name
|
||||
resolution, consumer override of a maintained base/backend, and rejection of
|
||||
an invalid final backend. Test a replaced lower-layer domain error succeeds.
|
||||
Do not independently validate catalog-only intermediate configurations, parse
|
||||
raw assets in PromptKit tests, or audit origins to establish ownership.
|
||||
|
||||
Exit: catalog and owned definitions participate in the new keyed configuration
|
||||
model without a legacy profile parser on that path. Catalog integration tests
|
||||
pass against released modules with `GOWORK=off` and no replacements.
|
||||
|
||||
## Stage 7 — Cut Over Engine Assembly And Public Construction
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: `engine.go`, configuration facade/helpers, `internal/engine`, facade
|
||||
adapters, and migration of their direct test setup.
|
||||
|
||||
1. Introduce the Construction Surface exactly as specified above. Remove the
|
||||
superseded source/profile options and convenience types rather than keeping
|
||||
deprecated pass-through wrappers. Keep intentional runtime injection APIs.
|
||||
2. Load the combined backend/profile/prompt configuration, construct its runtime
|
||||
registry and indexed snapshot, then assemble renderer, validator, capacity
|
||||
manager, and runner. Return no Engine on construction failure.
|
||||
Translate construction errors to ErrInvalidConfig while preserving LoadError
|
||||
via errors.As. Resource errors include semantic path and safe source context.
|
||||
3. Connect indexed prompt/profile lookups to preparation and inspection. Missing
|
||||
selections keep meaningful not-found errors; prompt version ambiguity stays a
|
||||
selection error. Old parse/load error categories need not be manufactured for
|
||||
errors now detected at construction.
|
||||
4. Move root test fixtures/setup and maintained example construction to ConfigKit
|
||||
sources, keyed definitions, ProfileSource, and BackendSource so all consumers
|
||||
still compile. Update local-backend examples to choose their ID as a map key.
|
||||
Update expected timing only for the deliberate eager-validation change. Keep
|
||||
behavioral assertions for prompting, overrides, and errors.
|
||||
5. Add public tests for a minimal custom local backend and programmatic profile,
|
||||
plus embedded defaults/operator/programmatic tiers. No elaborate consumer
|
||||
setup should be needed merely to send one prompt.
|
||||
|
||||
Exit: the public Engine uses only the new loading path. Root/package tests pass;
|
||||
all package/example sources compile. Update GoDoc and consumer construction
|
||||
guidance in this stage so removed options are not presented as usable.
|
||||
|
||||
## Stage 8 — Complete Frozen Rendering And Lifecycle Integration
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: renderer/runtime consumption of the snapshot, ownership, and lifecycle
|
||||
regressions. Stage 2 already owns acquisition and static syntax validation.
|
||||
|
||||
1. Use captured text and parsed templates without per-run configuration reads,
|
||||
parsing, or inheritance. Bind input helpers per invocation safely; keep missing
|
||||
request data errors and direct-session template bypass behavior.
|
||||
2. Verify preparation and execution still handle artifacts and schema compilation
|
||||
at invocation time, honor output overrides, freeze prepared resources, and
|
||||
keep prepared handles bound to their engine. Credentials remain at runtime.
|
||||
3. Test an old engine after configuration/resource edits and a newly constructed
|
||||
engine observing the edits. Test concurrent renders with different Vars/input
|
||||
helpers and no data leakage. Use an FS that rejects reads after construction
|
||||
to demonstrate no configuration/content-file reopening during ordinary runs;
|
||||
do not incorrectly prohibit request artifact/schema reads.
|
||||
4. Preserve appended-message ordering, structural repair, retries, admission,
|
||||
cancellation, observers, and result metadata through existing regression
|
||||
tests; add only distinct missing cross-boundary cases.
|
||||
|
||||
Exit: focused race tests pass for assembled rendering/preparation and lifecycle.
|
||||
No retained callbacks capture ConfigKit resolution contexts or request state.
|
||||
|
||||
## Stage 9 — Expose Explicit Configuration Inspection
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: the read-only facade and retained ConfigKit inspection adapter.
|
||||
|
||||
1. Implement ConfigurationInspection and its specified methods without exposing
|
||||
Result.Config or filesystem capabilities. Retain a private narrow view of the
|
||||
ConfigKit result; no regeneration of origins from runtime values.
|
||||
2. Ensure effective/explanation reports use ConfigKit redaction, including the
|
||||
selected sensitivity predicate. Preserve safe value-free domain diagnostics.
|
||||
3. Test a targeted literal-key path with layered/inherited history, detached
|
||||
returned slices/records, writer failures, zero/nil behavior, and redacted secret
|
||||
shaped provider values. Demonstrate inspection still works after source removal
|
||||
and makes no filesystem/provider calls.
|
||||
4. Document construction inspection versus InspectProfile and prepared details.
|
||||
Consumers choose report disclosure; do not add CLI commands or automatic logs.
|
||||
|
||||
Exit: public inspection tests pass, including concurrent inspection/execution.
|
||||
The runner has no reference to the inspector or ConfigKit metadata types.
|
||||
|
||||
## Stage 10 — Migrate Examples And Canonical Format Guidance
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: maintained examples and user-facing documentation.
|
||||
|
||||
1. Complete the preparation, execution, and observation example migration begun
|
||||
at public cutover. Preserve their distinct existing semantic assertions; extend
|
||||
the preparation example with a small explicit inspection query if useful.
|
||||
2. Update `docs/formats.md` to own the new keyed syntax, source-group semantics,
|
||||
inheritance/presence/null table, eager validation, snapshot lifetime, and
|
||||
resource-context rules. Link ConfigKit for generic grammar; do not copy its
|
||||
complete parser/inheritance contract.
|
||||
3. Update README and `docs/consumers/pkg-promptkit.md` with minimal local-backend,
|
||||
file/embedded source, programmatic Mapping/ProfileSource, explicit inspection,
|
||||
and engine-replacement workflows. Explain main versus ordered underlays and
|
||||
backend layering in place of registration-only restrictions.
|
||||
4. Add concise old-to-new migration guidance in the consumer guide: removed API
|
||||
options, exact IDs, `base_profile` to `extends`, zero/false/null, recursive maps,
|
||||
no clear via `{}`, reserved `extends` runtime escape, failure timing, and the
|
||||
fact that changing a model/backend does not clear unrelated explicit fields.
|
||||
Remove old automatic reload claims. Release notes later link to this owner.
|
||||
|
||||
Exit: all three offline examples run with their semantic checks; documentation
|
||||
links and snippets match the implemented API. No unreleased version is tagged or
|
||||
published, and no speculative release-note version is invented.
|
||||
|
||||
## Stage 11 — Remove Superseded Machinery And Consolidate Tests
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: dead loading/resolution code and its obsolete tests, module cleanup, and
|
||||
internal architectural documentation.
|
||||
|
||||
1. Delete lazy prompt/profile metadata prepasses, filesystem repositories,
|
||||
fallback-on-error overlays, custom base-chain resolver/depth cap, zero-sentinel
|
||||
profile merging, and temporary stage adapters once no caller needs them.
|
||||
Remove the separate backend-manifest decoder, source-policy audits, and
|
||||
pre-handoff backend registration validation. Keep final-value domain checks.
|
||||
Retain narrow lookup interfaces only where they still simplify real consumers.
|
||||
2. Retain useful filecatalog/schema utilities and JSON-value ownership/validation.
|
||||
Remove direct yaml.v3 use and dependency if no remaining owner requires it;
|
||||
do not claim all YAML dependencies disappear when ConfigKit still uses YAML.
|
||||
3. Move meaningful regressions from deleted package tests to their new owning
|
||||
boundary. Retire tests about old internals, repeated parser coverage,
|
||||
source-policy enforcement, or superseded semantics. Do not replace lost runtime
|
||||
protection with loader-only tests. Keep private assembly checks separate from
|
||||
external facade tests.
|
||||
4. Update `docs/internal/overview.md`, `docs/internal/sources.md`, relevant renderer
|
||||
and engine documents, and the implemented-structure paragraphs in architecture
|
||||
policy. Describe the new package/dependency boundary without turning names or
|
||||
chosen adapters into permanent invariants. Update OSS notices with module tidy.
|
||||
|
||||
Exit: no legacy source path remains, no internal package imports the public root,
|
||||
runtime/domain packages do not import ConfigKit, and `go test ./...` passes with
|
||||
the reduced implementation/test inventory.
|
||||
|
||||
## Stage 12 — Validate Scale And The Complete Consumer Workflow
|
||||
|
||||
**Completed:** 2026-09-30.
|
||||
|
||||
Scope: final integration evidence and high-confidence simplification, not another
|
||||
feature expansion or release gate.
|
||||
|
||||
1. Add focused benchmarks for construction with many independent definitions,
|
||||
shared inherited bases, and several tiers; add an already-constructed selection
|
||||
benchmark. Report allocations and input/output shape; inherited materialized
|
||||
output/history may dominate. Do not impose timing thresholds or new caps.
|
||||
2. Inspect for repeated full-map copies, per-run provenance calls, rescans,
|
||||
repeated inheritance, and unnecessary double parsing. Fix demonstrated local
|
||||
inefficiencies with straightforward ownership/indexing; no global cache.
|
||||
3. Run the repository's existing complete local check script once on the final
|
||||
candidate, including race tests and offline examples. Investigate failures;
|
||||
do not weaken meaningful tests to get a green result. Stage intended new
|
||||
files before the script's tracked-file checks.
|
||||
4. Review the feature-roadmap capability list against actual public workflows:
|
||||
independent inspection, partial profile completion, optional credentials,
|
||||
source contexts, artifacts/schemas, prepared ownership, backend capacity,
|
||||
overrides, appended messages, repair/retries, observations, and hashes.
|
||||
5. Report completed stages, selected catalog versions, validation evidence, and
|
||||
remaining issues. Minor-release documentation/version selection and roadmap
|
||||
retirement belong to the separately authorized release preparation workflow.
|
||||
|
||||
Exit: the feature roadmap's end state is demonstrably implemented, with one
|
||||
configuration lifecycle, explicit operator inspection, no legacy loader, and
|
||||
no newly invented provider checks or human release gates.
|
||||
|
||||
The catalog-validation placement question is resolved by the configuration-boundary
|
||||
ADR. No design question remains open here. Catalog publication authority and
|
||||
ordinary module availability remain operational checkpoints for the catalog stages.
|
||||
@@ -1,126 +0,0 @@
|
||||
# ConfigKit Adoption Review Findings
|
||||
|
||||
Review date: 2026-10-03. Reviewed implementation commits `2b847ac` through
|
||||
`66c55b7` against the [feature roadmap](configkit.md),
|
||||
[implementation plan](implementation.md), and accepted
|
||||
[configuration-boundary ADR](../adr/0001-configuration-boundary.md).
|
||||
|
||||
## Summary
|
||||
|
||||
The principal architecture is in place: one ConfigKit load assembles backend,
|
||||
profile, and prompt configuration; PromptKit validates effective values, captures
|
||||
resources/templates, and builds execution-owned indexes. Catalog preflight and
|
||||
legacy profile resolution are removed. Operator inspection delegates to the
|
||||
retained ConfigKit result separately from ordinary execution.
|
||||
|
||||
Both findings have been addressed. The original review evidence is retained
|
||||
below; the resolution section records the changes and regression coverage.
|
||||
|
||||
## 1. P2 — Message Normalization Loses Content-File Resource Origins
|
||||
|
||||
Location: `internal/configuration/prompts.go`, particularly `resolvePrompts`
|
||||
lines 69–99 and `readContent`'s effective-origin lookup.
|
||||
|
||||
When a role or cache-control value needs normalization, `resolvePrompts` records
|
||||
a normalization operation for the entire `messages` sequence in addition to the
|
||||
field-level operations. The resulting metadata no longer provides the source
|
||||
location needed by `readContent` for an unchanged `content_file`. Construction
|
||||
then rejects an otherwise valid prompt.
|
||||
|
||||
Confirmed through the public API using FilesystemInputs over an fs.FS containing
|
||||
this document and a readable `body.txt`:
|
||||
|
||||
```yaml
|
||||
prompts:
|
||||
p:
|
||||
v1:
|
||||
messages:
|
||||
- role: " USER "
|
||||
content_file: body.txt
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
```
|
||||
|
||||
NewEngine fails with
|
||||
`/prompts/p/v1/messages/0/content_file: content_file needs one contributing source with a filesystem context`.
|
||||
Changing only the role to `user` succeeds. Role trimming/lowercasing and
|
||||
source-relative message capture are both intended capabilities; requiring
|
||||
already-normalized metadata is an accidental restriction.
|
||||
|
||||
Required outcome:
|
||||
|
||||
- Preserve ConfigKit's effective resource context while normalizing neighboring
|
||||
message metadata. Review the broad sequence normalization record and how the
|
||||
final input origin is retained across domain normalization.
|
||||
- Use effective handoff metadata/context, not historical traversal, raw-source
|
||||
inspection, source rereading, or an invented fallback directory.
|
||||
- Add focused regressions combining file-backed content with role and
|
||||
cache-control normalization, including inherited/imported content. Existing
|
||||
tests cover normalization and resource capture separately but miss this
|
||||
interaction. Verify inspection remains accurate for untouched content fields.
|
||||
|
||||
## 2. P2 — Profiles Gain An Unrequested Environment-Name Restriction
|
||||
|
||||
Location: `internal/configuration/load.go`, profile validation at lines 278–279,
|
||||
using the package's `envName` regular expression.
|
||||
|
||||
Profile `api_key_env` values now must match `[A-Za-z_][A-Za-z0-9_]*`. The previous
|
||||
profile path accepted environment lookup names without this shell-identifier
|
||||
restriction; the regular expression previously governed backend registration.
|
||||
The refactor extends it to profiles even though arbitrary environment names
|
||||
supported by the host remain usable through Go's environment lookup and the
|
||||
request override path.
|
||||
|
||||
Confirmed through NewEngine with an otherwise valid endpoint/model profile:
|
||||
`api_key_env: NORMAL_KEY` succeeds, while `api_key_env: service.api-key` fails
|
||||
construction. The latter is a usable environment name on the reviewed Linux
|
||||
platform. This restriction is unrelated to configuration layering, ConfigKit
|
||||
typing, or optional credential availability.
|
||||
|
||||
The implementation plan's reference to existing naming rules is not evidence
|
||||
of an express user decision to introduce this new profile restriction. The
|
||||
requested compatibility break permits adopting ConfigKit semantics; it does
|
||||
not itself authorize additional unrelated name constraints.
|
||||
|
||||
Required outcome:
|
||||
|
||||
- Remove the newly introduced profile shell-identifier check, preserving the
|
||||
established trimming, explicit clearing, optional environment lookup, and
|
||||
direct-key precedence behavior.
|
||||
- Add a public-path regression for a supported non-shell-identifier environment
|
||||
name and for its unset/empty optional behavior, with controlled test environment
|
||||
state and a synthetic credential. Keep any prerequisite validation limited to
|
||||
actual supported environment lookup requirements.
|
||||
- Correct the plan's profile-field guidance so later work does not reintroduce
|
||||
this restriction. Treat any broader change to existing backend naming policy
|
||||
separately rather than silently extending or weakening it in this fix.
|
||||
|
||||
## Validation Performed
|
||||
|
||||
- `go test ./...` passed on Go 1.26.8.
|
||||
- `GOTOOLCHAIN=go1.25.5 go test ./...` passed on the declared minimum toolchain.
|
||||
- `go test -race ./...` passed.
|
||||
- Separate offline public-API reproductions confirmed both findings. Missing
|
||||
message/session variables also remained runtime errors in targeted checks.
|
||||
|
||||
The passing suite does not cover the two failing cases above. No provider
|
||||
requests were made. Beyond these findings, the reviewed implementation did not
|
||||
show another actionable ownership-boundary violation or strengthened guarantee.
|
||||
|
||||
## Resolution
|
||||
|
||||
1. Message role/cache-control normalization now occurs on execution-owned copies
|
||||
during materialization. ConfigKit's captured message sequence and its origins
|
||||
remain intact. The imported/inherited content regression now includes role
|
||||
and cache-control normalization and asserts the unchanged field's source in
|
||||
inspection metadata. This also removes duplicate cache-control normalization.
|
||||
2. Removed the profile environment-name regular expression check, leaving backend
|
||||
validation unchanged. A public engine/HTTP-transport regression covers a
|
||||
non-shell environment name, unset/empty/whitespace values, explicit clearing,
|
||||
trimming, and direct-key precedence. The implementation plan now states this
|
||||
distinction explicitly.
|
||||
|
||||
Both regressions failed against the reviewed implementation before the fixes.
|
||||
After remediation, the full ordinary and race-enabled suites, affected packages
|
||||
on Go 1.25.5, `go vet ./...`, and documentation/whitespace checks passed.
|
||||
99
engine.go
99
engine.go
@@ -24,9 +24,8 @@ var (
|
||||
// ErrInvalidRequest identifies a request whose required values, overrides,
|
||||
// credentials, or effective settings are invalid.
|
||||
ErrInvalidRequest = errors.New("invalid run request")
|
||||
// ErrPromptNotFound identifies a requested prompt ID or version that is not
|
||||
// present in the selected prompt source. It does not also match
|
||||
// ErrPromptLoad.
|
||||
// ErrPromptNotFound identifies a requested prompt ID that is not present in
|
||||
// the captured configuration. It does not also match ErrPromptLoad.
|
||||
ErrPromptNotFound = errors.New("prompt not found")
|
||||
// ErrProfileNotFound identifies a selected profile ID that is absent from
|
||||
// every configured profile source. It does not also match ErrProfileLoad.
|
||||
@@ -82,16 +81,21 @@ var (
|
||||
ErrValidation = errors.New("failed to validate output")
|
||||
)
|
||||
|
||||
// Engine inspects prompts and profiles and prepares and runs Promptkit prompt
|
||||
// requests.
|
||||
// Engine inspects prompts and profiles, enumerates prompt requirements, and
|
||||
// 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.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||
// [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent
|
||||
// backend-capacity pools that coordinate Run and RunPrepared admission and
|
||||
// model generation. Injected collaborators may still be invoked concurrently
|
||||
// across different backend pools or for unlimited backends.
|
||||
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||
// [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles],
|
||||
// [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and
|
||||
// [Engine.ListCollections]. Engines constructed separately by [NewEngine] own
|
||||
// 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 {
|
||||
internal *internalengine.Engine
|
||||
}
|
||||
@@ -208,11 +212,11 @@ func WithSchemaFile(path string) Option {
|
||||
|
||||
// NewEngine constructs an Engine from configuration and options.
|
||||
//
|
||||
// ConfigKit composes maintained catalog inputs, PromptKit-owned definitions,
|
||||
// caller underlays, and the optional main input in that order. Construction
|
||||
// validates the final effective backend, profile, and prompt definitions and
|
||||
// captures referenced prompt content. A missing prompt collection is allowed.
|
||||
// Schema documents and request artifacts remain runtime resources.
|
||||
// ConfigKit composes caller-selected underlays in their given order, followed
|
||||
// by the optional main input. Construction validates the final effective
|
||||
// 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
|
||||
// configuration or options, preserving a ConfigKit LoadError when loading
|
||||
@@ -241,14 +245,13 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
return &Engine{internal: assembled}, nil
|
||||
}
|
||||
|
||||
// InspectPrompt resolves one explicit prompt definition without selecting a
|
||||
// profile or starting execution work.
|
||||
// InspectPrompt resolves one prompt ID without selecting a profile or starting
|
||||
// execution work.
|
||||
//
|
||||
// InspectPrompt requires a nonblank promptID. It passes nonblank promptID and
|
||||
// promptVersion values unchanged to the engine's ordinary, case-sensitive
|
||||
// prompt selection. An empty version succeeds only when that ID has one
|
||||
// version; a nonempty version selects one exact ID/version pair from the
|
||||
// captured configuration snapshot.
|
||||
// InspectPrompt requires a nonblank promptID and performs an exact,
|
||||
// case-sensitive lookup in the captured configuration snapshot. A
|
||||
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
||||
// qualified IDs pass through. Prompt version labels do not select definitions.
|
||||
//
|
||||
// A successful result proves that the selected definition and any referenced
|
||||
// message content files were structurally loaded. Inputs are returned in
|
||||
@@ -267,21 +270,19 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
// or content files; construct another Engine to observe source changes.
|
||||
//
|
||||
// A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID
|
||||
// matches ErrInvalidRequest. An absent exact ID or version matches
|
||||
// ErrPromptNotFound and not ErrPromptLoad. An ambiguous omitted version matches
|
||||
// ErrPromptLoad. Invalid definitions and resource failures reject construction
|
||||
// instead. Cancellation during lookup preserves the context error. InspectPrompt
|
||||
// returns no partial result on error.
|
||||
// matches ErrInvalidRequest. An absent exact ID matches ErrPromptNotFound and
|
||||
// not ErrPromptLoad. Invalid definitions and resource failures reject
|
||||
// construction instead. Cancellation during lookup preserves the context
|
||||
// error. InspectPrompt returns no partial result on error.
|
||||
func (e *Engine) InspectPrompt(
|
||||
ctx context.Context,
|
||||
promptID string,
|
||||
promptVersion string,
|
||||
) (*PromptInspection, error) {
|
||||
if e == nil || e.internal == nil {
|
||||
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 {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -346,6 +347,32 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
|
||||
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
||||
// error. It validates RunRequest.TransientRetries but makes no model attempts.
|
||||
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)
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
@@ -360,15 +387,16 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
return fromDomainPreparedRun(prepared), nil
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// PrepareExecution completely prepares a prompt request without calling the
|
||||
// configured LLMClient or reserving backend admission capacity. Validated
|
||||
// RunRequest.AppendedMessages are included in the frozen effective messages.
|
||||
//
|
||||
// The returned opaque handle is bound to this Engine and permits one
|
||||
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
||||
// The returned opaque handle is bound to this Engine's underlying execution
|
||||
// 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
|
||||
// metadata, and validation resources needed by that invocation. The handle
|
||||
// retains a direct RunRequest.APIKey only in private execution state;
|
||||
@@ -480,6 +508,11 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
// RunPrepared atomically claims and executes a handle created by
|
||||
// [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
|
||||
// credential revalidation, backend admission, generation, or validation.
|
||||
// Cancellation, capacity rejection, generation failure, operational
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
prompts:
|
||||
example.assessment:
|
||||
"1.0.0":
|
||||
default_profile: assessment-profile
|
||||
description: Assess a structured proposal independently of schema validity.
|
||||
inputs:
|
||||
- name: proposal
|
||||
required: true
|
||||
content_type: application/json
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise assessment as JSON.
|
||||
- role: user
|
||||
content: '{{input "proposal"}}'
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: assessment.schema.json
|
||||
version: "1.0.0"
|
||||
default_profile: assessment-profile
|
||||
description: Assess a structured proposal independently of schema validity.
|
||||
inputs:
|
||||
- name: proposal
|
||||
required: true
|
||||
content_type: application/json
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise assessment as JSON.
|
||||
- role: user
|
||||
content: '{{input "proposal"}}'
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: assessment.schema.json
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
prompts:
|
||||
example.proposal:
|
||||
"1.0.0":
|
||||
default_profile: proposal-profile
|
||||
description: Propose a bounded improvement from a synthetic idea.
|
||||
inputs:
|
||||
- name: idea
|
||||
required: true
|
||||
content_type: text/plain
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise proposal as JSON.
|
||||
- role: user
|
||||
content: '{{input "idea"}}'
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: proposal.schema.json
|
||||
version: "1.0.0"
|
||||
default_profile: proposal-profile
|
||||
description: Propose a bounded improvement from a synthetic idea.
|
||||
inputs:
|
||||
- name: idea
|
||||
required: true
|
||||
content_type: text/plain
|
||||
messages:
|
||||
- role: system
|
||||
content: Return a concise proposal as JSON.
|
||||
- role: user
|
||||
content: '{{input "idea"}}'
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: proposal.schema.json
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
backends:
|
||||
offline:
|
||||
endpoint: https://review.example.invalid/v1
|
||||
profiles:
|
||||
audit:
|
||||
backend: review/offline
|
||||
model: review-model
|
||||
prompts:
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
version: "1"
|
||||
configkit:
|
||||
imports:
|
||||
- path: fragments/resources.yaml
|
||||
release_version: "1.4.0"
|
||||
@@ -0,0 +1,28 @@
|
||||
backends:
|
||||
offline:
|
||||
endpoint: https://example.invalid/v1
|
||||
profiles:
|
||||
base:
|
||||
backend: support/offline
|
||||
model: offline-model
|
||||
timeout_seconds: 90
|
||||
fast:
|
||||
extends: base
|
||||
timeout_seconds: 60
|
||||
prompts:
|
||||
prepare:
|
||||
version: "1.0.0"
|
||||
default_profile: support/fast
|
||||
description: Prepare a prompt without contacting a model provider.
|
||||
inputs:
|
||||
- name: note
|
||||
required: true
|
||||
content_type: text/plain
|
||||
messages:
|
||||
- role: system
|
||||
content: Summarize the note in one sentence.
|
||||
- role: user
|
||||
content_file: ../messages/note.md
|
||||
output:
|
||||
format: text
|
||||
validation_mode: basic
|
||||
@@ -0,0 +1 @@
|
||||
{{input "note"}}
|
||||
@@ -0,0 +1,4 @@
|
||||
# A later input intentionally replaces only the inherited profile timeout.
|
||||
profiles:
|
||||
fast:
|
||||
timeout_seconds: 30
|
||||
@@ -11,41 +11,138 @@ import (
|
||||
)
|
||||
|
||||
type summary struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
PromptVersion string `json:"prompt_version"`
|
||||
SelectedProfile string `json:"selected_profile"`
|
||||
Model string `json:"model"`
|
||||
MessageCount int `json:"message_count"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
PromptVersion *string `json:"prompt_version,omitempty"`
|
||||
SelectedProfile string `json:"selected_profile"`
|
||||
Model string `json:"model"`
|
||||
MessageCount int `json:"message_count"`
|
||||
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() {
|
||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||
support, err := promptkit.CollectionInput(
|
||||
"support",
|
||||
configkit.File("examples/go-library/prepare/collections/support/collection.yaml"),
|
||||
)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
review, err := promptkit.CollectionInput(
|
||||
"review",
|
||||
configkit.File("examples/go-library/prepare/collections/review/collection.yaml"),
|
||||
)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
override, err := promptkit.CollectionInput(
|
||||
"support",
|
||||
configkit.File("examples/go-library/prepare/collections/support/operator-overrides.yaml"),
|
||||
)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{
|
||||
"offline-example": {Endpoint: &endpoint, Model: &model},
|
||||
})}}}},
|
||||
Main: &configkit.Input{Source: configkit.File("examples/go-library/prepare/prompt.yaml")},
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{support, review}},
|
||||
{Inputs: []configkit.Input{override}},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
||||
Prompt: "support", Profile: "support", Backend: "support",
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
view, err := engine.InspectConfiguration()
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
if _, found := view.Explain(configkit.Keys("profiles", "offline-example", "model")); !found {
|
||||
exit(fmt.Errorf("configured profile model was not found"))
|
||||
if _, found := view.Explain(configkit.Keys(
|
||||
"collections", "support", "profiles", "fast", "timeout_seconds",
|
||||
)); !found {
|
||||
exit(fmt.Errorf("collection profile override was not found"))
|
||||
}
|
||||
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: "example.prepare",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"note": promptkit.Inline("Ada finished the migration review."),
|
||||
},
|
||||
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||
PromptID: "prepare",
|
||||
})
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
||||
requirements.Inputs[0].Name != "note" ||
|
||||
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
||||
exit(fmt.Errorf("required note input was not discovered before preparation"))
|
||||
}
|
||||
|
||||
request := promptkit.RunRequest{
|
||||
PromptID: "prepare",
|
||||
ProfileID: "fast",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"note": promptkit.Inline("Ada finished the migration review."),
|
||||
},
|
||||
}
|
||||
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
||||
exit(err)
|
||||
}
|
||||
prepared, err := scoped.Prepare(ctx, request)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
profile, err := scoped.InspectProfile(ctx, "fast")
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
||||
exit(fmt.Errorf(
|
||||
"later profile override was not applied: timeout is %d seconds",
|
||||
profile.EffectiveModelParams.TimeoutSeconds,
|
||||
))
|
||||
}
|
||||
namespaces, err := scoped.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
collections, err := scoped.ListCollections(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
scopedPrompts, err := scoped.ListPrompts(ctx)
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
||||
Mode: promptkit.NamespaceNamed, Name: "review",
|
||||
})
|
||||
if err != nil {
|
||||
exit(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.SetIndent("", " ")
|
||||
@@ -55,6 +152,11 @@ func main() {
|
||||
SelectedProfile: prepared.SelectedProfileID,
|
||||
Model: prepared.EffectiveModelParams.Model,
|
||||
MessageCount: len(prepared.Messages),
|
||||
ProfileTimeout: profile.EffectiveModelParams.TimeoutSeconds,
|
||||
Namespaces: namespaces,
|
||||
Collections: collectionSummaries,
|
||||
ScopedPrompts: scopedPromptIDs,
|
||||
ReviewPrompts: reviewPromptIDs,
|
||||
}); err != nil {
|
||||
exit(err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,17 +1,17 @@
|
||||
prompts:
|
||||
example.run:
|
||||
"1.0.0":
|
||||
default_profile: offline-example
|
||||
description: Run a prompt with a deterministic injected model client.
|
||||
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
|
||||
version: "1.0.0"
|
||||
default_profile: offline-example
|
||||
description: Run a prompt with a deterministic injected model client.
|
||||
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
|
||||
|
||||
@@ -18,9 +18,8 @@ func (r RunRequest) GoString() string {
|
||||
|
||||
func (r RunRequest) redactedString() string {
|
||||
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.PromptVersion,
|
||||
r.ProfileID,
|
||||
r.APIKey != "",
|
||||
len(r.Inputs),
|
||||
|
||||
@@ -19,15 +19,15 @@ import (
|
||||
const capturedPromptDocument = `
|
||||
prompts:
|
||||
captured:
|
||||
"1":
|
||||
default_profile: offline
|
||||
session_id: '{{.session}}'
|
||||
messages:
|
||||
- role: user
|
||||
content_file: body.tmpl
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
version: "1"
|
||||
default_profile: offline
|
||||
session_id: '{{.session}}'
|
||||
messages:
|
||||
- role: user
|
||||
content_file: body.tmpl
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`
|
||||
|
||||
func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
||||
@@ -149,12 +149,13 @@ func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *tes
|
||||
"offline": {Endpoint: &endpoint, Model: &model},
|
||||
})}}}},
|
||||
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||
"prompts": map[string]any{"structured": map[string]any{"1": map[string]any{
|
||||
"prompts": map[string]any{"structured": map[string]any{
|
||||
"version": "1",
|
||||
"default_profile": "offline",
|
||||
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"},
|
||||
}}},
|
||||
}},
|
||||
})},
|
||||
}}, promptkit.WithSchemaFS(schemas, "."))
|
||||
if err != nil {
|
||||
|
||||
11
go.mod
11
go.mod
@@ -1,15 +1,18 @@
|
||||
module gitea.maximumdirect.net/eric/promptkit
|
||||
|
||||
go 1.25.5
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
|
||||
16
go.sum
16
go.sum
@@ -1,16 +1,16 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
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=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
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/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=
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
)
|
||||
|
||||
const fileReadChunkSize = 64 * 1024
|
||||
const fileReadInitialCapacityLimit = 1 << 20
|
||||
|
||||
// Reader resolves artifact references into actual artifacts.
|
||||
type Reader interface {
|
||||
@@ -133,7 +134,11 @@ func readFileArtifact(ctx context.Context, path string, open func(string) (artif
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFile, path)
|
||||
}
|
||||
|
||||
data := make([]byte, 0)
|
||||
capacity := 0
|
||||
if size := openedInfo.Size(); size > 0 {
|
||||
capacity = int(min(size, int64(fileReadInitialCapacityLimit)))
|
||||
}
|
||||
data := make([]byte, 0, capacity)
|
||||
chunk := make([]byte, fileReadChunkSize)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -153,6 +158,13 @@ func readFileArtifact(ctx context.Context, path string, open func(string) (artif
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", path, readErr)
|
||||
}
|
||||
}
|
||||
if cap(data)-len(data) > len(data) {
|
||||
// A stale size hint should not make a small completed artifact retain a
|
||||
// disproportionately large backing array.
|
||||
compact := make([]byte, len(data))
|
||||
copy(compact, data)
|
||||
data = compact
|
||||
}
|
||||
|
||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||
if contentType == "" {
|
||||
|
||||
180
internal/artifact/reader_benchmark_test.go
Normal file
180
internal/artifact/reader_benchmark_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var artifactFileReadBenchmarkSink *domain.Artifact
|
||||
|
||||
func BenchmarkArtifactFileRead(b *testing.B) {
|
||||
for _, fixture := range makeArtifactReadBenchmarkFiles(b) {
|
||||
b.Run(fixture.name, func(b *testing.B) {
|
||||
reader := &fileReader{open: openArtifactFile}
|
||||
ref := domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: fixture.path}
|
||||
checkArtifactFileRead(b, reader, ref, fixture.body)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(fixture.body)))
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
artifact, err := reader.Read(context.Background(), ref)
|
||||
if err != nil {
|
||||
b.Fatalf("read artifact: %v", err)
|
||||
}
|
||||
artifactFileReadBenchmarkSink = artifact
|
||||
}
|
||||
b.StopTimer()
|
||||
runtime.KeepAlive(artifactFileReadBenchmarkSink)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRetainedArtifactFileRead measures live artifact bodies when an
|
||||
// opened file reports a size larger than the content that reaches EOF.
|
||||
func BenchmarkRetainedArtifactFileRead(b *testing.B) {
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{name: "small", body: bytes.Repeat([]byte("x"), 256<<10)},
|
||||
{name: "empty", body: []byte{}},
|
||||
} {
|
||||
b.Run(fixture.name, func(b *testing.B) {
|
||||
benchmarkRetainedArtifactFileRead(b, fixture.body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkRetainedArtifactFileRead(b *testing.B, body []byte) {
|
||||
path := filepath.Join(b.TempDir(), "overreported.txt")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
mime.TypeByExtension(filepath.Ext(path))
|
||||
|
||||
reader := &fileReader{open: func(path string) (artifactFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sizeReportingArtifactFile{artifactFile: file, reportedSize: 16 << 20}, nil
|
||||
}}
|
||||
ref := domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
|
||||
checkArtifactFileRead(b, reader, ref, body)
|
||||
|
||||
const retainedBatchSize = 4
|
||||
retained := make([]*domain.Artifact, retainedBatchSize)
|
||||
artifactFileReadBenchmarkSink = nil
|
||||
runtime.GC()
|
||||
var setupMemory runtime.MemStats
|
||||
runtime.ReadMemStats(&setupMemory)
|
||||
runtime.KeepAlive(reader)
|
||||
runtime.KeepAlive(ref)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(body)))
|
||||
b.ResetTimer()
|
||||
for iteration := 0; iteration < b.N; iteration++ {
|
||||
artifact, err := reader.Read(context.Background(), ref)
|
||||
if err != nil {
|
||||
b.Fatalf("read retained artifact: %v", err)
|
||||
}
|
||||
retained[iteration%len(retained)] = artifact
|
||||
}
|
||||
b.StopTimer()
|
||||
for i, artifact := range retained {
|
||||
if artifact != nil {
|
||||
continue
|
||||
}
|
||||
artifact, err := reader.Read(context.Background(), ref)
|
||||
if err != nil {
|
||||
b.Fatalf("fill retained artifact batch: %v", err)
|
||||
}
|
||||
retained[i] = artifact
|
||||
}
|
||||
runtime.GC()
|
||||
var retainedMemory runtime.MemStats
|
||||
runtime.ReadMemStats(&retainedMemory)
|
||||
b.ReportMetric(float64(int64(retainedMemory.HeapAlloc)-int64(setupMemory.HeapAlloc)), "retained-B")
|
||||
runtime.KeepAlive(retained)
|
||||
runtime.KeepAlive(reader)
|
||||
runtime.KeepAlive(ref)
|
||||
}
|
||||
|
||||
type artifactReadBenchmarkFile struct {
|
||||
name string
|
||||
path string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func makeArtifactReadBenchmarkFiles(b *testing.B) []artifactReadBenchmarkFile {
|
||||
b.Helper()
|
||||
fixtures := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{name: "small_4KiB", size: 4 << 10},
|
||||
{name: "1MiB", size: 1 << 20},
|
||||
{name: "large_4MiB", size: 4 << 20},
|
||||
}
|
||||
results := make([]artifactReadBenchmarkFile, 0, len(fixtures))
|
||||
for _, fixture := range fixtures {
|
||||
body := bytes.Repeat([]byte("x"), fixture.size)
|
||||
path := filepath.Join(b.TempDir(), fixture.name+".txt")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
mime.TypeByExtension(filepath.Ext(path))
|
||||
results = append(results, artifactReadBenchmarkFile{
|
||||
name: fixture.name,
|
||||
path: path,
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func checkArtifactFileRead(b *testing.B, reader Reader, ref domain.ArtifactRef, want []byte) {
|
||||
b.Helper()
|
||||
artifact, err := reader.Read(context.Background(), ref)
|
||||
if err != nil {
|
||||
b.Fatalf("check artifact read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(artifact.Body, want) || artifact.Size != int64(len(want)) {
|
||||
b.Fatalf("artifact size=%d body length=%d, want %d", artifact.Size, len(artifact.Body), len(want))
|
||||
}
|
||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(want))
|
||||
if artifact.Hash != wantHash {
|
||||
b.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
||||
}
|
||||
}
|
||||
|
||||
type sizeReportingArtifactFile struct {
|
||||
artifactFile
|
||||
reportedSize int64
|
||||
}
|
||||
|
||||
func (file *sizeReportingArtifactFile) Stat() (os.FileInfo, error) {
|
||||
info, err := file.artifactFile.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reportedArtifactFileInfo{FileInfo: info, size: file.reportedSize}, nil
|
||||
}
|
||||
|
||||
type reportedArtifactFileInfo struct {
|
||||
os.FileInfo
|
||||
size int64
|
||||
}
|
||||
|
||||
func (info reportedArtifactFileInfo) Size() int64 { return info.size }
|
||||
@@ -3,7 +3,9 @@ package artifact
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -232,6 +234,64 @@ func TestFileReaderFailuresAndMetadata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileReaderUsesOpenedSizeOnlyAsACapacityHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
reportedSize int64
|
||||
}{
|
||||
{name: "overreported size", body: []byte("actual file content"), reportedSize: 128},
|
||||
{name: "overreported size with empty content", body: []byte{}, reportedSize: 16 << 20},
|
||||
{name: "underreported size", body: bytes.Repeat([]byte("x"), fileReadChunkSize+17), reportedSize: 1},
|
||||
{name: "very large hint with small content", body: []byte("small actual content"), reportedSize: int64(^uint64(0) >> 1)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||
if err := os.WriteFile(filePath, tc.body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := &fileReader{open: func(path string) (artifactFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &reportedInfoFile{
|
||||
artifactFile: file,
|
||||
info: reportedFileSizeInfo{FileInfo: info, size: tc.reportedSize},
|
||||
}, nil
|
||||
}}
|
||||
|
||||
artifact, err := reader.Read(context.Background(), domain.ArtifactRef{
|
||||
Type: domain.ArtifactRefFile,
|
||||
URI: filePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact: %v", err)
|
||||
}
|
||||
if !bytes.Equal(artifact.Body, tc.body) {
|
||||
t.Fatalf("artifact body length=%d, want %d", len(artifact.Body), len(tc.body))
|
||||
}
|
||||
if artifact.Size != int64(len(tc.body)) {
|
||||
t.Fatalf("artifact size=%d, want %d", artifact.Size, len(tc.body))
|
||||
}
|
||||
if artifact.Name != filepath.Base(filePath) || artifact.URI != filePath || artifact.ContentType == "" {
|
||||
t.Fatalf("unexpected artifact metadata: name=%q URI=%q content type=%q", artifact.Name, artifact.URI, artifact.ContentType)
|
||||
}
|
||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(tc.body))
|
||||
if artifact.Hash != wantHash {
|
||||
t.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileReaderCancelsAfterReadProgress(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "artifact.bin")
|
||||
content := bytes.Repeat([]byte("x"), fileReadChunkSize*2)
|
||||
@@ -268,6 +328,13 @@ func (f *reportedInfoFile) Stat() (os.FileInfo, error) {
|
||||
return f.info, nil
|
||||
}
|
||||
|
||||
type reportedFileSizeInfo struct {
|
||||
os.FileInfo
|
||||
size int64
|
||||
}
|
||||
|
||||
func (info reportedFileSizeInfo) Size() int64 { return info.size }
|
||||
|
||||
type cancelAfterProgressFile struct {
|
||||
artifactFile
|
||||
cancel context.CancelFunc
|
||||
|
||||
@@ -7,13 +7,12 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
const (
|
||||
// OpenRouterID identifies the maintained OpenRouter backend.
|
||||
// OpenRouterID is the conventional OpenRouter backend identifier.
|
||||
OpenRouterID = "openrouter"
|
||||
// RakestrawHomeID identifies the maintained Rakestrawhome backend.
|
||||
// RakestrawHomeID is the conventional Rakestrawhome backend identifier.
|
||||
RakestrawHomeID = "rakestrawhome"
|
||||
)
|
||||
|
||||
@@ -31,13 +30,12 @@ type Registry struct {
|
||||
func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) {
|
||||
registry := &Registry{backends: make(map[string]domain.Backend, len(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.ExtraParams = params
|
||||
registry.backends[id] = definition
|
||||
cloned, err := domain.CloneBackend(definition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backend %q: %w", id, err)
|
||||
}
|
||||
registry.backends[id] = cloned
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
@@ -51,12 +49,11 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
||||
if !ok {
|
||||
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||
}
|
||||
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||
cloned, err := domain.CloneBackend(definition)
|
||||
if err != nil {
|
||||
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
||||
}
|
||||
definition.ExtraParams = extraParams
|
||||
return definition, nil
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
// CapacityPolicies returns a copy of the normalized policies for limited
|
||||
|
||||
@@ -11,25 +11,61 @@ import (
|
||||
|
||||
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
||||
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{
|
||||
" local.v1 ": {
|
||||
Endpoint: "https://backend.example/v1", ExtraParams: params,
|
||||
ConcurrencyLimit: 2, QueueCapacity: 3, QueueCapacitySet: true,
|
||||
SmokeTests: smokeTests,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 ")
|
||||
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)
|
||||
}
|
||||
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.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 ")
|
||||
if err != nil || again.ExtraParams["nested"].(map[string]any)["enabled"] != true {
|
||||
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()
|
||||
if !reflect.DeepEqual(policies, map[string]domain.BackendCapacityPolicy{
|
||||
" local.v1 ": {ConcurrencyLimit: 2, QueueCapacity: 3},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
const benchmarkDefinitionCount = 128
|
||||
|
||||
var (
|
||||
constructionResult *loaded
|
||||
selectedPrompt *domain.PromptDefinition
|
||||
selectedProfile *domain.ExecutionProfile
|
||||
constructionResult *loaded
|
||||
selectedPrompt *domain.PromptDefinition
|
||||
selectedPromptHash string
|
||||
selectedPromptInspection *domain.PromptInspection
|
||||
selectedProfile *domain.ExecutionProfile
|
||||
selectedProfileIdentity string
|
||||
)
|
||||
|
||||
type configurationShape struct {
|
||||
@@ -28,6 +31,7 @@ type configurationShape struct {
|
||||
|
||||
func benchmarkPrompt(profileID string) map[string]any {
|
||||
return map[string]any{
|
||||
"version": "1",
|
||||
"default_profile": profileID,
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
@@ -41,7 +45,7 @@ func independentDefinitions(count int) configurationShape {
|
||||
profileID := fmt.Sprintf("profile-%03d", index)
|
||||
promptID := fmt.Sprintf("prompt-%03d", index)
|
||||
profiles[profileID] = map[string]any{"endpoint": "https://example.test/v1", "model": profileID}
|
||||
prompts[promptID] = map[string]any{"1": benchmarkPrompt(profileID)}
|
||||
prompts[promptID] = benchmarkPrompt(profileID)
|
||||
}
|
||||
return configurationShape{
|
||||
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": profiles, "prompts": prompts})},
|
||||
@@ -60,7 +64,7 @@ func fileBackedDefinitions(count int) configurationShape {
|
||||
profiles[profileID] = map[string]any{"endpoint": "https://example.test/v1", "model": profileID}
|
||||
prompt := benchmarkPrompt(profileID)
|
||||
prompt["messages"] = []any{map[string]any{"role": "user", "content_file": "body.md"}}
|
||||
prompts[promptID] = map[string]any{"1": prompt}
|
||||
prompts[promptID] = prompt
|
||||
}
|
||||
namespace := configkit.NewFilesystem("benchmark content", fstest.MapFS{
|
||||
"assets/body.md": &fstest.MapFile{Data: []byte("Hello {{.name}}")},
|
||||
@@ -86,7 +90,7 @@ func sharedBaseDefinitions(count int) configurationShape {
|
||||
return configurationShape{
|
||||
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
||||
"profiles": profiles,
|
||||
"prompts": map[string]any{"shared": map[string]any{"1": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))}},
|
||||
"prompts": map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))},
|
||||
})},
|
||||
inputEntries: count + 2,
|
||||
effectiveEntries: count + 2,
|
||||
@@ -114,7 +118,7 @@ func layeredDefinitions(count int) configurationShape {
|
||||
},
|
||||
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||
"profiles": final,
|
||||
"prompts": map[string]any{"layered": map[string]any{"1": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))}},
|
||||
"prompts": map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))},
|
||||
})},
|
||||
inputEntries: count*4 + 1,
|
||||
effectiveEntries: count + 1,
|
||||
@@ -122,6 +126,40 @@ func layeredDefinitions(count int) configurationShape {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCollectionFileBackedConstruction(b *testing.B) {
|
||||
for _, count := range []int{4, 16, 64} {
|
||||
b.Run(fmt.Sprintf("collections_%d", count), func(b *testing.B) {
|
||||
collections := make(map[string]any, count)
|
||||
for index := range count {
|
||||
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
||||
"prompts": map[string]any{
|
||||
"example": map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
namespace := configkit.NewFilesystem("collection benchmark", fstest.MapFS{
|
||||
"body.md": {Data: []byte("Hello")},
|
||||
})
|
||||
main := configkit.Input{
|
||||
Source: configkit.Mapping("collections", map[string]any{"collections": collections}),
|
||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "."},
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
result, err := load(main, nil)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
constructionResult = result
|
||||
}
|
||||
b.ReportMetric(float64(count), "collections/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConfigurationConstruction(b *testing.B) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
@@ -157,7 +195,7 @@ func BenchmarkConfigurationSelection(b *testing.B) {
|
||||
b.Run("prompt", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
definition, _, err := result.prompt("prompt-127", "1")
|
||||
definition, _, err := result.prompt("prompt-127")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
@@ -177,3 +215,116 @@ func BenchmarkConfigurationSelection(b *testing.B) {
|
||||
b.ReportMetric(float64(shape.effectiveEntries), "effective-entries/op")
|
||||
})
|
||||
}
|
||||
|
||||
func inspectionExtraParams(entries int) map[string]any {
|
||||
providers := make(map[string]any, entries)
|
||||
for index := range entries {
|
||||
providers[fmt.Sprintf("provider-%03d", index)] = map[string]any{
|
||||
"enabled": true,
|
||||
"labels": []any{fmt.Sprintf("tier-%d", index%4), fmt.Sprintf("region-%d", index%3)},
|
||||
"limits": map[string]any{"burst": index + 1, "period": "minute"},
|
||||
"routes": []any{map[string]any{"weight": index + 1, "fallback": true}},
|
||||
}
|
||||
}
|
||||
return map[string]any{"provider_options": providers}
|
||||
}
|
||||
|
||||
func inspectionDefinitions(extraParams map[string]any) configurationShape {
|
||||
profiles := map[string]any{
|
||||
"small": map[string]any{"model": "inspection-model", "extra_params": extraParams},
|
||||
}
|
||||
prompt := benchmarkPrompt("small")
|
||||
prompt["inputs"] = []any{map[string]any{"name": "transcript", "required": true}}
|
||||
prompts := map[string]any{
|
||||
"inspection": prompt,
|
||||
}
|
||||
return configurationShape{
|
||||
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
||||
"profiles": profiles,
|
||||
"prompts": prompts,
|
||||
})},
|
||||
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkInspectionSelection(b *testing.B) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
entries int
|
||||
}{
|
||||
{name: "small_extra", entries: 2},
|
||||
{name: "nested_extra", entries: 64},
|
||||
} {
|
||||
shape := inspectionDefinitions(inspectionExtraParams(scenario.entries))
|
||||
result, err := load(shape.main, nil)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
||||
if err != nil || definition.ID != "inspection" || definition.Version == nil || *definition.Version != "1" || hash == "" ||
|
||||
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
||||
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
||||
}
|
||||
profile, err := result.GetProfile(ctx, "small")
|
||||
if err != nil || profile.ID != "small" || profile.ResolvedTarget.Model != "inspection-model" ||
|
||||
len(profile.ResolvedTarget.ExtraParams["provider_options"].(map[string]any)) != scenario.entries {
|
||||
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
||||
}
|
||||
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
||||
if err != nil || inspection.PromptID != "inspection" || inspection.PromptVersion == nil || *inspection.PromptVersion != "1" ||
|
||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
||||
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
||||
}
|
||||
identity, err := result.GetProfileIdentity(ctx, "small")
|
||||
if err != nil || identity != "small" {
|
||||
b.Fatalf("profile identity fixture is invalid: identity=%q err=%v", identity, err)
|
||||
}
|
||||
|
||||
b.Run("prompt/"+scenario.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
selectedPrompt = definition
|
||||
selectedPromptHash = hash
|
||||
}
|
||||
b.ReportMetric(float64(scenario.entries), "extra-entries/op")
|
||||
})
|
||||
b.Run("prompt_inspection/"+scenario.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
selectedPromptInspection = inspection
|
||||
}
|
||||
b.ReportMetric(float64(scenario.entries), "extra-entries/op")
|
||||
})
|
||||
b.Run("profile/"+scenario.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
profile, err := result.GetProfile(ctx, "small")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
selectedProfile = profile
|
||||
}
|
||||
b.ReportMetric(float64(scenario.entries), "extra-entries/op")
|
||||
})
|
||||
b.Run("profile_identity/"+scenario.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
identity, err := result.GetProfileIdentity(ctx, "small")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
selectedProfileIdentity = identity
|
||||
}
|
||||
b.ReportMetric(float64(scenario.entries), "extra-entries/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ type Inspection interface {
|
||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||
WriteEffective(io.Writer) error
|
||||
WriteExplanation(io.Writer, configkit.Path) error
|
||||
capturedResult() *configkit.Result[document]
|
||||
}
|
||||
|
||||
type inspectionResult struct {
|
||||
*configkit.Result[document]
|
||||
}
|
||||
|
||||
func (inspection inspectionResult) capturedResult() *configkit.Result[document] {
|
||||
return inspection.Result
|
||||
}
|
||||
|
||||
// Inspection returns only the retained result's inspection operations.
|
||||
@@ -23,7 +32,19 @@ func (state *loaded) Inspection() Inspection {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return state.result
|
||||
return inspectionResult{Result: state.result}
|
||||
}
|
||||
|
||||
// Diff compares two captured ConfigKit results without reconstructing them.
|
||||
func Diff(left, right Inspection) ([]configkit.Difference, error) {
|
||||
var leftResult, rightResult *configkit.Result[document]
|
||||
if left != nil {
|
||||
leftResult = left.capturedResult()
|
||||
}
|
||||
if right != nil {
|
||||
rightResult = right.capturedResult()
|
||||
}
|
||||
return configkit.Diff(leftResult, rightResult)
|
||||
}
|
||||
|
||||
func secretProviderPath(path configkit.Path) bool {
|
||||
@@ -32,7 +53,7 @@ func secretProviderPath(path configkit.Path) bool {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(pointer, "/"), "/")
|
||||
if len(parts) < 4 || (parts[0] != "backends" && parts[0] != "profiles") || parts[2] != "extra_params" {
|
||||
if !providerExtraParamsPath(parts) {
|
||||
return false
|
||||
}
|
||||
key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(parts[len(parts)-1], "~1", "/"), "~0", "~"))
|
||||
@@ -43,3 +64,13 @@ func secretProviderPath(path configkit.Path) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerExtraParamsPath(parts []string) bool {
|
||||
if len(parts) >= 4 && parts[1] != "" &&
|
||||
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
||||
return true
|
||||
}
|
||||
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
||||
(parts[2] == "backends" || parts[2] == "profiles") &&
|
||||
parts[3] != "" && parts[4] == "extra_params"
|
||||
}
|
||||
|
||||
32
internal/configuration/inspection_test.go
Normal file
32
internal/configuration/inspection_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path configkit.Path
|
||||
want bool
|
||||
}{
|
||||
{name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true},
|
||||
{name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true},
|
||||
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true},
|
||||
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), want: true},
|
||||
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
||||
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
||||
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
||||
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
||||
{name: "ordinary provider value", path: configkit.Keys("profiles", "assistant", "extra_params", "provider", "mode"), want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := secretProviderPath(test.path); got != test.want {
|
||||
t.Fatalf("secretProviderPath(%q) = %t, want %t", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
87
internal/configuration/listing.go
Normal file
87
internal/configuration/listing.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// CollectionIdentity is the captured metadata for one final collection entry.
|
||||
type CollectionIdentity struct {
|
||||
Namespace string
|
||||
ReleaseVersion *string
|
||||
}
|
||||
|
||||
func (state *loaded) ListProfiles(ctx context.Context) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles := append([]string(nil), state.profileIDs...)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (state *loaded) ListBackends(ctx context.Context) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backends := append([]string(nil), state.backendIDs...)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return backends, nil
|
||||
}
|
||||
|
||||
func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected := kinds
|
||||
if len(selected) == 0 {
|
||||
selected = []string{"backend", "profile", "prompt"}
|
||||
}
|
||||
set := make(map[string]struct{})
|
||||
for _, kind := range selected {
|
||||
for _, namespace := range state.namespaces[kind] {
|
||||
set[namespace] = struct{}{}
|
||||
}
|
||||
}
|
||||
namespaces := sortedKeys(set)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return namespaces, nil
|
||||
}
|
||||
|
||||
func (state *loaded) ListCollections(ctx context.Context) ([]CollectionIdentity, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collections := make([]CollectionIdentity, len(state.collections))
|
||||
for index, collection := range state.collections {
|
||||
collections[index] = CollectionIdentity{
|
||||
Namespace: collection.Namespace,
|
||||
ReleaseVersion: stringPointerValue(collection.ReleaseVersion),
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prompts := append([]domain.PromptIdentity(nil), state.promptIDs...)
|
||||
for index := range prompts {
|
||||
prompts[index].Version = stringPointerValue(prompts[index].Version)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prompts, nil
|
||||
}
|
||||
160
internal/configuration/listing_test.go
Normal file
160
internal/configuration/listing_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"backends": map[string]any{
|
||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1"},
|
||||
},
|
||||
"collections": map[string]any{
|
||||
"z": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "z-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
||||
"partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local"}}},
|
||||
"a": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "a-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
||||
" z": map[string]any{"profiles": map[string]any{"profile ": map[string]any{"backend": "local", "model": "spaced-model"}}, "prompts": map[string]any{"prompt ": listingPromptDefinition()}},
|
||||
},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends, err := state.ListBackends(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBackends := []string{"local"}
|
||||
if !reflect.DeepEqual(backends, wantBackends) {
|
||||
t.Fatalf("backend IDs = %#v, want %#v", backends, wantBackends)
|
||||
}
|
||||
|
||||
profiles, err := state.ListProfiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantProfiles := []string{" z/profile ", "a/profile", "partial/profile", "z/profile"}
|
||||
if !reflect.DeepEqual(profiles, wantProfiles) {
|
||||
t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles)
|
||||
}
|
||||
|
||||
prompts, err := state.ListPrompts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPrompts := []domain.PromptIdentity{
|
||||
{ID: " z/prompt ", Version: stringPointer("v1")},
|
||||
{ID: "a/prompt", Version: stringPointer("v1")},
|
||||
{ID: "z/prompt", Version: stringPointer("v1")},
|
||||
}
|
||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedListsAreIndependent(t *testing.T) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"backends": map[string]any{"backend": map[string]any{"endpoint": "https://example.test/v1"}},
|
||||
"profiles": map[string]any{"profile": map[string]any{}},
|
||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
profiles, err := state.ListProfiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profiles = append(profiles, "caller profile")
|
||||
profiles[0] = "changed profile"
|
||||
backends, err := state.ListBackends(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends[0] = "changed backend"
|
||||
|
||||
prompts, err := state.ListPrompts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompts[0].ID = "changed prompt"
|
||||
*prompts[0].Version = "changed version"
|
||||
|
||||
profiles, err = state.ListProfiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(profiles, []string{"profile"}) {
|
||||
t.Fatalf("caller mutation changed profile inventory: %#v", profiles)
|
||||
}
|
||||
backends, err = state.ListBackends(context.Background())
|
||||
if err != nil || !reflect.DeepEqual(backends, []string{"backend"}) {
|
||||
t.Fatalf("caller mutation changed backend inventory: %#v, %v", backends, err)
|
||||
}
|
||||
prompts, err = state.ListPrompts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPrompts := []domain.PromptIdentity{{ID: "example", Version: stringPointer("v1")}}
|
||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedListsEmptyInventory(t *testing.T) {
|
||||
state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profiles, err := state.ListProfiles(context.Background())
|
||||
if err != nil || len(profiles) != 0 {
|
||||
t.Fatalf("empty profile listing = %#v, %v", profiles, err)
|
||||
}
|
||||
backends, err := state.ListBackends(context.Background())
|
||||
if err != nil || len(backends) != 0 {
|
||||
t.Fatalf("empty backend listing = %#v, %v", backends, err)
|
||||
}
|
||||
prompts, err := state.ListPrompts(context.Background())
|
||||
if err != nil || len(prompts) != 0 {
|
||||
t.Fatalf("empty prompt listing = %#v, %v", prompts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
||||
state, err := Load(mapping("identities", map[string]any{
|
||||
"profiles": map[string]any{"partial": map[string]any{}},
|
||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
profiles, err := state.ListProfiles(ctx)
|
||||
if !errors.Is(err, context.Canceled) || len(profiles) != 0 {
|
||||
t.Fatalf("canceled profile listing = %#v, %v", profiles, err)
|
||||
}
|
||||
backends, err := state.ListBackends(ctx)
|
||||
if !errors.Is(err, context.Canceled) || len(backends) != 0 {
|
||||
t.Fatalf("canceled backend listing = %#v, %v", backends, err)
|
||||
}
|
||||
prompts, err := state.ListPrompts(ctx)
|
||||
if !errors.Is(err, context.Canceled) || len(prompts) != 0 {
|
||||
t.Fatalf("canceled prompt listing = %#v, %v", prompts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func listingPromptDefinition() map[string]any {
|
||||
return map[string]any{
|
||||
"version": "v1",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package configuration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -16,17 +17,42 @@ import (
|
||||
)
|
||||
|
||||
type document struct {
|
||||
Backends map[string]backendConfig `yaml:"backends"`
|
||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
||||
Prompts map[string]map[string]promptConfig `yaml:"prompts"`
|
||||
Version *string `yaml:"version"`
|
||||
Backends map[string]backendConfig `yaml:"backends"`
|
||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
||||
Collections map[string]collectionConfig `yaml:"collections"`
|
||||
}
|
||||
|
||||
type collectionConfig struct {
|
||||
ReleaseVersion *string `yaml:"release_version"`
|
||||
Backends map[string]backendConfig `yaml:"backends"`
|
||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type backendConfig struct {
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
APIKeyEnv *string `yaml:"api_key_env"`
|
||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||
QueueCapacity *int `yaml:"queue_capacity"`
|
||||
ExtraParams map[string]any `yaml:"extra_params"`
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
APIKeyEnv *string `yaml:"api_key_env"`
|
||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||
QueueCapacity *int `yaml:"queue_capacity"`
|
||||
ExtraParams map[string]any `yaml:"extra_params"`
|
||||
SmokeTests *backendSmokeTestConfig `yaml:"smoke_tests"`
|
||||
}
|
||||
|
||||
type backendSmokeTestConfig struct {
|
||||
DefaultProfile *string `yaml:"default_profile"`
|
||||
Reachability *reachabilityProbeConfig `yaml:"reachability"`
|
||||
Authentication *authenticationProbeConfig `yaml:"authentication"`
|
||||
}
|
||||
|
||||
type reachabilityProbeConfig struct {
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type authenticationProbeConfig struct {
|
||||
Kind *string `yaml:"kind"`
|
||||
Endpoint *string `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type profileConfig struct {
|
||||
@@ -45,6 +71,7 @@ type profileConfig struct {
|
||||
}
|
||||
|
||||
type promptConfig struct {
|
||||
Version *string `yaml:"version"`
|
||||
DefaultProfile *string `yaml:"default_profile"`
|
||||
Description *string `yaml:"description"`
|
||||
SessionID *string `yaml:"session_id"`
|
||||
@@ -54,10 +81,10 @@ type promptConfig struct {
|
||||
}
|
||||
|
||||
type promptInputConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Required bool `yaml:"required"`
|
||||
ContentType string `yaml:"content_type"`
|
||||
Description string `yaml:"description"`
|
||||
Name string `yaml:"name"`
|
||||
Required bool `yaml:"required"`
|
||||
ContentType *string `yaml:"content_type"`
|
||||
Description *string `yaml:"description"`
|
||||
}
|
||||
|
||||
type promptMessageConfig struct {
|
||||
@@ -93,15 +120,98 @@ type loaded struct {
|
||||
result *configkit.Result[document]
|
||||
backends map[string]map[string]fieldState
|
||||
profiles map[string]map[string]fieldState
|
||||
prompts map[string]map[string]*promptEntry
|
||||
ambiguous map[string]bool
|
||||
promptMetadata map[string][]promptInputPresence
|
||||
prompts map[string]*promptEntry
|
||||
registry *backend.Registry
|
||||
indexedProfiles map[string]domain.ExecutionProfile
|
||||
backendIDs []string
|
||||
profileIDs []string
|
||||
promptIDs []domain.PromptIdentity
|
||||
namespaces map[string][]string
|
||||
collections []CollectionIdentity
|
||||
}
|
||||
|
||||
type resourceBases struct {
|
||||
backends configkit.Path
|
||||
profiles configkit.Path
|
||||
prompts configkit.Path
|
||||
}
|
||||
|
||||
type resourceSet struct {
|
||||
isCollection bool
|
||||
namespace string
|
||||
bases resourceBases
|
||||
backends map[string]backendConfig
|
||||
profiles map[string]profileConfig
|
||||
prompts map[string]promptConfig
|
||||
}
|
||||
|
||||
func rootResourceBases() resourceBases {
|
||||
return resourceBases{
|
||||
backends: configkit.Keys("backends"),
|
||||
profiles: configkit.Keys("profiles"),
|
||||
prompts: configkit.Keys("prompts"),
|
||||
}
|
||||
}
|
||||
|
||||
func resourceSets(config *document) []resourceSet {
|
||||
sets := make([]resourceSet, 0, len(config.Collections)+1)
|
||||
sets = append(sets, resourceSet{
|
||||
bases: rootResourceBases(), backends: config.Backends, profiles: config.Profiles, prompts: config.Prompts,
|
||||
})
|
||||
for _, namespace := range sortedKeys(config.Collections) {
|
||||
collection := config.Collections[namespace]
|
||||
base := configkit.Keys("collections", namespace)
|
||||
sets = append(sets, resourceSet{
|
||||
isCollection: true,
|
||||
namespace: namespace,
|
||||
bases: resourceBases{
|
||||
backends: base.Key("backends"),
|
||||
profiles: base.Key("profiles"),
|
||||
prompts: base.Key("prompts"),
|
||||
},
|
||||
backends: collection.Backends,
|
||||
profiles: collection.Profiles,
|
||||
prompts: collection.Prompts,
|
||||
})
|
||||
}
|
||||
return sets
|
||||
}
|
||||
|
||||
func indexedResourceID(namespace, localKey string) string {
|
||||
qualified, valid := domain.QualifyLocalResourceID(namespace, localKey)
|
||||
if valid {
|
||||
return qualified
|
||||
}
|
||||
if namespace == "" {
|
||||
return localKey
|
||||
}
|
||||
return namespace + "/" + localKey
|
||||
}
|
||||
|
||||
func indexedBackends(config *document) map[string]backendConfig {
|
||||
backends := make(map[string]backendConfig)
|
||||
for _, resources := range resourceSets(config) {
|
||||
for localKey, definition := range resources.backends {
|
||||
backends[indexedResourceID(resources.namespace, localKey)] = definition
|
||||
}
|
||||
}
|
||||
return backends
|
||||
}
|
||||
|
||||
func indexedProfiles(config *document) map[string]profileConfig {
|
||||
profiles := make(map[string]profileConfig)
|
||||
for _, resources := range resourceSets(config) {
|
||||
for localKey, definition := range resources.profiles {
|
||||
profiles[indexedResourceID(resources.namespace, localKey)] = definition
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params"}
|
||||
var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"}
|
||||
var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"}
|
||||
|
||||
// Load captures one immutable effective configuration for internal consumers.
|
||||
@@ -112,22 +222,33 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
||||
// load performs one ConfigKit lifecycle over the selected source tiers.
|
||||
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||
state := &loaded{
|
||||
backends: make(map[string]map[string]fieldState),
|
||||
profiles: make(map[string]map[string]fieldState),
|
||||
backends: make(map[string]map[string]fieldState),
|
||||
profiles: make(map[string]map[string]fieldState),
|
||||
promptMetadata: make(map[string][]promptInputPresence),
|
||||
}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
||||
Underlays: underlays,
|
||||
Underlays: underlays,
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[document]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: checkConfigurationVersion,
|
||||
},
|
||||
Secret: secretProviderPath,
|
||||
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
||||
captureFields(config.Backends, "backends", backendFields, resolution, state.backends)
|
||||
captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles)
|
||||
resolveBackends(config.Backends, resolution)
|
||||
resolveProfiles(config.Profiles, resolution)
|
||||
resolvePrompts(config.Prompts, resolution)
|
||||
for _, resources := range resourceSets(config) {
|
||||
captureFields(resources.backends, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends)
|
||||
captureFields(resources.profiles, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
|
||||
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
|
||||
resolveBackends(resources.backends, resources.bases.backends, resolution)
|
||||
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
|
||||
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Validate: validateEffective,
|
||||
Validate: func(config *document, validation *configkit.Validation) {
|
||||
validateEffective(config, validation)
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -140,14 +261,91 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
||||
if err := state.buildExecutionProfiles(); err != nil {
|
||||
return nil, fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
state.backendIDs = sortedKeys(indexedBackends(&result.Config))
|
||||
state.profileIDs = sortedKeys(state.indexedProfiles)
|
||||
for _, id := range sortedKeys(state.prompts) {
|
||||
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: stringPointerValue(state.prompts[id].definition.Version)})
|
||||
}
|
||||
state.namespaces = map[string][]string{
|
||||
"backend": resourceNamespaces(state.backendIDs),
|
||||
"profile": resourceNamespaces(state.profileIDs),
|
||||
"prompt": promptNamespaces(state.promptIDs),
|
||||
}
|
||||
for _, namespace := range sortedKeys(result.Config.Collections) {
|
||||
collection := result.Config.Collections[namespace]
|
||||
state.collections = append(state.collections, CollectionIdentity{
|
||||
Namespace: namespace, ReleaseVersion: stringPointerValue(collection.ReleaseVersion),
|
||||
})
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func captureFields[T any](entries map[string]T, collection string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
||||
func resourceNamespaces(ids []string) []string {
|
||||
namespaces := make(map[string]struct{})
|
||||
for _, id := range ids {
|
||||
if namespace, _, qualified := strings.Cut(id, "/"); qualified {
|
||||
namespaces[namespace] = struct{}{}
|
||||
}
|
||||
}
|
||||
return sortedKeys(namespaces)
|
||||
}
|
||||
|
||||
func promptNamespaces(prompts []domain.PromptIdentity) []string {
|
||||
ids := make([]string, len(prompts))
|
||||
for index := range prompts {
|
||||
ids[index] = prompts[index].ID
|
||||
}
|
||||
return resourceNamespaces(ids)
|
||||
}
|
||||
|
||||
func checkConfigurationVersion(version configkit.Version, validation *configkit.Validation) error {
|
||||
if !version.Present {
|
||||
return nil
|
||||
}
|
||||
if version.Null || version.Text != "1" {
|
||||
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type promptInputPresence struct {
|
||||
contentType fieldState
|
||||
description fieldState
|
||||
}
|
||||
|
||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||
for _, id := range sortedKeys(prompts) {
|
||||
inputs := prompts[id].Inputs
|
||||
states := make([]promptInputPresence, len(inputs))
|
||||
base := basePath.Key(id).Key("inputs")
|
||||
for index := range inputs {
|
||||
path := base.Index(index)
|
||||
states[index] = promptInputPresence{
|
||||
contentType: captureFieldState(path.Key("content_type"), resolution),
|
||||
description: captureFieldState(path.Key("description"), resolution),
|
||||
}
|
||||
}
|
||||
destination[indexedResourceID(namespace, id)] = states
|
||||
}
|
||||
}
|
||||
|
||||
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
||||
info, exists := resolution.InputInfo(path)
|
||||
switch {
|
||||
case !exists || !info.Explicit:
|
||||
return fieldAbsent
|
||||
case info.Null:
|
||||
return fieldNull
|
||||
default:
|
||||
return fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
func captureFields[T any](entries map[string]T, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
||||
for id := range entries {
|
||||
states := make(map[string]fieldState, len(fields))
|
||||
for _, field := range fields {
|
||||
info, exists := resolution.InputInfo(configkit.Keys(collection, id, field))
|
||||
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
||||
switch {
|
||||
case !exists || !info.Explicit:
|
||||
states[field] = fieldAbsent
|
||||
@@ -157,7 +355,7 @@ func captureFields[T any](entries map[string]T, collection string, fields []stri
|
||||
states[field] = fieldValue
|
||||
}
|
||||
}
|
||||
destination[id] = states
|
||||
destination[indexedResourceID(namespace, id)] = states
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +376,10 @@ func normalizeString(value **string, path configkit.Path, resolution *configkit.
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveBackends(backends map[string]backendConfig, resolution *configkit.Resolution) {
|
||||
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for _, id := range sortedKeys(backends) {
|
||||
backend := backends[id]
|
||||
base := configkit.Keys("backends", id)
|
||||
base := basePath.Key(id)
|
||||
if backend.Endpoint != nil {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
||||
if err != nil {
|
||||
@@ -193,6 +391,21 @@ func resolveBackends(backends map[string]backendConfig, resolution *configkit.Re
|
||||
if backend.APIKeyEnv != nil {
|
||||
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
||||
}
|
||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
||||
smokePath := base.Key("smoke_tests")
|
||||
if smokeTests.Reachability != nil {
|
||||
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
||||
}
|
||||
if smokeTests.Authentication != nil {
|
||||
if smokeTests.Authentication.Kind != nil {
|
||||
normalizeString(&smokeTests.Authentication.Kind,
|
||||
smokePath.Key("authentication").Key("kind"), resolution,
|
||||
strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind)))
|
||||
}
|
||||
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
||||
smokePath.Key("authentication").Key("endpoint"), resolution)
|
||||
}
|
||||
}
|
||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
|
||||
capacity := 1024
|
||||
if resolution.IsExplicit(base.Key("queue_capacity")) {
|
||||
@@ -205,10 +418,25 @@ func resolveBackends(backends map[string]backendConfig, resolution *configkit.Re
|
||||
}
|
||||
}
|
||||
|
||||
func resolveProfiles(profiles map[string]profileConfig, resolution *configkit.Resolution) {
|
||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
||||
if value == nil || *value == nil {
|
||||
return
|
||||
}
|
||||
normalized := strings.TrimSpace(**value)
|
||||
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
||||
if normalized == "" || err != nil || request.URL == nil ||
|
||||
(strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") ||
|
||||
request.URL.Hostname() == "" {
|
||||
resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||
return
|
||||
}
|
||||
normalizeString(value, path, resolution, normalized)
|
||||
}
|
||||
|
||||
func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for _, id := range sortedKeys(profiles) {
|
||||
profile := profiles[id]
|
||||
base := configkit.Keys("profiles", id)
|
||||
base := basePath.Key(id)
|
||||
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
||||
if err != nil {
|
||||
@@ -234,68 +462,110 @@ func resolveProfiles(profiles map[string]profileConfig, resolution *configkit.Re
|
||||
}
|
||||
|
||||
func validateEffective(config *document, validation *configkit.Validation) {
|
||||
for _, id := range sortedKeys(config.Backends) {
|
||||
backend := config.Backends[id]
|
||||
base := configkit.Keys("backends", id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
validation.Error(base, "backend ID must not be blank")
|
||||
sets := resourceSets(config)
|
||||
backendIDs := make(map[string]struct{})
|
||||
for _, resources := range sets {
|
||||
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
||||
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
||||
}
|
||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||
for localKey := range resources.backends {
|
||||
backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{}
|
||||
}
|
||||
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
||||
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
||||
}
|
||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
||||
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
||||
}
|
||||
if backend.QueueCapacity != nil {
|
||||
if *backend.QueueCapacity < 0 {
|
||||
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
||||
}
|
||||
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
||||
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
||||
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
||||
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
||||
}
|
||||
}
|
||||
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
||||
}
|
||||
for _, id := range sortedKeys(config.Profiles) {
|
||||
profile := config.Profiles[id]
|
||||
base := configkit.Keys("profiles", id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
validation.Error(base, "profile ID must not be blank")
|
||||
}
|
||||
if profile.Backend != nil && *profile.Backend != "" {
|
||||
if _, exists := config.Backends[*profile.Backend]; !exists {
|
||||
validation.Error(base.Key("backend"), "backend is not defined")
|
||||
for _, resources := range sets {
|
||||
for _, id := range sortedKeys(resources.backends) {
|
||||
backend := resources.backends[id]
|
||||
base := resources.bases.backends.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "backend key must be a nonblank local key without a slash")
|
||||
}
|
||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||
}
|
||||
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
||||
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
||||
}
|
||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
||||
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
||||
}
|
||||
if backend.QueueCapacity != nil {
|
||||
if *backend.QueueCapacity < 0 {
|
||||
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
||||
}
|
||||
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
||||
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
||||
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
||||
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
||||
}
|
||||
}
|
||||
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
||||
smokePath := base.Key("smoke_tests")
|
||||
if smokeTests.DefaultProfile != nil {
|
||||
if strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
||||
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
||||
} else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) {
|
||||
validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed")
|
||||
}
|
||||
}
|
||||
if authentication := smokeTests.Authentication; authentication != nil {
|
||||
kindPath := smokePath.Key("authentication").Key("kind")
|
||||
if authentication.Kind == nil {
|
||||
validation.Error(kindPath, "authentication kind is required")
|
||||
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
||||
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
||||
validation.Error(base.Key("model"), "model must not be blank")
|
||||
for _, id := range sortedKeys(resources.profiles) {
|
||||
profile := resources.profiles[id]
|
||||
base := resources.bases.profiles.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "profile key must be a nonblank local key without a slash")
|
||||
}
|
||||
if profile.Backend != nil && *profile.Backend != "" {
|
||||
if !domain.IsValidResourceID(*profile.Backend) {
|
||||
validation.Error(base.Key("backend"), "backend reference is malformed")
|
||||
} else if _, exists := backendIDs[*profile.Backend]; !exists {
|
||||
validation.Error(base.Key("backend"), "backend is not defined")
|
||||
}
|
||||
}
|
||||
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
||||
validation.Error(base.Key("model"), "model must not be blank")
|
||||
}
|
||||
if profile.Temperature != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
||||
validation.Error(base.Key("temperature"), err.Error())
|
||||
}
|
||||
}
|
||||
if profile.TopP != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
||||
validation.Error(base.Key("top_p"), err.Error())
|
||||
}
|
||||
}
|
||||
if profile.MaxTokens != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
||||
validation.Error(base.Key("max_tokens"), err.Error())
|
||||
}
|
||||
}
|
||||
if profile.TimeoutSeconds != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
||||
validation.Error(base.Key("timeout_seconds"), err.Error())
|
||||
}
|
||||
}
|
||||
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||
}
|
||||
if profile.Temperature != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
||||
validation.Error(base.Key("temperature"), err.Error())
|
||||
for _, id := range sortedKeys(resources.prompts) {
|
||||
prompt := resources.prompts[id]
|
||||
base := resources.bases.prompts.Key(id)
|
||||
if !domain.IsValidLocalResourceKey(id) {
|
||||
validation.Error(base, "prompt key must be a nonblank local key without a slash")
|
||||
}
|
||||
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
||||
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
||||
}
|
||||
}
|
||||
if profile.TopP != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
||||
validation.Error(base.Key("top_p"), err.Error())
|
||||
}
|
||||
}
|
||||
if profile.MaxTokens != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
||||
validation.Error(base.Key("max_tokens"), err.Error())
|
||||
}
|
||||
}
|
||||
if profile.TimeoutSeconds != nil {
|
||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
||||
validation.Error(base.Key("timeout_seconds"), err.Error())
|
||||
}
|
||||
}
|
||||
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package configuration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
@@ -11,40 +12,128 @@ func mapping(label string, values map[string]any) configkit.Input {
|
||||
return configkit.Input{Source: configkit.Mapping(label, values)}
|
||||
}
|
||||
|
||||
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
version any
|
||||
include bool
|
||||
valid bool
|
||||
}{
|
||||
{name: "string one", version: "1", include: true, valid: true},
|
||||
{name: "integer one", version: int64(1), include: true, valid: true},
|
||||
{name: "omitted", valid: true},
|
||||
{name: "explicit null", include: true},
|
||||
{name: "unsupported string", version: "2", include: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := map[string]any{}
|
||||
if test.include {
|
||||
values["version"] = test.version
|
||||
}
|
||||
loaded, err := load(mapping("main", values), nil)
|
||||
if test.valid {
|
||||
if err != nil {
|
||||
t.Fatalf("load accepted version: %v", err)
|
||||
}
|
||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") {
|
||||
t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version)
|
||||
}
|
||||
if !test.include && loaded.result.Config.Version != nil {
|
||||
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
||||
}
|
||||
return
|
||||
}
|
||||
var loadErr *configkit.LoadError
|
||||
if err == nil || !errors.As(err, &loadErr) {
|
||||
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "2") {
|
||||
t.Fatalf("load error disclosed the unsupported value: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("load diagnostics = %+v, want safe version diagnostic", loadErr.Diagnostics())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
||||
underlay := mapping("declared underlay", map[string]any{
|
||||
"version": int64(1),
|
||||
})
|
||||
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||
if err != nil {
|
||||
t.Fatalf("load accepted non-main declaration: %v", err)
|
||||
}
|
||||
if loaded.result.Config.Version != nil {
|
||||
t.Fatalf("consumed non-main declaration remained in effective payload: %q", *loaded.result.Config.Version)
|
||||
}
|
||||
|
||||
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
||||
"prompts": map[string]any{"incomplete": map[string]any{"description": "still validated"}},
|
||||
})
|
||||
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
||||
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
base := mapping("defaults", map[string]any{
|
||||
"version": int64(1),
|
||||
"backends": map[string]any{
|
||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2},
|
||||
},
|
||||
"profiles": map[string]any{
|
||||
"base/one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||
"child.v1": map[string]any{"extends": "base/one"},
|
||||
},
|
||||
"prompts": map[string]any{"invoice/summary": map[string]any{"v1.0": map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}},
|
||||
"collections": map[string]any{"base": map[string]any{
|
||||
"release_version": "",
|
||||
"backends": map[string]any{
|
||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3},
|
||||
},
|
||||
"profiles": map[string]any{
|
||||
"one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||
"child.v1": map[string]any{"extends": "one"},
|
||||
"namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"},
|
||||
},
|
||||
}, "invoice": map[string]any{
|
||||
"release_version": "r7",
|
||||
"prompts": map[string]any{
|
||||
"summary": map[string]any{
|
||||
"version": "v1.0",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
},
|
||||
"version": map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
overlay := mapping("operator", map[string]any{
|
||||
"backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}},
|
||||
"profiles": map[string]any{"child.v1": map[string]any{
|
||||
"collections": map[string]any{"base": map[string]any{"profiles": map[string]any{"child.v1": map[string]any{
|
||||
"temperature": 0, "top_p": nil, "api_key_required": false,
|
||||
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
||||
}},
|
||||
}}}},
|
||||
})
|
||||
loaded, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{
|
||||
loaded, err := load(mapping("main", map[string]any{"version": "1"}), []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := loaded.result.Config.Profiles["child.v1"]
|
||||
profile := loaded.result.Config.Collections["base"].Profiles["child.v1"]
|
||||
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||
t.Fatalf("effective values were not retained: %+v", profile)
|
||||
}
|
||||
if loaded.profiles["child.v1"]["temperature"] != fieldValue || loaded.profiles["child.v1"]["top_p"] != fieldNull || loaded.profiles["child.v1"]["api_key_required"] != fieldValue {
|
||||
t.Fatalf("effective presence was lost: %+v", loaded.profiles["child.v1"])
|
||||
if loaded.profiles["base/child.v1"]["temperature"] != fieldValue || loaded.profiles["base/child.v1"]["top_p"] != fieldNull || loaded.profiles["base/child.v1"]["api_key_required"] != fieldValue {
|
||||
t.Fatalf("effective presence was lost: %+v", loaded.profiles["base/child.v1"])
|
||||
}
|
||||
provider := profile.ExtraParams["provider"].(map[string]any)
|
||||
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
||||
@@ -54,9 +143,69 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue {
|
||||
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
||||
}
|
||||
if loaded.result.Config.Prompts["invoice/summary"]["v1.0"].Messages[0].Content == nil {
|
||||
if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil {
|
||||
t.Fatalf("collection backend was not indexed by its full ID: %v", err)
|
||||
}
|
||||
collectionProfile, exists := loaded.indexedProfiles["base/namespaced"]
|
||||
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
||||
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
||||
}
|
||||
if loaded.result.Config.Collections["invoice"].Prompts["summary"].Messages[0].Content == nil || loaded.result.Config.Collections["invoice"].Prompts["summary"].Version == nil || *loaded.result.Config.Collections["invoice"].Prompts["summary"].Version != "v1.0" {
|
||||
t.Fatal("literal prompt ID and version were not decoded")
|
||||
}
|
||||
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1" ||
|
||||
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
||||
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
||||
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
||||
}
|
||||
if _, _, err := loaded.prompt("invoice/version"); err != nil {
|
||||
t.Fatalf("resource named version was not indexed under its qualified identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "namespace with separator",
|
||||
values: map[string]any{"collections": map[string]any{"bad/name": map[string]any{}}},
|
||||
message: "collection namespace must be nonblank and contain no slash",
|
||||
},
|
||||
{
|
||||
name: "root resource with separator",
|
||||
values: map[string]any{"backends": map[string]any{
|
||||
"bad/name": map[string]any{"endpoint": "https://example.test/v1"},
|
||||
}},
|
||||
message: "backend key must be a nonblank local key without a slash",
|
||||
},
|
||||
{
|
||||
name: "collection resource with separator",
|
||||
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
||||
"prompts": map[string]any{"bad/name": map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}},
|
||||
}}},
|
||||
message: "prompt key must be a nonblank local key without a slash",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := load(mapping("invalid names", test.values), nil)
|
||||
var loadErr *configkit.LoadError
|
||||
if err == nil || !errors.As(err, &loadErr) {
|
||||
t.Fatalf("load error = %v, want ConfigKit validation error", err)
|
||||
}
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
if diagnostic.Message == test.message {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("load diagnostics = %+v, want message %q", loadErr.Diagnostics(), test.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||
@@ -78,6 +227,159 @@ func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
smokeTests any
|
||||
include bool
|
||||
inherited bool
|
||||
}{
|
||||
{name: "omitted"},
|
||||
{name: "null", smokeTests: nil, include: true, inherited: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
backend := map[string]any{"endpoint": "https://backend.example/v1"}
|
||||
if test.include {
|
||||
backend["smoke_tests"] = test.smokeTests
|
||||
}
|
||||
main := mapping("backend", map[string]any{
|
||||
"backends": map[string]any{"local": backend},
|
||||
})
|
||||
var underlays []configkit.Underlay
|
||||
if test.inherited {
|
||||
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://backend.example/v1",
|
||||
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
||||
}},
|
||||
})}}}
|
||||
}
|
||||
loaded, err := load(main, underlays)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.result.Config.Backends["local"].SmokeTests; got != nil {
|
||||
t.Fatalf("smoke_tests = %+v, want nil", got)
|
||||
}
|
||||
selected, err := loaded.registry.GetBackend("local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selected.SmokeTests != nil {
|
||||
t.Fatalf("indexed smoke_tests = %+v, want nil", selected.SmokeTests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||
base := mapping("defaults", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://backend.example/v1",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "catalog-profile",
|
||||
"reachability": map[string]any{"endpoint": " https://probe.example/ready?check=1 "},
|
||||
"authentication": map[string]any{
|
||||
"kind": " OPENROUTER_KEY ",
|
||||
"endpoint": "https://probe.example/key?scope=current",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
overlay := mapping("operator", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "not-yet-listed",
|
||||
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
||||
},
|
||||
}},
|
||||
})
|
||||
loaded, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{base}},
|
||||
{Inputs: []configkit.Input{overlay}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load effective diagnostic metadata: %v", err)
|
||||
}
|
||||
selected, err := loaded.registry.GetBackend("local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := selected.SmokeTests
|
||||
if selected.Endpoint != "https://backend.example/v1" {
|
||||
t.Fatalf("effective backend endpoint = %q", selected.Endpoint)
|
||||
}
|
||||
if metadata == nil || metadata.DefaultProfile == nil || *metadata.DefaultProfile != "not-yet-listed" {
|
||||
t.Fatalf("default profile overlay was not retained: %+v", metadata)
|
||||
}
|
||||
if metadata.Reachability == nil || metadata.Reachability.Endpoint == nil ||
|
||||
*metadata.Reachability.Endpoint != "https://probe.example/ready?check=1" {
|
||||
t.Fatalf("full reachability destination was not normalized: %+v", metadata.Reachability)
|
||||
}
|
||||
if metadata.Authentication == nil || metadata.Authentication.Kind == nil || *metadata.Authentication.Kind != "none" ||
|
||||
metadata.Authentication.Endpoint != nil {
|
||||
t.Fatalf("authentication overlay or null fallback was not retained: %+v", metadata.Authentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "blank default profile",
|
||||
data: map[string]any{"default_profile": " \t "},
|
||||
path: "/backends/local/smoke_tests/default_profile",
|
||||
},
|
||||
{
|
||||
name: "authentication kind required",
|
||||
data: map[string]any{"authentication": map[string]any{}},
|
||||
path: "/backends/local/smoke_tests/authentication/kind",
|
||||
},
|
||||
{
|
||||
name: "unknown authentication kind",
|
||||
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
||||
path: "/backends/local/smoke_tests/authentication/kind",
|
||||
},
|
||||
{
|
||||
name: "non-http reachability URL",
|
||||
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
||||
path: "/backends/local/smoke_tests/reachability/endpoint",
|
||||
},
|
||||
{
|
||||
name: "blank authentication URL",
|
||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
||||
},
|
||||
{
|
||||
name: "relative authentication URL",
|
||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := load(mapping("backend", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://backend.example/v1",
|
||||
"smoke_tests": test.data,
|
||||
}},
|
||||
}), nil)
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("expected final-value validation error, got %v", err)
|
||||
}
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Path.String() == test.path {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected diagnostic at %s, got %+v", test.path, loadError.Diagnostics())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
||||
@@ -128,6 +430,40 @@ func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||
underlay := mapping("defaults", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"endpoint": "https://backend.example/v1",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": " ",
|
||||
"reachability": map[string]any{"endpoint": "ftp://invalid.example/"},
|
||||
"authentication": map[string]any{"kind": "unsupported"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
main := mapping("operator", map[string]any{
|
||||
"backends": map[string]any{"local": map[string]any{
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "dangling-profile",
|
||||
"reachability": map[string]any{"endpoint": nil},
|
||||
"authentication": map[string]any{"kind": "none"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||
if err != nil {
|
||||
t.Fatalf("replaced lower metadata should not fail: %v", err)
|
||||
}
|
||||
selected, err := loaded.registry.GetBackend("local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selected.SmokeTests == nil || selected.SmokeTests.DefaultProfile == nil ||
|
||||
*selected.SmokeTests.DefaultProfile != "dangling-profile" {
|
||||
t.Fatalf("final dangling default profile was not retained: %+v", selected.SmokeTests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
||||
_, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}),
|
||||
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
||||
|
||||
@@ -16,70 +16,73 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errPromptNotFound = errors.New("prompt definition not found")
|
||||
errPromptAmbiguous = errors.New("prompt version is ambiguous")
|
||||
errPromptNotFound = errors.New("prompt definition not found")
|
||||
)
|
||||
|
||||
type promptEntry struct {
|
||||
definition domain.PromptDefinition
|
||||
inspection domain.PromptInspection
|
||||
hash string
|
||||
session *template.Template
|
||||
messages []*template.Template
|
||||
definition domain.PromptDefinition
|
||||
inspection domain.PromptInspection
|
||||
requirementInputs []prompt.InputMetadata
|
||||
hash string
|
||||
session *template.Template
|
||||
messages []*template.Template
|
||||
}
|
||||
|
||||
func resolvePrompts(prompts map[string]map[string]promptConfig, resolution *configkit.Resolution) {
|
||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for _, id := range sortedKeys(prompts) {
|
||||
for _, version := range sortedKeys(prompts[id]) {
|
||||
definition := prompts[id][version]
|
||||
base := configkit.Keys("prompts", id, version)
|
||||
definition := prompts[id]
|
||||
base := basePath.Key(id)
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value **string
|
||||
}{
|
||||
{"description", &definition.Description},
|
||||
{"session_id", &definition.SessionID},
|
||||
} {
|
||||
if *field.value != nil {
|
||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||
}
|
||||
}
|
||||
inputsChanged := false
|
||||
for index := range definition.Inputs {
|
||||
input := &definition.Inputs[index]
|
||||
inputPath := base.Key("inputs").Index(index)
|
||||
normalizedName := strings.TrimSpace(input.Name)
|
||||
if normalizedName != input.Name {
|
||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
||||
inputsChanged = true
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value **string
|
||||
}{
|
||||
{"description", &definition.Description},
|
||||
{"session_id", &definition.SessionID},
|
||||
{"content_type", &input.ContentType},
|
||||
{"description", &input.Description},
|
||||
} {
|
||||
if *field.value != nil {
|
||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||
}
|
||||
}
|
||||
inputsChanged := false
|
||||
for index := range definition.Inputs {
|
||||
input := &definition.Inputs[index]
|
||||
inputPath := base.Key("inputs").Index(index)
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *string
|
||||
}{
|
||||
{"name", &input.Name},
|
||||
{"content_type", &input.ContentType},
|
||||
{"description", &input.Description},
|
||||
} {
|
||||
normalized := strings.TrimSpace(*field.value)
|
||||
if normalized != *field.value {
|
||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, normalized, "normalize input metadata")
|
||||
normalized := strings.TrimSpace(**field.value)
|
||||
if normalized != **field.value {
|
||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
||||
inputsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if inputsChanged {
|
||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
||||
}
|
||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
||||
// records sequence mutations as whole-value replacements, which would
|
||||
// discard the effective source contexts of unchanged content_file fields.
|
||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
||||
}
|
||||
prompts[id][version] = definition
|
||||
}
|
||||
if inputsChanged {
|
||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
||||
}
|
||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
||||
// records sequence mutations as whole-value replacements, which would
|
||||
// discard the effective source contexts of unchanged content_file fields.
|
||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
||||
}
|
||||
prompts[id] = definition
|
||||
}
|
||||
}
|
||||
|
||||
func (state *loaded) materializePrompts() error {
|
||||
state.prompts = make(map[string]map[string]*promptEntry, len(state.result.Config.Prompts))
|
||||
state.ambiguous = make(map[string]bool)
|
||||
state.prompts = make(map[string]*promptEntry)
|
||||
var metadata *configkit.Metadata
|
||||
lookupOrigin := func(path configkit.Path) (configkit.FieldInfo, bool) {
|
||||
if metadata == nil {
|
||||
@@ -88,36 +91,29 @@ func (state *loaded) materializePrompts() error {
|
||||
}
|
||||
return metadata.Lookup(path)
|
||||
}
|
||||
for _, id := range sortedKeys(state.result.Config.Prompts) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("%s: prompt ID must not be blank", configkit.Keys("prompts", id))
|
||||
}
|
||||
versions := state.result.Config.Prompts[id]
|
||||
if versions == nil {
|
||||
return fmt.Errorf("%s: prompt collection must be a mapping", configkit.Keys("prompts", id))
|
||||
}
|
||||
state.prompts[id] = make(map[string]*promptEntry, len(versions))
|
||||
state.ambiguous[id] = len(versions) > 1
|
||||
for _, version := range sortedKeys(versions) {
|
||||
if strings.TrimSpace(version) == "" {
|
||||
return fmt.Errorf("%s: prompt version must not be blank", configkit.Keys("prompts", id, version))
|
||||
for _, resources := range resourceSets(&state.result.Config) {
|
||||
for _, id := range sortedKeys(resources.prompts) {
|
||||
promptBase := resources.bases.prompts.Key(id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||
}
|
||||
entry, err := state.materializePrompt(id, version, versions[version], lookupOrigin)
|
||||
fullID := indexedResourceID(resources.namespace, id)
|
||||
raw := resources.prompts[id]
|
||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.prompts[id][version] = entry
|
||||
state.prompts[fullID] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *loaded) materializePrompt(id, version string, raw promptConfig, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||
base := configkit.Keys("prompts", id, version)
|
||||
func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||
if len(raw.Messages) == 0 {
|
||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
||||
}
|
||||
definition := domain.PromptDefinition{ID: id, Version: version}
|
||||
definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)}
|
||||
if raw.DefaultProfile != nil {
|
||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||
@@ -132,6 +128,10 @@ func (state *loaded) materializePrompt(id, version string, raw promptConfig, loo
|
||||
}
|
||||
seenInputs := make(map[string]bool, len(raw.Inputs))
|
||||
definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs))
|
||||
entry := &promptEntry{
|
||||
messages: make([]*template.Template, 0, len(raw.Messages)),
|
||||
requirementInputs: make([]prompt.InputMetadata, 0, len(raw.Inputs)),
|
||||
}
|
||||
for index, input := range raw.Inputs {
|
||||
path := base.Key("inputs").Index(index)
|
||||
if input.Name == "" {
|
||||
@@ -141,11 +141,27 @@ func (state *loaded) materializePrompt(id, version string, raw promptConfig, loo
|
||||
return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
|
||||
}
|
||||
seenInputs[input.Name] = true
|
||||
contentType := ""
|
||||
if input.ContentType != nil {
|
||||
contentType = *input.ContentType
|
||||
}
|
||||
description := ""
|
||||
if input.Description != nil {
|
||||
description = *input.Description
|
||||
}
|
||||
definition.Inputs = append(definition.Inputs, domain.PromptInput{
|
||||
Name: input.Name, Required: input.Required, ContentType: input.ContentType, Description: input.Description,
|
||||
Name: input.Name, Required: input.Required, ContentType: contentType, Description: description,
|
||||
})
|
||||
presence := state.promptMetadata[id][index]
|
||||
metadata := prompt.InputMetadata{Name: input.Name, Required: input.Required}
|
||||
if presence.contentType == fieldValue {
|
||||
metadata.ContentType = stringPointer(contentType)
|
||||
}
|
||||
if presence.description == fieldValue {
|
||||
metadata.Description = stringPointer(description)
|
||||
}
|
||||
entry.requirementInputs = append(entry.requirementInputs, metadata)
|
||||
}
|
||||
entry := &promptEntry{messages: make([]*template.Template, 0, len(raw.Messages))}
|
||||
definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
||||
for index, message := range raw.Messages {
|
||||
path := base.Key("messages").Index(index)
|
||||
@@ -226,13 +242,25 @@ func (state *loaded) materializePrompt(id, version string, raw promptConfig, loo
|
||||
entry.definition = definition
|
||||
entry.hash = hash
|
||||
entry.inspection = domain.PromptInspection{
|
||||
PromptID: id, PromptVersion: version, PromptHash: hash,
|
||||
PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash,
|
||||
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||
OutputContract: contract,
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
copy := value
|
||||
return ©
|
||||
}
|
||||
|
||||
func stringPointerValue(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return stringPointer(*value)
|
||||
}
|
||||
|
||||
func (state *loaded) readContent(fieldPath configkit.Path, name string, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (string, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "", fmt.Errorf("%s: content_file path must not be blank", fieldPath)
|
||||
@@ -265,24 +293,19 @@ func (state *loaded) readContent(fieldPath configkit.Path, name string, lookupOr
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func (state *loaded) prompt(id, version string) (*domain.PromptDefinition, domain.PromptInspection, error) {
|
||||
if state == nil || strings.TrimSpace(id) == "" || (version != "" && strings.TrimSpace(version) == "") {
|
||||
return nil, domain.PromptInspection{}, errPromptNotFound
|
||||
func (state *loaded) lookupPromptEntry(id string) (*promptEntry, error) {
|
||||
if state == nil || strings.TrimSpace(id) == "" {
|
||||
return nil, errPromptNotFound
|
||||
}
|
||||
versions := state.prompts[id]
|
||||
if version == "" {
|
||||
if state.ambiguous[id] {
|
||||
return nil, domain.PromptInspection{}, errPromptAmbiguous
|
||||
}
|
||||
for selected := range versions {
|
||||
version = selected
|
||||
}
|
||||
}
|
||||
entry := versions[version]
|
||||
entry := state.prompts[id]
|
||||
if entry == nil {
|
||||
return nil, domain.PromptInspection{}, errPromptNotFound
|
||||
return nil, errPromptNotFound
|
||||
}
|
||||
definition := entry.definition
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
||||
definition.Version = stringPointerValue(definition.Version)
|
||||
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
||||
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
||||
for index := range definition.Templates {
|
||||
@@ -291,41 +314,100 @@ func (state *loaded) prompt(id, version string) (*domain.PromptDefinition, domai
|
||||
definition.Templates[index].CacheControl = ©
|
||||
}
|
||||
}
|
||||
inspection := entry.inspection
|
||||
return &definition
|
||||
}
|
||||
|
||||
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
||||
inspection.PromptVersion = stringPointerValue(inspection.PromptVersion)
|
||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||
return &definition, inspection, nil
|
||||
return &inspection
|
||||
}
|
||||
|
||||
func promptLookupError(err error) error {
|
||||
if errors.Is(err, errPromptNotFound) {
|
||||
return promptdef.ErrPromptDefinitionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (state *loaded) prompt(id string) (*domain.PromptDefinition, domain.PromptInspection, error) {
|
||||
entry, err := state.lookupPromptEntry(id)
|
||||
if err != nil {
|
||||
return nil, domain.PromptInspection{}, err
|
||||
}
|
||||
return ownPromptDefinition(entry.definition), *ownPromptInspection(entry.inspection), nil
|
||||
}
|
||||
|
||||
// GetPromptSnapshot returns an independently owned captured definition and its
|
||||
// construction-time hash. Lookup retains literal ID and version keys.
|
||||
func (state *loaded) GetPromptSnapshot(ctx context.Context, id, version string) (*domain.PromptDefinition, string, error) {
|
||||
// construction-time hash.
|
||||
func (state *loaded) GetPromptSnapshot(ctx context.Context, id string) (*domain.PromptDefinition, string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
definition, inspection, err := state.prompt(id, version)
|
||||
if errors.Is(err, errPromptNotFound) {
|
||||
return nil, "", promptdef.ErrPromptDefinitionNotFound
|
||||
}
|
||||
entry, err := state.lookupPromptEntry(id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", promptLookupError(err)
|
||||
}
|
||||
return definition, inspection.PromptHash, nil
|
||||
return ownPromptDefinition(entry.definition), entry.hash, nil
|
||||
}
|
||||
|
||||
// GetPromptInspection returns an independently owned inspection of one
|
||||
// captured prompt without copying its execution templates.
|
||||
func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := state.lookupPromptEntry(id)
|
||||
if err != nil {
|
||||
return nil, promptLookupError(err)
|
||||
}
|
||||
return ownPromptInspection(entry.inspection), nil
|
||||
}
|
||||
|
||||
// GetPromptDefinition implements the runner's prompt lookup boundary.
|
||||
func (state *loaded) GetPromptDefinition(ctx context.Context, id, version string) (*domain.PromptDefinition, error) {
|
||||
definition, _, err := state.GetPromptSnapshot(ctx, id, version)
|
||||
func (state *loaded) GetPromptDefinition(ctx context.Context, id string) (*domain.PromptDefinition, error) {
|
||||
definition, _, err := state.GetPromptSnapshot(ctx, id)
|
||||
return definition, err
|
||||
}
|
||||
|
||||
// GetCompiledTemplates returns construction-time parses for one exact prompt.
|
||||
func (state *loaded) GetCompiledTemplates(id, version string) (prompt.CompiledTemplates, bool) {
|
||||
// GetCompiledTemplates returns construction-time parses for one prompt ID.
|
||||
func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) {
|
||||
if state == nil {
|
||||
return prompt.CompiledTemplates{}, false
|
||||
}
|
||||
entry := state.prompts[id][version]
|
||||
entry := state.prompts[id]
|
||||
if entry == nil {
|
||||
return prompt.CompiledTemplates{}, false
|
||||
}
|
||||
return prompt.CompiledTemplates{Session: entry.session, Messages: entry.messages}, true
|
||||
}
|
||||
|
||||
// GetRequirementSnapshot returns captured templates and input metadata for
|
||||
// one prompt ID. Returned metadata and slices are independently
|
||||
// owned; the compiled templates are shared immutable construction-time parses.
|
||||
func (state *loaded) GetRequirementSnapshot(id string) (prompt.RequirementSnapshot, bool) {
|
||||
if state == nil {
|
||||
return prompt.RequirementSnapshot{}, false
|
||||
}
|
||||
entry := state.prompts[id]
|
||||
if entry == nil {
|
||||
return prompt.RequirementSnapshot{}, false
|
||||
}
|
||||
snapshot := prompt.RequirementSnapshot{
|
||||
Templates: prompt.CompiledTemplates{
|
||||
Session: entry.session,
|
||||
Messages: append([]*template.Template(nil), entry.messages...),
|
||||
},
|
||||
Inputs: make([]prompt.InputMetadata, len(entry.requirementInputs)),
|
||||
}
|
||||
for index, input := range entry.requirementInputs {
|
||||
snapshot.Inputs[index] = input
|
||||
if input.ContentType != nil {
|
||||
snapshot.Inputs[index].ContentType = stringPointer(*input.ContentType)
|
||||
}
|
||||
if input.Description != nil {
|
||||
snapshot.Inputs[index].Description = stringPointer(*input.Description)
|
||||
}
|
||||
}
|
||||
return snapshot, true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -10,13 +11,16 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
)
|
||||
|
||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\nprompts:\n child:\n extends: base\n")},
|
||||
"catalog/base.yaml": {Data: []byte("prompts:\n base:\n v1:\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
||||
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
||||
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\n - path: ../other/definition.yaml\ncollections:\n docs:\n prompts:\n child:\n extends: base\n")},
|
||||
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
||||
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
||||
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n child:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||
}
|
||||
namespace := configkit.NewFilesystem("embedded", files)
|
||||
state, err := load(configkit.Input{
|
||||
@@ -30,7 +34,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, inspection, err := state.prompt("child", "")
|
||||
definition, inspection, err := state.prompt("docs/child")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -41,7 +45,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||
}
|
||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("prompts", "child", "v1", "messages").Index(0).Key("content_file"))
|
||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file"))
|
||||
if !ok || len(field.Origin.Locations) != 1 {
|
||||
t.Fatalf("content origin was lost during normalization: %+v", field)
|
||||
}
|
||||
@@ -54,16 +58,25 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
if originPath != "catalog/base.yaml" {
|
||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||
}
|
||||
other, otherInspection, err := state.prompt("other/child")
|
||||
if err != nil || other.Templates[0].Content != "Other collection {{.name}}" {
|
||||
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||
}
|
||||
files["catalog/body.md"].Data = []byte("Changed")
|
||||
again, againInspection, err := state.prompt("child", "v1")
|
||||
files["other/body.md"].Data = []byte("Changed other collection")
|
||||
again, againInspection, err := state.prompt("docs/child")
|
||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
||||
t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err)
|
||||
}
|
||||
definition.Templates[0].Content = "caller mutation"
|
||||
third, _, err := state.prompt("child", "v1")
|
||||
third, _, err := state.prompt("docs/child")
|
||||
if err != nil || third.Templates[0].Content != "Captured {{.name}}" {
|
||||
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||
}
|
||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
||||
if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||
@@ -79,12 +92,12 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, _, err := state.prompt("example", "v1")
|
||||
definition, _, err := state.prompt("example")
|
||||
if err != nil || definition.Templates[0].Content != "From mapping" || filesystem.contentOpened != 1 || filesystem.contentClosed != 1 {
|
||||
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
||||
}
|
||||
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/v1/messages/0/content_file") {
|
||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") {
|
||||
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +106,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
definitionPath := filepath.Join(root, "definition.yaml")
|
||||
contentPath := filepath.Join(root, "body.md")
|
||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n v1:\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
||||
@@ -106,41 +119,216 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
||||
if err := os.WriteFile(contentPath, []byte("Modified"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, _, err := state.prompt("example", "v1")
|
||||
definition, _, err := state.prompt("example")
|
||||
if err != nil || definition.Templates[0].Content != "Native body" {
|
||||
t.Fatalf("native content was not captured: %+v, %v", definition, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMalformedStaticSyntaxAndAmbiguousSelection(t *testing.T) {
|
||||
func TestLoadRejectsMalformedStaticSyntaxAndCapturesOptionalVersion(t *testing.T) {
|
||||
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
||||
t.Fatalf("expected construction-time syntax error, got %v", err)
|
||||
}
|
||||
state, err := load(mapping("versions", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "one"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||
"v2": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "two"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||
state, err := load(mapping("version metadata", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
"version": "release label",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "one"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := state.prompt("example", ""); !errors.Is(err, errPromptAmbiguous) {
|
||||
t.Fatalf("expected ambiguous version selection, got %v", err)
|
||||
definition, inspection, err := state.prompt("example")
|
||||
if err != nil || definition.Version == nil || *definition.Version != "release label" ||
|
||||
inspection.PromptVersion == nil || *inspection.PromptVersion != "release label" {
|
||||
t.Fatalf("optional version metadata was not retained: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptVersionLabelPresenceAndOwnership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version any
|
||||
include bool
|
||||
wantPresent bool
|
||||
wantValue string
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "null", include: true},
|
||||
{name: "empty", version: "", include: true, wantPresent: true},
|
||||
{name: "exact text", version: " Release/v1 ", include: true, wantPresent: true, wantValue: " Release/v1 "},
|
||||
}
|
||||
|
||||
hashes := make(map[string]string, len(tests))
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
definition := map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}
|
||||
if test.include {
|
||||
definition["version"] = test.version
|
||||
}
|
||||
state, err := load(mapping("prompt version label", map[string]any{
|
||||
"prompts": map[string]any{"example": definition},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
captured, inspection, err := state.prompt("example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := state.ListPrompts(context.Background())
|
||||
if err != nil || len(identities) != 1 {
|
||||
t.Fatalf("list prompt labels: %#v, %v", identities, err)
|
||||
}
|
||||
check := func(name string, value *string) {
|
||||
t.Helper()
|
||||
if !test.wantPresent {
|
||||
if value != nil {
|
||||
t.Errorf("%s label = %q, want absent", name, *value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if value == nil || *value != test.wantValue {
|
||||
t.Errorf("%s label = %v, want %q", name, value, test.wantValue)
|
||||
}
|
||||
}
|
||||
check("definition", captured.Version)
|
||||
check("inspection", inspection.PromptVersion)
|
||||
check("listing", identities[0].Version)
|
||||
if captured.Version != nil && (captured.Version == inspection.PromptVersion || captured.Version == identities[0].Version || inspection.PromptVersion == identities[0].Version) {
|
||||
t.Fatal("returned prompt labels share mutable pointers")
|
||||
}
|
||||
hashes[test.name] = inspection.PromptHash
|
||||
|
||||
if captured.Version != nil {
|
||||
*captured.Version = "mutated definition"
|
||||
}
|
||||
if inspection.PromptVersion != nil {
|
||||
*inspection.PromptVersion = "mutated inspection"
|
||||
}
|
||||
if identities[0].Version != nil {
|
||||
*identities[0].Version = "mutated listing"
|
||||
}
|
||||
again, againInspection, err := state.prompt("example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("captured definition after mutation", again.Version)
|
||||
check("captured inspection after mutation", againInspection.PromptVersion)
|
||||
})
|
||||
}
|
||||
if hashes["absent"] == hashes["empty"] {
|
||||
t.Fatal("absent and declared-empty labels produced the same definition hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptVersionNullClearsInheritedLabel(t *testing.T) {
|
||||
prompt := map[string]any{
|
||||
"version": "base-label",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}
|
||||
state, err := load(
|
||||
mapping("clear inherited label", map[string]any{"prompts": map[string]any{"example": map[string]any{"version": nil}}}),
|
||||
[]configkit.Underlay{{Inputs: []configkit.Input{mapping("base prompt", map[string]any{"prompts": map[string]any{"example": prompt}})}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, inspection, err := state.prompt("example")
|
||||
if err != nil || definition.Version != nil || inspection.PromptVersion != nil {
|
||||
t.Fatalf("null overlay retained inherited label: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
||||
makePrompt := func(content string) map[string]any {
|
||||
return map[string]any{
|
||||
"default_profile": "partial",
|
||||
"inputs": []any{map[string]any{
|
||||
"name": "document", "required": true,
|
||||
"content_type": " text/plain ", "description": " source document ",
|
||||
}},
|
||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
}
|
||||
}
|
||||
selectedPrompt := makePrompt("selected")
|
||||
selectedPrompt["version"] = "v2"
|
||||
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
||||
"profiles": map[string]any{"partial": map[string]any{}},
|
||||
"prompts": map[string]any{
|
||||
"selected": selectedPrompt,
|
||||
},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
{
|
||||
inspection, err := state.GetPromptInspection(context.Background(), "selected")
|
||||
if err != nil {
|
||||
t.Fatalf("inspect prompt ID: %v", err)
|
||||
}
|
||||
definition, hash, err := state.GetPromptSnapshot(context.Background(), "selected")
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot prompt ID: %v", err)
|
||||
}
|
||||
if inspection.PromptID != definition.ID || inspection.PromptVersion == nil || definition.Version == nil ||
|
||||
*inspection.PromptVersion != *definition.Version ||
|
||||
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
||||
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
||||
t.Fatalf("inspection did not match the selected snapshot: inspection=%+v definition=%+v hash=%q", inspection, definition, hash)
|
||||
}
|
||||
if len(definition.Inputs) != len(inspection.Inputs) || definition.Inputs[0].Name != inspection.Inputs[0].Name {
|
||||
t.Fatalf("definition and inspection inputs diverged: definition=%+v inspection=%+v", definition.Inputs, inspection.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
owned, err := state.GetPromptInspection(context.Background(), "selected")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned.Inputs[0].Name = "caller mutation"
|
||||
owned.Inputs[0].Description = "caller mutation"
|
||||
again, err := state.GetPromptInspection(context.Background(), "selected")
|
||||
if err != nil || again.Inputs[0].Name != "document" || again.Inputs[0].Description != "source document" {
|
||||
t.Fatalf("inspection mutation escaped into captured state: inspection=%+v err=%v", again, err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"missing", " "} {
|
||||
inspection, err := state.GetPromptInspection(context.Background(), id)
|
||||
if inspection != nil || !errors.Is(err, promptdef.ErrPromptDefinitionNotFound) {
|
||||
t.Errorf("inspection lookup %q=(%+v, %v), want not found", id, inspection, err)
|
||||
}
|
||||
definition, _, err := state.GetPromptSnapshot(context.Background(), id)
|
||||
if definition != nil || !errors.Is(err, promptdef.ErrPromptDefinitionNotFound) {
|
||||
t.Errorf("snapshot lookup %q=(%+v, %v), want not found", id, definition, err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := state.GetPromptInspection(ctx, "selected"); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("canceled inspection selection error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{"v1": map[string]any{
|
||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{
|
||||
"version": "v1",
|
||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
||||
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}}), nil)
|
||||
}}}), nil)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
@@ -148,18 +336,19 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, _, err := state.prompt(" exact.id ", "v1")
|
||||
definition, _, err := state.prompt(" exact.id ")
|
||||
if err != nil || definition.ID != " exact.id " || definition.Templates[0].Content != "" || definition.Templates[0].Role != "user" ||
|
||||
definition.Inputs[0].Name != "note" || definition.Templates[0].CacheControl.TTL != "1h" {
|
||||
t.Fatalf("explicit empty content or literal ID was changed: %+v, %v", definition, err)
|
||||
}
|
||||
if _, _, err := state.prompt("exact.id", "v1"); !errors.Is(err, errPromptNotFound) {
|
||||
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
||||
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
||||
}
|
||||
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
|
||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
"version": "v1",
|
||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}}), nil)
|
||||
}}}), nil)
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
||||
@@ -170,11 +359,117 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
||||
state, err := load(mapping("requirement-metadata", map[string]any{
|
||||
"prompts": map[string]any{"example": map[string]any{
|
||||
"version": "v1",
|
||||
"session_id": "{{.session}}",
|
||||
"inputs": []any{
|
||||
map[string]any{"name": "absent"},
|
||||
map[string]any{"name": "null", "content_type": nil, "description": nil},
|
||||
map[string]any{"name": "empty", "content_type": "", "description": ""},
|
||||
map[string]any{"name": "normalized", "content_type": " text/plain ", "description": " final text "},
|
||||
},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "{{input \"absent\"}}"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, ok := state.GetRequirementSnapshot("example")
|
||||
if !ok || len(snapshot.Inputs) != 4 || len(snapshot.Templates.Messages) != 1 || snapshot.Templates.Session == nil {
|
||||
t.Fatalf("requirement snapshot was not captured: %+v, %t", snapshot, ok)
|
||||
}
|
||||
for _, index := range []int{0, 1} {
|
||||
if snapshot.Inputs[index].ContentType != nil || snapshot.Inputs[index].Description != nil {
|
||||
t.Errorf("input %q should not have explicit string metadata: %+v", snapshot.Inputs[index].Name, snapshot.Inputs[index])
|
||||
}
|
||||
}
|
||||
if snapshot.Inputs[2].ContentType == nil || *snapshot.Inputs[2].ContentType != "" ||
|
||||
snapshot.Inputs[2].Description == nil || *snapshot.Inputs[2].Description != "" {
|
||||
t.Errorf("explicit empty metadata presence was lost: %+v", snapshot.Inputs[2])
|
||||
}
|
||||
if snapshot.Inputs[3].ContentType == nil || *snapshot.Inputs[3].ContentType != "text/plain" ||
|
||||
snapshot.Inputs[3].Description == nil || *snapshot.Inputs[3].Description != "final text" {
|
||||
t.Errorf("snapshot did not use normalized final metadata: %+v", snapshot.Inputs[3])
|
||||
}
|
||||
|
||||
definition, inspection, err := state.prompt("example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(definition.Inputs) != 4 || len(inspection.Inputs) != 4 || inspection.Inputs[2].ContentType != "" || inspection.Inputs[2].Description != "" ||
|
||||
inspection.Inputs[3].ContentType != "text/plain" || inspection.Inputs[3].Description != "final text" {
|
||||
t.Fatalf("existing prompt inspection shape or normalized values changed: %+v", inspection)
|
||||
}
|
||||
|
||||
*snapshot.Inputs[2].ContentType = "caller mutation"
|
||||
snapshot.Inputs[3].Name = "caller mutation"
|
||||
snapshot.Templates.Messages[0] = nil
|
||||
again, ok := state.GetRequirementSnapshot("example")
|
||||
if !ok || *again.Inputs[2].ContentType != "" || again.Inputs[3].Name != "normalized" || again.Templates.Messages[0] == nil {
|
||||
t.Fatalf("snapshot mutation escaped into captured state: %+v, %t", again, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T) {
|
||||
variants := []struct {
|
||||
name string
|
||||
fields map[string]any
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "null", fields: map[string]any{"content_type": nil, "description": nil}},
|
||||
{name: "empty", fields: map[string]any{"content_type": "", "description": ""}},
|
||||
}
|
||||
var expectedHash string
|
||||
for _, variant := range variants {
|
||||
t.Run(variant.name, func(t *testing.T) {
|
||||
input := map[string]any{"name": "document"}
|
||||
for name, value := range variant.fields {
|
||||
input[name] = value
|
||||
}
|
||||
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
||||
"prompts": map[string]any{"same": map[string]any{
|
||||
"version": "v1",
|
||||
"inputs": []any{input},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, inspection, err := state.prompt("same")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inspection.Inputs) != 1 || inspection.Inputs[0].ContentType != "" || inspection.Inputs[0].Description != "" {
|
||||
t.Fatalf("old inspection metadata changed: %+v", inspection.Inputs)
|
||||
}
|
||||
if expectedHash == "" {
|
||||
expectedHash = inspection.PromptHash
|
||||
} else if inspection.PromptHash != expectedHash {
|
||||
t.Fatalf("metadata presence changed the definition hash: got %q, want %q", inspection.PromptHash, expectedHash)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func promptWithFile(name string) map[string]any {
|
||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
|
||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}}
|
||||
}}}
|
||||
}
|
||||
|
||||
type countingFS struct {
|
||||
|
||||
@@ -12,14 +12,25 @@ import (
|
||||
)
|
||||
|
||||
func (state *loaded) buildExecutionProfiles() error {
|
||||
definitions := make(map[string]domain.Backend, len(state.result.Config.Backends))
|
||||
for _, id := range sortedKeys(state.result.Config.Backends) {
|
||||
config := state.result.Config.Backends[id]
|
||||
params, err := jsonvalue.CopyMap(config.ExtraParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backend %q extra parameters: %w", id, err)
|
||||
backendConfigs := indexedBackends(&state.result.Config)
|
||||
profileConfigs := indexedProfiles(&state.result.Config)
|
||||
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
||||
for _, id := range sortedKeys(backendConfigs) {
|
||||
config := backendConfigs[id]
|
||||
definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams}
|
||||
if config.SmokeTests != nil {
|
||||
smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile}
|
||||
if config.SmokeTests.Reachability != nil {
|
||||
smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint}
|
||||
}
|
||||
if config.SmokeTests.Authentication != nil {
|
||||
smokeTests.Authentication = &domain.AuthenticationProbeConfig{
|
||||
Kind: config.SmokeTests.Authentication.Kind,
|
||||
Endpoint: config.SmokeTests.Authentication.Endpoint,
|
||||
}
|
||||
}
|
||||
definition.SmokeTests = smokeTests
|
||||
}
|
||||
definition := domain.Backend{ID: id, ExtraParams: params}
|
||||
if config.Endpoint != nil {
|
||||
definition.Endpoint = *config.Endpoint
|
||||
}
|
||||
@@ -40,9 +51,9 @@ func (state *loaded) buildExecutionProfiles() error {
|
||||
return err
|
||||
}
|
||||
state.registry = registry
|
||||
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(state.result.Config.Profiles))
|
||||
for _, id := range sortedKeys(state.result.Config.Profiles) {
|
||||
config := state.result.Config.Profiles[id]
|
||||
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
|
||||
for _, id := range sortedKeys(profileConfigs) {
|
||||
config := profileConfigs[id]
|
||||
fields := state.profiles[id]
|
||||
target := defaults.ExecutionTargetDefault()
|
||||
if config.Backend != nil && *config.Backend != "" {
|
||||
@@ -147,18 +158,26 @@ func overlayProviderMap(lower, upper map[string]any) (map[string]any, error) {
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// GetProfile returns an independently owned indexed profile for the internal
|
||||
// runner's lookup boundary.
|
||||
func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||
func (state *loaded) lookupProfile(ctx context.Context, id string) (domain.ExecutionProfile, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return domain.ExecutionProfile{}, err
|
||||
}
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("%w: %q", profilepkg.ErrProfileNotFound, id)
|
||||
return domain.ExecutionProfile{}, fmt.Errorf("%w: %q", profilepkg.ErrProfileNotFound, id)
|
||||
}
|
||||
profile, found := state.indexedProfiles[id]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: %q", profilepkg.ErrProfileNotFound, id)
|
||||
return domain.ExecutionProfile{}, fmt.Errorf("%w: %q", profilepkg.ErrProfileNotFound, id)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetProfile returns an independently owned indexed profile for the internal
|
||||
// runner's lookup boundary.
|
||||
func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||
profile, err := state.lookupProfile(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := *profile.ResolvedTarget
|
||||
params, err := jsonvalue.CopyMap(target.ExtraParams)
|
||||
@@ -170,6 +189,15 @@ func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.Executi
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// GetProfileIdentity returns the exact lookup ID for a captured profile
|
||||
// without copying its execution target.
|
||||
func (state *loaded) GetProfileIdentity(ctx context.Context, id string) (string, error) {
|
||||
if _, err := state.lookupProfile(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetBackend returns an independently owned effective backend definition.
|
||||
func (state *loaded) GetBackend(id string) (domain.Backend, error) {
|
||||
if state == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package configuration
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
)
|
||||
|
||||
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
||||
@@ -89,6 +91,18 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
||||
if partial.ResolvedTarget.Endpoint != "" || partial.ResolvedTarget.Model != "" {
|
||||
t.Fatalf("partial profile gained a runnable target: %+v", partial.ResolvedTarget)
|
||||
}
|
||||
identity, err := state.GetProfileIdentity(context.Background(), "partial")
|
||||
if err != nil || identity != "partial" {
|
||||
t.Fatalf("partial profile identity=(%q, %v)", identity, err)
|
||||
}
|
||||
if _, err := state.GetProfileIdentity(context.Background(), "missing"); !errors.Is(err, profilepkg.ErrProfileNotFound) {
|
||||
t.Fatalf("missing profile identity error=%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := state.GetProfileIdentity(ctx, "partial"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled profile identity lookup error=%v", err)
|
||||
}
|
||||
endpointOnly, _ := state.GetProfile(context.Background(), "endpoint-only")
|
||||
if endpointOnly.ResolvedTarget.Endpoint != "https://standalone.example/v1" ||
|
||||
endpointOnly.ResolvedTarget.BackendID != "" || endpointOnly.ResolvedTarget.Model != "" {
|
||||
|
||||
@@ -61,7 +61,6 @@ type CacheControl struct {
|
||||
// RunRequest represents a request to generate a single artifact.
|
||||
type RunRequest struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
ProfileID string
|
||||
SessionID string
|
||||
APIKey string `json:"-" yaml:"-"`
|
||||
@@ -81,7 +80,7 @@ type RunResult struct {
|
||||
RawOutput string
|
||||
Validation ValidationResult
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
SessionID string
|
||||
RenderedPromptHash string
|
||||
@@ -101,7 +100,7 @@ type RunResult struct {
|
||||
// It must never include resolved API key values, model output, or validation data.
|
||||
type PreparedRun struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
SelectedProfileID string
|
||||
SelectedBackendID string
|
||||
@@ -135,10 +134,16 @@ type Artifact struct {
|
||||
Hash string
|
||||
}
|
||||
|
||||
// PromptIdentity identifies one configured prompt and its descriptive version label.
|
||||
type PromptIdentity struct {
|
||||
ID string
|
||||
Version *string
|
||||
}
|
||||
|
||||
// PromptDefinition represents a configured prompt execution definition.
|
||||
type PromptDefinition struct {
|
||||
ID string `yaml:"id"`
|
||||
Version string `yaml:"version"`
|
||||
Version *string `yaml:"version"`
|
||||
DefaultProfile string `yaml:"default_profile"`
|
||||
Description string `yaml:"description"`
|
||||
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||
@@ -151,7 +156,7 @@ type PromptDefinition struct {
|
||||
// PromptInspection is the resolved result of exact prompt inspection.
|
||||
type PromptInspection struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptVersion *string
|
||||
PromptHash string
|
||||
DefaultProfileID string
|
||||
Inputs []PromptInput
|
||||
@@ -174,12 +179,14 @@ type PromptMessageTemplate struct {
|
||||
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
||||
}
|
||||
|
||||
// Backend describes reusable OpenAI-compatible connection defaults.
|
||||
// Backend describes reusable OpenAI-compatible connection defaults and optional
|
||||
// diagnostic metadata.
|
||||
type Backend struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
APIKeyEnv string
|
||||
ExtraParams map[string]any
|
||||
SmokeTests *BackendSmokeTestConfig
|
||||
ConcurrencyLimit int
|
||||
QueueCapacity int
|
||||
QueueCapacitySet bool
|
||||
|
||||
@@ -68,7 +68,7 @@ type ExecutionEvent struct {
|
||||
RunID string
|
||||
Time time.Time
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
Model string
|
||||
|
||||
58
internal/domain/requirements.go
Normal file
58
internal/domain/requirements.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package domain
|
||||
|
||||
// RequirementKind classifies how a prompt may depend on an input or variable.
|
||||
type RequirementKind string
|
||||
|
||||
const (
|
||||
RequirementRequired RequirementKind = "required"
|
||||
RequirementConditional RequirementKind = "conditional"
|
||||
RequirementOptional RequirementKind = "optional"
|
||||
)
|
||||
|
||||
// InputRequirement describes one declared or template-referenced input.
|
||||
type InputRequirement struct {
|
||||
Name string
|
||||
Kind RequirementKind
|
||||
Declared bool
|
||||
ContentType *string
|
||||
Description *string
|
||||
Explanation string
|
||||
}
|
||||
|
||||
// VariableRequirement describes one caller variable referenced by a prompt.
|
||||
type VariableRequirement struct {
|
||||
Name string
|
||||
Kind RequirementKind
|
||||
Supplied bool
|
||||
Explanation string
|
||||
}
|
||||
|
||||
// RequirementIssueCode identifies a limit that may affect requirement
|
||||
// discovery.
|
||||
type RequirementIssueCode string
|
||||
|
||||
const (
|
||||
RequirementIssueMissingVariable RequirementIssueCode = "missing_variable"
|
||||
RequirementIssueInputContent RequirementIssueCode = "input_content"
|
||||
RequirementIssueDynamicInputName RequirementIssueCode = "dynamic_input_name"
|
||||
RequirementIssueUnsupportedConstruct RequirementIssueCode = "unsupported_construct"
|
||||
)
|
||||
|
||||
// RequirementIssue describes one limitation on requirement discovery.
|
||||
type RequirementIssue struct {
|
||||
Code RequirementIssueCode
|
||||
InputName string
|
||||
VariableName string
|
||||
Description string
|
||||
}
|
||||
|
||||
// RequirementInspection contains the inventories discovered for one prompt.
|
||||
type RequirementInspection struct {
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
Inputs []InputRequirement
|
||||
Variables []VariableRequirement
|
||||
Complete bool
|
||||
Issues []RequirementIssue
|
||||
}
|
||||
70
internal/domain/resource_identity.go
Normal file
70
internal/domain/resource_identity.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
func validResourceID(id string) bool {
|
||||
if !strings.Contains(id, "/") {
|
||||
return strings.TrimSpace(id) != ""
|
||||
}
|
||||
if strings.Count(id, "/") != 1 {
|
||||
return false
|
||||
}
|
||||
namespace, localID, _ := strings.Cut(id, "/")
|
||||
return namespace != "" && localID != ""
|
||||
}
|
||||
|
||||
func validResourceNamespace(namespace string) bool {
|
||||
return strings.TrimSpace(namespace) != "" && !strings.Contains(namespace, "/")
|
||||
}
|
||||
|
||||
func validLocalResourceKey(key string) bool {
|
||||
return strings.TrimSpace(key) != "" && !strings.Contains(key, "/")
|
||||
}
|
||||
|
||||
func qualifyResourceID(id, namespace string) (string, bool) {
|
||||
if !validResourceID(id) || (namespace != "" && !validResourceNamespace(namespace)) {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(id, "/") || namespace == "" {
|
||||
return id, true
|
||||
}
|
||||
return namespace + "/" + id, true
|
||||
}
|
||||
|
||||
func resourceNamespaceFromID(id string) (string, bool) {
|
||||
if !validResourceID(id) || !strings.Contains(id, "/") {
|
||||
return "", false
|
||||
}
|
||||
namespace, _, _ := strings.Cut(id, "/")
|
||||
return namespace, true
|
||||
}
|
||||
|
||||
// IsValidResourceID reports whether id is an unqualified resource ID or one
|
||||
// valid namespace/local-ID pair.
|
||||
func IsValidResourceID(id string) bool { return validResourceID(id) }
|
||||
|
||||
// QualifyResourceID validates id and applies namespace to an unqualified ID.
|
||||
// An already-qualified ID is returned unchanged.
|
||||
func QualifyResourceID(id, namespace string) (string, bool) {
|
||||
return qualifyResourceID(id, namespace)
|
||||
}
|
||||
|
||||
// IsValidResourceNamespace reports whether namespace is nonblank and contains
|
||||
// no namespace separator.
|
||||
func IsValidResourceNamespace(namespace string) bool { return validResourceNamespace(namespace) }
|
||||
|
||||
// IsValidLocalResourceKey reports whether key is a nonblank key for a
|
||||
// namespace-local resource mapping.
|
||||
func IsValidLocalResourceKey(key string) bool { return validLocalResourceKey(key) }
|
||||
|
||||
// QualifyLocalResourceID combines a validated namespace and local mapping key.
|
||||
// An empty namespace leaves a valid unqualified key unchanged.
|
||||
func QualifyLocalResourceID(namespace, key string) (string, bool) {
|
||||
if !validLocalResourceKey(key) || (namespace != "" && !validResourceNamespace(namespace)) {
|
||||
return "", false
|
||||
}
|
||||
if namespace == "" {
|
||||
return key, true
|
||||
}
|
||||
return namespace + "/" + key, true
|
||||
}
|
||||
110
internal/domain/resource_identity_test.go
Normal file
110
internal/domain/resource_identity_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidResourceID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
want bool
|
||||
}{
|
||||
{name: "ordinary", id: "draft", want: true},
|
||||
{name: "dotted", id: "group.prompt.v2", want: true},
|
||||
{name: "preserved surrounding whitespace", id: " draft ", want: true},
|
||||
{name: "qualified", id: "group/draft", want: true},
|
||||
{name: "qualified components preserve spelling", id: " Group / draft ", want: true},
|
||||
{name: "empty", id: "", want: false},
|
||||
{name: "blank", id: " \t", want: false},
|
||||
{name: "missing namespace", id: "/draft", want: false},
|
||||
{name: "missing local id", id: "group/", want: false},
|
||||
{name: "multiple separators", id: "group/subgroup/draft", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := validResourceID(test.id); got != test.want {
|
||||
t.Fatalf("validResourceID(%q) = %t, want %t", test.id, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidResourceNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
want bool
|
||||
}{
|
||||
{name: "ordinary", namespace: "group", want: true},
|
||||
{name: "dotted", namespace: "group.v2", want: true},
|
||||
{name: "preserved whitespace", namespace: " group ", want: true},
|
||||
{name: "empty", namespace: "", want: false},
|
||||
{name: "blank", namespace: " \t", want: false},
|
||||
{name: "slash", namespace: "group/subgroup", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := validResourceNamespace(test.namespace); got != test.want {
|
||||
t.Fatalf("validResourceNamespace(%q) = %t, want %t", test.namespace, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidLocalResourceKey(t *testing.T) {
|
||||
if !validLocalResourceKey("draft.v2") || !validLocalResourceKey(" draft ") {
|
||||
t.Fatal("expected nonblank slash-free keys to be valid")
|
||||
}
|
||||
for _, key := range []string{"", " \t", "group/draft"} {
|
||||
if validLocalResourceKey(key) {
|
||||
t.Errorf("validLocalResourceKey(%q) = true, want false", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualifyResourceID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
namespace string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "without scope", id: "draft", want: "draft", wantOK: true},
|
||||
{name: "with scope", id: "draft", namespace: "group", want: "group/draft", wantOK: true},
|
||||
{name: "qualified bypass", id: "other/draft", namespace: "group", want: "other/draft", wantOK: true},
|
||||
{name: "preserve spelling", id: " draft ", namespace: " Group ", want: " Group / draft ", wantOK: true},
|
||||
{name: "omission remains caller policy", id: "", wantOK: false},
|
||||
{name: "malformed ID", id: "group/draft/extra", namespace: "group", wantOK: false},
|
||||
{name: "malformed scope", id: "draft", namespace: "group/subgroup", wantOK: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, ok := qualifyResourceID(test.id, test.namespace)
|
||||
if got != test.want || ok != test.wantOK {
|
||||
t.Fatalf("qualifyResourceID(%q, %q) = (%q, %t), want (%q, %t)", test.id, test.namespace, got, ok, test.want, test.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceNamespaceFromID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
namespace string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "qualified", id: "team/item", namespace: "team", wantOK: true},
|
||||
{name: "same-prefix namespace stays distinct", id: "team-extra/item", namespace: "team-extra", wantOK: true},
|
||||
{name: "unqualified", id: "item", wantOK: false},
|
||||
{name: "malformed", id: "team/sub/item", wantOK: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, ok := resourceNamespaceFromID(test.id)
|
||||
if got != test.namespace || ok != test.wantOK {
|
||||
t.Fatalf("resourceNamespaceFromID(%q) = (%q, %t), want (%q, %t)", test.id, got, ok, test.namespace, test.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
263
internal/domain/smoke_diagnostics.go
Normal file
263
internal/domain/smoke_diagnostics.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults.
|
||||
type BackendSmokeTestConfig struct {
|
||||
DefaultProfile *string
|
||||
Reachability *ReachabilityProbeConfig
|
||||
Authentication *AuthenticationProbeConfig
|
||||
}
|
||||
|
||||
// ReachabilityProbeConfig describes an optional endpoint for reachability tests.
|
||||
type ReachabilityProbeConfig struct {
|
||||
Endpoint *string
|
||||
}
|
||||
|
||||
// AuthenticationProbeConfig describes an optional authentication diagnostic.
|
||||
type AuthenticationProbeConfig struct {
|
||||
Kind *string
|
||||
Endpoint *string
|
||||
}
|
||||
|
||||
// SmokeTestKind identifies one built-in diagnostic operation.
|
||||
type SmokeTestKind string
|
||||
|
||||
const (
|
||||
SmokeTestReachability SmokeTestKind = "reachability"
|
||||
SmokeTestAuthentication SmokeTestKind = "authentication"
|
||||
SmokeTestTextInference SmokeTestKind = "text_inference"
|
||||
SmokeTestStructuredOutput SmokeTestKind = "structured_output"
|
||||
)
|
||||
|
||||
// SmokeTestRequest selects one diagnostic and its execution target.
|
||||
type SmokeTestRequest struct {
|
||||
Kind SmokeTestKind
|
||||
BackendID string
|
||||
PromptID string
|
||||
ProfileID string
|
||||
Execution *ExecutionTargetOverride
|
||||
APIKey string `json:"-" yaml:"-"`
|
||||
TransientRetries *int
|
||||
}
|
||||
|
||||
// SmokeTestReport contains factual diagnostic results, not generated content.
|
||||
type SmokeTestReport struct {
|
||||
Kind SmokeTestKind
|
||||
Supported bool
|
||||
Applicable *bool
|
||||
Completed bool
|
||||
Reason SmokeTestReason
|
||||
Target SmokeTestTarget
|
||||
Facts SmokeTestFacts
|
||||
Timing SmokeTestTiming
|
||||
Attempts []SmokeTestAttempt
|
||||
Usage ObservedTokenUsage
|
||||
Failure ExecutionFailureKind
|
||||
}
|
||||
|
||||
// SmokeTestTarget identifies the selected diagnostic target and its settings.
|
||||
type SmokeTestTarget struct {
|
||||
BackendID string
|
||||
ProfileID string
|
||||
PromptID string
|
||||
PromptVersion *string
|
||||
DefaultSourceBackendID string
|
||||
SelectionSource string
|
||||
Endpoint string
|
||||
EffectiveSettings ExecutionTarget
|
||||
SettingsPresence ExecutionTargetPresence
|
||||
}
|
||||
|
||||
// SmokeTestFacts contains optional factual observations from a diagnostic.
|
||||
type SmokeTestFacts struct {
|
||||
CredentialsSupplied *bool
|
||||
EndpointReachable *bool
|
||||
CredentialsAccepted *bool
|
||||
ResponseReceived *bool
|
||||
GenerationCompleted *bool
|
||||
TextReturned *bool
|
||||
ExpectedTextMatched *bool
|
||||
StructuredOutputRequested *bool
|
||||
ValidJSON *bool
|
||||
SchemaMatched *bool
|
||||
}
|
||||
|
||||
// SmokeTestTiming records elapsed diagnostic and phase durations.
|
||||
type SmokeTestTiming struct {
|
||||
StartedAt time.Time
|
||||
Duration time.Duration
|
||||
CapacityWait *time.Duration
|
||||
RequestDuration *time.Duration
|
||||
RetryDelay *time.Duration
|
||||
EvaluationDuration *time.Duration
|
||||
}
|
||||
|
||||
// SmokeTestAttempt records one dispatched attempt or an interrupted acquisition.
|
||||
type SmokeTestAttempt struct {
|
||||
Dispatched bool
|
||||
InitialEndpoint string
|
||||
Endpoint string
|
||||
HTTPStatus *int
|
||||
Facts SmokeTestFacts
|
||||
Timing SmokeTestTiming
|
||||
Failure ExecutionFailureKind
|
||||
Usage ObservedTokenUsage
|
||||
}
|
||||
|
||||
// SmokeTestReason identifies additional factual context for a diagnostic.
|
||||
type SmokeTestReason string
|
||||
|
||||
const (
|
||||
SmokeTestReasonNone SmokeTestReason = ""
|
||||
SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable"
|
||||
SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured"
|
||||
SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable"
|
||||
SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing"
|
||||
SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized"
|
||||
)
|
||||
|
||||
// CloneBackend returns a copy of a backend, including its mutable settings.
|
||||
func CloneBackend(backend Backend) (Backend, error) {
|
||||
cloned := backend
|
||||
var err error
|
||||
cloned.ExtraParams, err = jsonvalue.CopyMap(backend.ExtraParams)
|
||||
if err != nil {
|
||||
return Backend{}, err
|
||||
}
|
||||
cloned.SmokeTests = CloneBackendSmokeTestConfig(backend.SmokeTests)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
// CloneBackendSmokeTestConfig returns an independent copy of optional metadata.
|
||||
func CloneBackendSmokeTestConfig(config *BackendSmokeTestConfig) *BackendSmokeTestConfig {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *config
|
||||
cloned.DefaultProfile = cloneString(config.DefaultProfile)
|
||||
if config.Reachability != nil {
|
||||
reachability := *config.Reachability
|
||||
reachability.Endpoint = cloneString(config.Reachability.Endpoint)
|
||||
cloned.Reachability = &reachability
|
||||
}
|
||||
if config.Authentication != nil {
|
||||
authentication := *config.Authentication
|
||||
authentication.Kind = cloneString(config.Authentication.Kind)
|
||||
authentication.Endpoint = cloneString(config.Authentication.Endpoint)
|
||||
cloned.Authentication = &authentication
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// CloneSmokeTestRequest returns a copy that owns its optional settings.
|
||||
func CloneSmokeTestRequest(request SmokeTestRequest) (SmokeTestRequest, error) {
|
||||
cloned := request
|
||||
cloned.TransientRetries = cloneInt(request.TransientRetries)
|
||||
if request.Execution == nil {
|
||||
return cloned, nil
|
||||
}
|
||||
execution := *request.Execution
|
||||
execution.Temperature = cloneFloat64(request.Execution.Temperature)
|
||||
execution.MaxTokens = cloneInt(request.Execution.MaxTokens)
|
||||
execution.TopP = cloneFloat64(request.Execution.TopP)
|
||||
execution.TimeoutSeconds = cloneInt(request.Execution.TimeoutSeconds)
|
||||
execution.ReasoningEffort = cloneString(request.Execution.ReasoningEffort)
|
||||
var err error
|
||||
execution.ExtraParams, err = jsonvalue.CopyMap(request.Execution.ExtraParams)
|
||||
if err != nil {
|
||||
return SmokeTestRequest{}, err
|
||||
}
|
||||
cloned.Execution = &execution
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
// CloneSmokeTestReport returns a copy with independent optional facts and maps.
|
||||
func CloneSmokeTestReport(report SmokeTestReport) (SmokeTestReport, error) {
|
||||
cloned := report
|
||||
cloned.Applicable = cloneBool(report.Applicable)
|
||||
cloned.Target.PromptVersion = cloneString(report.Target.PromptVersion)
|
||||
cloned.Facts = cloneSmokeTestFacts(report.Facts)
|
||||
cloned.Timing = cloneSmokeTestTiming(report.Timing)
|
||||
var err error
|
||||
cloned.Target.EffectiveSettings.ExtraParams, err = jsonvalue.CopyMap(report.Target.EffectiveSettings.ExtraParams)
|
||||
if err != nil {
|
||||
return SmokeTestReport{}, err
|
||||
}
|
||||
if report.Attempts != nil {
|
||||
cloned.Attempts = make([]SmokeTestAttempt, len(report.Attempts))
|
||||
for index, attempt := range report.Attempts {
|
||||
cloned.Attempts[index] = attempt
|
||||
cloned.Attempts[index].HTTPStatus = cloneInt(attempt.HTTPStatus)
|
||||
cloned.Attempts[index].Facts = cloneSmokeTestFacts(attempt.Facts)
|
||||
cloned.Attempts[index].Timing = cloneSmokeTestTiming(attempt.Timing)
|
||||
}
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func cloneSmokeTestFacts(facts SmokeTestFacts) SmokeTestFacts {
|
||||
facts.CredentialsSupplied = cloneBool(facts.CredentialsSupplied)
|
||||
facts.EndpointReachable = cloneBool(facts.EndpointReachable)
|
||||
facts.CredentialsAccepted = cloneBool(facts.CredentialsAccepted)
|
||||
facts.ResponseReceived = cloneBool(facts.ResponseReceived)
|
||||
facts.GenerationCompleted = cloneBool(facts.GenerationCompleted)
|
||||
facts.TextReturned = cloneBool(facts.TextReturned)
|
||||
facts.ExpectedTextMatched = cloneBool(facts.ExpectedTextMatched)
|
||||
facts.StructuredOutputRequested = cloneBool(facts.StructuredOutputRequested)
|
||||
facts.ValidJSON = cloneBool(facts.ValidJSON)
|
||||
facts.SchemaMatched = cloneBool(facts.SchemaMatched)
|
||||
return facts
|
||||
}
|
||||
|
||||
func cloneSmokeTestTiming(timing SmokeTestTiming) SmokeTestTiming {
|
||||
timing.CapacityWait = cloneDuration(timing.CapacityWait)
|
||||
timing.RequestDuration = cloneDuration(timing.RequestDuration)
|
||||
timing.RetryDelay = cloneDuration(timing.RetryDelay)
|
||||
timing.EvaluationDuration = cloneDuration(timing.EvaluationDuration)
|
||||
return timing
|
||||
}
|
||||
|
||||
func cloneBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDuration(value *time.Duration) *time.Duration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneFloat64(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneString(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
115
internal/domain/smoke_diagnostics_test.go
Normal file
115
internal/domain/smoke_diagnostics_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) {
|
||||
profile := "profile"
|
||||
reachabilityEndpoint := "https://probe.example/health"
|
||||
authKind := "openrouter_key"
|
||||
authEndpoint := "https://probe.example/key"
|
||||
backend := Backend{
|
||||
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
||||
SmokeTests: &BackendSmokeTestConfig{
|
||||
DefaultProfile: &profile,
|
||||
Reachability: &ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint},
|
||||
Authentication: &AuthenticationProbeConfig{
|
||||
Kind: &authKind,
|
||||
Endpoint: &authEndpoint,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cloned, err := CloneBackend(backend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*cloned.SmokeTests.DefaultProfile = "changed"
|
||||
*cloned.SmokeTests.Reachability.Endpoint = "changed"
|
||||
*cloned.SmokeTests.Authentication.Kind = "changed"
|
||||
*cloned.SmokeTests.Authentication.Endpoint = "changed"
|
||||
cloned.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||
|
||||
if profile != "profile" || reachabilityEndpoint != "https://probe.example/health" ||
|
||||
authKind != "openrouter_key" || authEndpoint != "https://probe.example/key" {
|
||||
t.Fatal("cloned diagnostic metadata shared source pointers")
|
||||
}
|
||||
if got := backend.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("source provider parameter changed through clone: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSmokeTestRequestOwnsOptionalSettings(t *testing.T) {
|
||||
temperature := 0.25
|
||||
retries := 2
|
||||
reasoning := "low"
|
||||
request := SmokeTestRequest{
|
||||
APIKey: "secret",
|
||||
TransientRetries: &retries,
|
||||
Execution: &ExecutionTargetOverride{
|
||||
Temperature: &temperature,
|
||||
ReasoningEffort: &reasoning,
|
||||
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
||||
},
|
||||
}
|
||||
|
||||
cloned, err := CloneSmokeTestRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*cloned.TransientRetries = 3
|
||||
*cloned.Execution.Temperature = 0.5
|
||||
*cloned.Execution.ReasoningEffort = "high"
|
||||
cloned.Execution.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||
|
||||
if retries != 2 || temperature != 0.25 || reasoning != "low" {
|
||||
t.Fatal("cloned request shared optional scalar pointers")
|
||||
}
|
||||
if got := request.Execution.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("source provider parameter changed through clone: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) {
|
||||
applicable := true
|
||||
reachable := true
|
||||
status := 200
|
||||
wait := 3 * time.Millisecond
|
||||
promptVersion := "v1"
|
||||
report := SmokeTestReport{
|
||||
Applicable: &applicable,
|
||||
Target: SmokeTestTarget{
|
||||
PromptVersion: &promptVersion,
|
||||
EffectiveSettings: ExecutionTarget{
|
||||
ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
|
||||
},
|
||||
},
|
||||
Facts: SmokeTestFacts{EndpointReachable: &reachable},
|
||||
Attempts: []SmokeTestAttempt{{
|
||||
HTTPStatus: &status,
|
||||
Facts: SmokeTestFacts{EndpointReachable: &reachable},
|
||||
Timing: SmokeTestTiming{CapacityWait: &wait},
|
||||
}},
|
||||
}
|
||||
|
||||
cloned, err := CloneSmokeTestReport(report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*cloned.Applicable = false
|
||||
*cloned.Facts.EndpointReachable = false
|
||||
*cloned.Attempts[0].HTTPStatus = 503
|
||||
*cloned.Attempts[0].Facts.EndpointReachable = false
|
||||
*cloned.Attempts[0].Timing.CapacityWait = time.Second
|
||||
*cloned.Target.PromptVersion = "changed"
|
||||
cloned.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||
|
||||
if !applicable || !reachable || status != 200 || wait != 3*time.Millisecond || promptVersion != "v1" {
|
||||
t.Fatal("cloned report shared optional scalar pointers")
|
||||
}
|
||||
if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("source provider parameter changed through clone: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/catalog"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
@@ -81,8 +80,41 @@ func (o *Options) SetSchemaFile(path string) error {
|
||||
|
||||
// Engine forwards domain operations to the single assembled use-case runner.
|
||||
type Engine struct {
|
||||
runner *usecase.Runner
|
||||
inspection configuration.Inspection
|
||||
runner *usecase.Runner
|
||||
smokeTester *usecase.SmokeTester
|
||||
inspection configuration.Inspection
|
||||
listing identityInventory
|
||||
namespaces resourceNamespaces
|
||||
}
|
||||
|
||||
type resourceNamespaces struct {
|
||||
prompt string
|
||||
profile string
|
||||
backend string
|
||||
}
|
||||
|
||||
type identityInventory interface {
|
||||
ListBackends(context.Context) ([]string, error)
|
||||
ListProfiles(context.Context) ([]string, error)
|
||||
ListPrompts(context.Context) ([]domain.PromptIdentity, error)
|
||||
ListNamespaces(context.Context, []string) ([]string, error)
|
||||
ListCollections(context.Context) ([]configuration.CollectionIdentity, error)
|
||||
}
|
||||
|
||||
// ListingFilterMode selects namespace membership for one identity listing.
|
||||
type ListingFilterMode uint8
|
||||
|
||||
const (
|
||||
ListingDefault ListingFilterMode = iota
|
||||
ListingNamed
|
||||
ListingUnqualified
|
||||
ListingAll
|
||||
)
|
||||
|
||||
// ListingFilter optionally overrides the namespace selected by an engine view.
|
||||
type ListingFilter struct {
|
||||
Mode ListingFilterMode
|
||||
Name string
|
||||
}
|
||||
|
||||
// New assembles an engine from facade-selected configuration and collaborators.
|
||||
@@ -91,11 +123,7 @@ func New(cfg Config, options Options) (*Engine, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
underlays, err := catalog.Underlays()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select maintained configuration: %w", err)
|
||||
}
|
||||
underlays = append(underlays, cfg.Sources.Underlays...)
|
||||
underlays := cfg.Sources.Underlays
|
||||
main := configkit.Input{Source: configkit.NoSource()}
|
||||
if cfg.Sources.Main != nil {
|
||||
main = *cfg.Sources.Main
|
||||
@@ -118,11 +146,13 @@ func New(cfg Config, options Options) (*Engine, error) {
|
||||
validator = validate.NewStandardValidator(schemaDir)
|
||||
}
|
||||
var client llm.Client
|
||||
var diagnosticHTTPClient *llm.OpenAICompatibleClient
|
||||
if options.llmClient == nil {
|
||||
builtIn, newErr := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
|
||||
if newErr != nil {
|
||||
return nil, newErr
|
||||
}
|
||||
diagnosticHTTPClient = builtIn
|
||||
client = generation.NewBuiltIn(manager, builtIn)
|
||||
} else {
|
||||
client = generation.NewInjected(manager, options.llmClient)
|
||||
@@ -132,12 +162,25 @@ func New(cfg Config, options Options) (*Engine, error) {
|
||||
reader = artifact.NewCompositeReader()
|
||||
}
|
||||
|
||||
runner := usecase.NewRunnerWithRepairer(snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)
|
||||
return &Engine{
|
||||
runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries),
|
||||
inspection: snapshot.Inspection(),
|
||||
runner: runner,
|
||||
smokeTester: usecase.NewSmokeTester(runner, diagnosticHTTPClient),
|
||||
inspection: snapshot.Inspection(),
|
||||
listing: snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithNamespaces returns an immutable view sharing this engine's assembled
|
||||
// collaborators and configuration snapshot. Caller-supplied selection IDs are
|
||||
// qualified at each operation boundary; captured configuration references are
|
||||
// not rewritten.
|
||||
func (e *Engine) WithNamespaces(prompt, profile, backend string) *Engine {
|
||||
view := *e
|
||||
view.namespaces = resourceNamespaces{prompt: prompt, profile: profile, backend: backend}
|
||||
return &view
|
||||
}
|
||||
|
||||
// InspectConfiguration returns read-only operations over the captured result.
|
||||
func (e *Engine) InspectConfiguration() configuration.Inspection {
|
||||
if e == nil {
|
||||
@@ -175,21 +218,213 @@ func fileSource(name string) (fs.FS, string, string, error) {
|
||||
return os.DirFS(directory), filepath.ToSlash(base), directory, nil
|
||||
}
|
||||
|
||||
func (e *Engine) InspectPrompt(ctx context.Context, id, version string) (*domain.PromptInspection, error) {
|
||||
return e.runner.InspectPrompt(ctx, id, version)
|
||||
func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
||||
var err error
|
||||
id, err = e.qualifyResourceID(id, e.namespaces.prompt, "prompt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.InspectPrompt(ctx, id)
|
||||
}
|
||||
func (e *Engine) InspectRequirements(
|
||||
ctx context.Context,
|
||||
request usecase.RequirementInspectionRequest,
|
||||
) (*domain.RequirementInspection, error) {
|
||||
var err error
|
||||
request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.InspectRequirements(ctx, request)
|
||||
}
|
||||
func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) {
|
||||
var err error
|
||||
id, err = e.qualifyResourceID(id, e.namespaces.profile, "profile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.InspectProfile(ctx, id)
|
||||
}
|
||||
|
||||
func (e *Engine) ListBackends(ctx context.Context, filters ...ListingFilter) ([]string, error) {
|
||||
return filterIdentityList(ctx, filters, e.namespaces.backend, e.listing.ListBackends)
|
||||
}
|
||||
|
||||
func (e *Engine) ListProfiles(ctx context.Context, filters ...ListingFilter) ([]string, error) {
|
||||
return filterIdentityList(ctx, filters, e.namespaces.profile, e.listing.ListProfiles)
|
||||
}
|
||||
|
||||
func (e *Engine) ListPrompts(ctx context.Context, filters ...ListingFilter) ([]domain.PromptIdentity, error) {
|
||||
mode, namespace, err := resolveListingFilter(filters, e.namespaces.prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prompts, err := e.listing.ListPrompts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []domain.PromptIdentity
|
||||
for _, prompt := range prompts {
|
||||
if matchesListingNamespace(prompt.ID, mode, namespace) {
|
||||
filtered = append(filtered, prompt)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (e *Engine) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
||||
for _, kind := range kinds {
|
||||
switch kind {
|
||||
case "prompt", "profile", "backend":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown resource kind %q", usecase.ErrInvalidRequest, kind)
|
||||
}
|
||||
}
|
||||
return e.listing.ListNamespaces(ctx, kinds)
|
||||
}
|
||||
|
||||
func (e *Engine) ListCollections(ctx context.Context) ([]configuration.CollectionIdentity, error) {
|
||||
return e.listing.ListCollections(ctx)
|
||||
}
|
||||
|
||||
func filterIdentityList(
|
||||
ctx context.Context,
|
||||
filters []ListingFilter,
|
||||
scope string,
|
||||
list func(context.Context) ([]string, error),
|
||||
) ([]string, error) {
|
||||
mode, namespace, err := resolveListingFilter(filters, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := list(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []string
|
||||
for _, id := range ids {
|
||||
if matchesListingNamespace(id, mode, namespace) {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func resolveListingFilter(filters []ListingFilter, scope string) (ListingFilterMode, string, error) {
|
||||
if len(filters) > 1 {
|
||||
return 0, "", fmt.Errorf("%w: at most one listing filter is allowed", usecase.ErrInvalidRequest)
|
||||
}
|
||||
filter := ListingFilter{}
|
||||
if len(filters) == 1 {
|
||||
filter = filters[0]
|
||||
}
|
||||
switch filter.Mode {
|
||||
case ListingDefault:
|
||||
if scope == "" {
|
||||
return ListingAll, "", nil
|
||||
}
|
||||
return ListingNamed, scope, nil
|
||||
case ListingNamed:
|
||||
if !domain.IsValidResourceNamespace(filter.Name) {
|
||||
return 0, "", fmt.Errorf("%w: invalid listing namespace", usecase.ErrInvalidRequest)
|
||||
}
|
||||
return ListingNamed, filter.Name, nil
|
||||
case ListingUnqualified, ListingAll:
|
||||
return filter.Mode, "", nil
|
||||
default:
|
||||
return 0, "", fmt.Errorf("%w: unknown listing filter mode", usecase.ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func matchesListingNamespace(id string, mode ListingFilterMode, namespace string) bool {
|
||||
switch mode {
|
||||
case ListingNamed:
|
||||
idNamespace, _, qualified := strings.Cut(id, "/")
|
||||
return qualified && idNamespace == namespace
|
||||
case ListingUnqualified:
|
||||
return !strings.Contains(id, "/")
|
||||
case ListingAll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) {
|
||||
request, err := e.qualifyRunRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.Prepare(ctx, request)
|
||||
}
|
||||
func (e *Engine) PrepareExecution(ctx context.Context, request domain.RunRequest) (*usecase.PreparedExecution, error) {
|
||||
request, err := e.qualifyRunRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.PrepareExecution(ctx, request)
|
||||
}
|
||||
func (e *Engine) Run(ctx context.Context, request domain.RunRequest) (*domain.RunResult, error) {
|
||||
request, err := e.qualifyRunRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.runner.Run(ctx, request)
|
||||
}
|
||||
|
||||
func (e *Engine) qualifyRunRequest(request domain.RunRequest) (domain.RunRequest, error) {
|
||||
var err error
|
||||
request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt")
|
||||
if err != nil {
|
||||
return domain.RunRequest{}, err
|
||||
}
|
||||
request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile")
|
||||
if err != nil {
|
||||
return domain.RunRequest{}, err
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (e *Engine) qualifyResourceID(id, namespace, kind string) (string, error) {
|
||||
if id == "" {
|
||||
return "", nil
|
||||
}
|
||||
qualified, ok := domain.QualifyResourceID(id, namespace)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: invalid %s ID", usecase.ErrInvalidRequest, kind)
|
||||
}
|
||||
return qualified, nil
|
||||
}
|
||||
|
||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *usecase.PreparedExecution) (*domain.RunResult, error) {
|
||||
return e.runner.RunPrepared(ctx, prepared)
|
||||
}
|
||||
|
||||
func (e *Engine) SmokeTest(ctx context.Context, request domain.SmokeTestRequest) (*domain.SmokeTestReport, error) {
|
||||
if e == nil || e.smokeTester == nil {
|
||||
return nil, fmt.Errorf("%w: smoke tester is not configured", usecase.ErrInvalidRequest)
|
||||
}
|
||||
var err error
|
||||
request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.BackendID, err = e.qualifyResourceID(request.BackendID, e.namespaces.backend, "backend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.smokeTester.Run(ctx, request)
|
||||
}
|
||||
|
||||
124
internal/engine/smoke_test.go
Normal file
124
internal/engine/smoke_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func engineSmokeBackendInput() configkit.Input {
|
||||
return configkit.Input{Source: configkit.Mapping("smoke-test backend", map[string]any{
|
||||
"backends": map[string]any{"test-backend": map[string]any{
|
||||
"endpoint": "https://test-backend.example/v1",
|
||||
"smoke_tests": map[string]any{
|
||||
"default_profile": "test-profile",
|
||||
"authentication": map[string]any{"kind": "none"},
|
||||
},
|
||||
}},
|
||||
"profiles": map[string]any{"test-profile": map[string]any{
|
||||
"backend": "test-backend",
|
||||
"model": "synthetic-model",
|
||||
}},
|
||||
})}
|
||||
}
|
||||
|
||||
func TestEngineSmokeTestUsesInjectedGenerationWithoutHTTPSupport(t *testing.T) {
|
||||
calls := 0
|
||||
options := Options{}
|
||||
options.SetLLMClient(engineSmokeClientFunc(func(_ context.Context, request domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||
calls++
|
||||
if len(request.Prompt.Messages) != 1 || request.Prompt.Messages[0].Role != "user" {
|
||||
t.Fatalf("injected generation request = %+v", request)
|
||||
}
|
||||
return &domain.GenerateResponse{Content: "engine response"}, nil
|
||||
}))
|
||||
input := engineSmokeBackendInput()
|
||||
assembled, err := New(Config{Sources: ConfigurationSources{Main: &input}}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := assembled.SmokeTest(context.Background(), domain.SmokeTestRequest{
|
||||
Kind: domain.SmokeTestTextInference, BackendID: "test-backend",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || !report.Completed || !report.Supported || report.Applicable == nil || !*report.Applicable ||
|
||||
report.Facts.GenerationCompleted == nil || !*report.Facts.GenerationCompleted ||
|
||||
report.Facts.TextReturned == nil || !*report.Facts.TextReturned ||
|
||||
report.Facts.ExpectedTextMatched == nil || *report.Facts.ExpectedTextMatched ||
|
||||
report.Facts.EndpointReachable != nil || report.Target.BackendID != "test-backend" {
|
||||
t.Fatalf("injected smoke report calls=%d: %+v", calls, report)
|
||||
}
|
||||
if report.Target.Endpoint != "" || report.Target.EffectiveSettings.Endpoint == "" || len(report.Attempts) != 1 ||
|
||||
report.Attempts[0].InitialEndpoint != "" || report.Attempts[0].Endpoint != "" {
|
||||
t.Fatalf("injected report claimed HTTP destinations: %+v", report)
|
||||
}
|
||||
|
||||
authentication, err := assembled.SmokeTest(context.Background(), domain.SmokeTestRequest{
|
||||
Kind: domain.SmokeTestAuthentication, BackendID: "test-backend",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !authentication.Supported || authentication.Applicable == nil || *authentication.Applicable ||
|
||||
authentication.Reason != domain.SmokeTestReasonAuthenticationNotApplicable || len(authentication.Attempts) != 0 {
|
||||
t.Fatalf("injected authentication report = %+v", authentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSmokeTestUsesBuiltInHTTPClientForInferenceEvidence(t *testing.T) {
|
||||
var observedURL string
|
||||
var authorization string
|
||||
options := Options{}
|
||||
input := engineSmokeBackendInput()
|
||||
assembled, err := New(Config{Sources: ConfigurationSources{Main: &input}, HTTPClient: &http.Client{Transport: engineRoundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
||||
observedURL = request.URL.String()
|
||||
authorization = request.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"engine response"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)),
|
||||
}, nil
|
||||
})}}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := assembled.SmokeTest(context.Background(), domain.SmokeTestRequest{
|
||||
Kind: domain.SmokeTestTextInference, BackendID: "test-backend",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observedURL == "" || authorization != "" {
|
||||
t.Fatalf("built-in request URL=%q authorization=%q", observedURL, authorization)
|
||||
}
|
||||
if !report.Completed || report.Facts.EndpointReachable == nil || !*report.Facts.EndpointReachable ||
|
||||
report.Facts.ResponseReceived == nil || !*report.Facts.ResponseReceived ||
|
||||
report.Facts.GenerationCompleted == nil || !*report.Facts.GenerationCompleted ||
|
||||
report.Facts.CredentialsSupplied == nil || *report.Facts.CredentialsSupplied ||
|
||||
report.Facts.TextReturned == nil || !*report.Facts.TextReturned ||
|
||||
report.Facts.ExpectedTextMatched == nil || *report.Facts.ExpectedTextMatched || len(report.Attempts) != 1 ||
|
||||
report.Attempts[0].Endpoint != observedURL {
|
||||
t.Fatalf("built-in smoke report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
type engineSmokeClientFunc func(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error)
|
||||
|
||||
func (f engineSmokeClientFunc) Generate(ctx context.Context, request domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||
return f(ctx, request)
|
||||
}
|
||||
|
||||
type engineRoundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f engineRoundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
@@ -72,14 +72,43 @@ func (e *ExhaustedError) Attempts() uint64 {
|
||||
// Generate executes either one opaque client call or a frozen built-in HTTP
|
||||
// sequence with one permit per actual dispatch.
|
||||
func (c *Coordinator) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||
response, err, _ := c.generate(ctx, req, false)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// GenerateWithEvidence runs the built-in retry path and returns its factual
|
||||
// attempt evidence alongside any response retained before cancellation.
|
||||
func (c *Coordinator) GenerateWithEvidence(
|
||||
ctx context.Context,
|
||||
req domain.GenerateRequest,
|
||||
) (*domain.GenerateResponse, error, GenerationEvidence) {
|
||||
return c.generate(ctx, req, true)
|
||||
}
|
||||
|
||||
func (c *Coordinator) generate(
|
||||
ctx context.Context,
|
||||
req domain.GenerateRequest,
|
||||
captureEvidence bool,
|
||||
) (*domain.GenerateResponse, error, GenerationEvidence) {
|
||||
if c.injected != nil {
|
||||
return c.generateInjected(ctx, req)
|
||||
return c.generateInjected(ctx, req, captureEvidence)
|
||||
}
|
||||
prepared, err := c.builtIn.PrepareGeneration(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, err, GenerationEvidence{}
|
||||
}
|
||||
|
||||
evidence := GenerationEvidence{}
|
||||
if captureEvidence {
|
||||
evidence.InitialEndpoint = prepared.InitialEndpoint()
|
||||
}
|
||||
var usage observation.UsageAccumulator
|
||||
finish := func(response *domain.GenerateResponse, err error) (*domain.GenerateResponse, error, GenerationEvidence) {
|
||||
if captureEvidence {
|
||||
evidence.Usage = usage.Observed()
|
||||
}
|
||||
return response, err, evidence
|
||||
}
|
||||
var attempts, retriesUsed uint64
|
||||
recorder := observation.FromContext(ctx)
|
||||
generation := req.Generation
|
||||
@@ -88,56 +117,102 @@ func (c *Coordinator) Generate(ctx context.Context, req domain.GenerateRequest)
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return finish(nil, err)
|
||||
}
|
||||
ordinal := attempts + 1
|
||||
outcome, dispatched, capacityWait, generationDuration := c.execute(ctx, req.Target.BackendID, prepared)
|
||||
outcome, dispatched, capacityWait, generationDuration, startedAt := c.execute(ctx, req.Target.BackendID, prepared, captureEvidence)
|
||||
if dispatched {
|
||||
attempts++
|
||||
}
|
||||
if captureEvidence {
|
||||
attempt := AttemptEvidence{
|
||||
StartedAt: startedAt.UTC(),
|
||||
Dispatched: dispatched,
|
||||
ResponseReceived: outcome.Evidence.ResponseReceived,
|
||||
HTTP: cloneHTTPEvidence(outcome.Evidence),
|
||||
CapacityWait: capacityWait,
|
||||
RequestDuration: generationDuration,
|
||||
Failure: ClassifyFailure(outcome.Err),
|
||||
ResponseDecoded: outcome.ResponseDecoded,
|
||||
GenerationCompleted: outcome.GenerationCompleted,
|
||||
}
|
||||
if outcome.StructuredOutputRequested != nil {
|
||||
requested := *outcome.StructuredOutputRequested
|
||||
attempt.StructuredOutputRequested = &requested
|
||||
}
|
||||
if dispatched {
|
||||
usage.Add(outcome.Usage, outcome.UsagePresence)
|
||||
var attemptUsage observation.UsageAccumulator
|
||||
attemptUsage.Add(outcome.Usage, outcome.UsagePresence)
|
||||
attempt.Usage = attemptUsage.Observed()
|
||||
}
|
||||
evidence.Attempts = append(evidence.Attempts, attempt)
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: ordinal,
|
||||
Dispatched: dispatched, CapacityWait: capacityWait, GenerationDuration: generationDuration,
|
||||
Outcome: attemptOutcome(outcome.Err), Failure: attemptFailure(outcome.Err),
|
||||
Outcome: attemptOutcome(outcome.Err), Failure: ClassifyFailure(outcome.Err),
|
||||
StatusCode: outcome.StatusCode, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence})
|
||||
}
|
||||
if outcome.Err == nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
if captureEvidence {
|
||||
return finish(outcome.Response, err)
|
||||
}
|
||||
return finish(nil, err)
|
||||
}
|
||||
return outcome.Response, nil
|
||||
return finish(outcome.Response, nil)
|
||||
}
|
||||
if !dispatched || !eligibleStatus(outcome) {
|
||||
return nil, outcome.Err
|
||||
if captureEvidence {
|
||||
return finish(outcome.Response, outcome.Err)
|
||||
}
|
||||
return finish(nil, outcome.Err)
|
||||
}
|
||||
if retriesUsed == req.TransientRetries {
|
||||
if retriesUsed == 0 {
|
||||
return nil, outcome.Err
|
||||
return finish(nil, outcome.Err)
|
||||
}
|
||||
return nil, &ExhaustedError{attempts: attempts, cause: outcome.Err}
|
||||
return finish(nil, &ExhaustedError{attempts: attempts, cause: outcome.Err})
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return finish(nil, err)
|
||||
}
|
||||
retryOrdinal := retriesUsed + 1
|
||||
delay := retryDelay(c.backoff(retryOrdinal), outcome.RetryNotBefore, c.now())
|
||||
waitStart := c.now()
|
||||
waitErr := c.wait(ctx, delay)
|
||||
waitDuration := elapsed(waitStart, c.now())
|
||||
if captureEvidence {
|
||||
evidence.RetryDelays = append(evidence.RetryDelays, waitDuration)
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.Delay(observation.DelayData{Generation: generation, Attempt: attempts,
|
||||
Elapsed: elapsed(waitStart, c.now()), Failure: attemptFailure(waitErr)})
|
||||
Elapsed: waitDuration, Failure: ClassifyFailure(waitErr)})
|
||||
}
|
||||
if waitErr != nil {
|
||||
return nil, waitErr
|
||||
return finish(nil, waitErr)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return finish(nil, err)
|
||||
}
|
||||
retriesUsed++
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||
func (c *Coordinator) generateInjected(
|
||||
ctx context.Context,
|
||||
req domain.GenerateRequest,
|
||||
captureEvidence bool,
|
||||
) (*domain.GenerateResponse, error, GenerationEvidence) {
|
||||
evidence := GenerationEvidence{}
|
||||
var usage observation.UsageAccumulator
|
||||
finish := func(response *domain.GenerateResponse, err error) (*domain.GenerateResponse, error, GenerationEvidence) {
|
||||
if captureEvidence {
|
||||
evidence.Usage = usage.Observed()
|
||||
}
|
||||
return response, err, evidence
|
||||
}
|
||||
recorder := observation.FromContext(ctx)
|
||||
generation := req.Generation
|
||||
if generation == 0 {
|
||||
@@ -147,18 +222,32 @@ func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateR
|
||||
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
|
||||
capacityWait := c.capacityWait(req.Target.BackendID, start)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
|
||||
if captureEvidence {
|
||||
evidence.Attempts = append(evidence.Attempts, AttemptEvidence{
|
||||
StartedAt: start.UTC(),
|
||||
CapacityWait: capacityWait,
|
||||
Failure: ClassifyFailure(err),
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
if recorder != nil {
|
||||
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: ClassifyFailure(err)})
|
||||
}
|
||||
return finish(nil, err)
|
||||
}
|
||||
defer release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
release()
|
||||
if recorder != nil {
|
||||
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
|
||||
if captureEvidence {
|
||||
evidence.Attempts = append(evidence.Attempts, AttemptEvidence{
|
||||
StartedAt: start.UTC(),
|
||||
CapacityWait: capacityWait,
|
||||
Failure: ClassifyFailure(err),
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
if recorder != nil {
|
||||
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: ClassifyFailure(err)})
|
||||
}
|
||||
return finish(nil, err)
|
||||
}
|
||||
dispatchStart := c.now()
|
||||
response, err := c.injected.Generate(ctx, req)
|
||||
@@ -166,10 +255,33 @@ func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateR
|
||||
if err == nil && response == nil {
|
||||
err = llm.ErrMalformedResponse
|
||||
}
|
||||
if captureEvidence {
|
||||
attempt := AttemptEvidence{
|
||||
StartedAt: start.UTC(),
|
||||
Dispatched: true,
|
||||
ResponseReceived: response != nil,
|
||||
RequestDuration: duration,
|
||||
Failure: ClassifyFailure(err),
|
||||
GenerationCompleted: err == nil && response != nil,
|
||||
}
|
||||
requested := req.StructuredOutput != nil
|
||||
attempt.StructuredOutputRequested = &requested
|
||||
var reportedUsage domain.TokenUsage
|
||||
var reportedPresence *domain.TokenUsagePresence
|
||||
if err == nil && response != nil {
|
||||
reportedUsage = response.Usage
|
||||
reportedPresence = response.UsagePresence
|
||||
}
|
||||
usage.Add(reportedUsage, reportedPresence)
|
||||
var attemptUsage observation.UsageAccumulator
|
||||
attemptUsage.Add(reportedUsage, reportedPresence)
|
||||
attempt.Usage = attemptUsage.Observed()
|
||||
evidence.Attempts = append(evidence.Attempts, attempt)
|
||||
}
|
||||
release()
|
||||
if recorder != nil {
|
||||
data := observation.AttemptData{Generation: generation, Attempt: 1, Dispatched: true, CapacityWait: capacityWait, GenerationDuration: duration,
|
||||
Outcome: attemptOutcome(err), Failure: attemptFailure(err)}
|
||||
Outcome: attemptOutcome(err), Failure: ClassifyFailure(err)}
|
||||
if err == nil {
|
||||
data.Usage = response.Usage
|
||||
data.UsagePresence = response.UsagePresence
|
||||
@@ -177,28 +289,38 @@ func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateR
|
||||
recorder.Attempt(data)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return finish(nil, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
if captureEvidence {
|
||||
return finish(response, err)
|
||||
}
|
||||
return finish(nil, err)
|
||||
}
|
||||
return response, nil
|
||||
return finish(response, nil)
|
||||
}
|
||||
|
||||
func (c *Coordinator) execute(ctx context.Context, backendID string, prepared *llm.PreparedGeneration) (outcome llm.AttemptOutcome, dispatched bool, capacityWait, generationDuration time.Duration) {
|
||||
func (c *Coordinator) execute(ctx context.Context, backendID string, prepared *llm.PreparedGeneration, captureEvidence bool) (outcome llm.AttemptOutcome, dispatched bool, capacityWait, generationDuration time.Duration, startedAt time.Time) {
|
||||
start := c.now()
|
||||
startedAt = start
|
||||
release, err := c.manager.Acquire(ctx, backendID)
|
||||
capacityWait = c.capacityWait(backendID, start)
|
||||
if err != nil {
|
||||
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
|
||||
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0, startedAt
|
||||
}
|
||||
defer release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
|
||||
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0, startedAt
|
||||
}
|
||||
dispatchStart := c.now()
|
||||
outcome = prepared.Execute(ctx)
|
||||
return outcome, true, capacityWait, elapsed(dispatchStart, c.now())
|
||||
if captureEvidence {
|
||||
outcome = prepared.ExecuteWithEvidence(ctx)
|
||||
dispatched = outcome.Evidence.DispatchInvoked
|
||||
} else {
|
||||
outcome = prepared.Execute(ctx)
|
||||
dispatched = true
|
||||
}
|
||||
return outcome, dispatched, capacityWait, elapsed(dispatchStart, c.now()), startedAt
|
||||
}
|
||||
|
||||
func (c *Coordinator) capacityWait(backendID string, start time.Time) time.Duration {
|
||||
@@ -222,7 +344,8 @@ func attemptOutcome(err error) domain.ExecutionOutcome {
|
||||
return domain.ExecutionOutcomeError
|
||||
}
|
||||
|
||||
func attemptFailure(err error) domain.ExecutionFailureKind {
|
||||
// ClassifyFailure maps an execution error to the shared content-free category.
|
||||
func ClassifyFailure(err error) domain.ExecutionFailureKind {
|
||||
if err == nil {
|
||||
return domain.ExecutionFailureNone
|
||||
}
|
||||
|
||||
43
internal/generation/evidence.go
Normal file
43
internal/generation/evidence.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package generation
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
// GenerationEvidence contains invocation-local request evidence without raw
|
||||
// provider bodies, headers, credentials, or generated content.
|
||||
type GenerationEvidence struct {
|
||||
// InitialEndpoint is the frozen built-in request URL, if prepared.
|
||||
// Injected generation leaves it unknown.
|
||||
InitialEndpoint string
|
||||
Attempts []AttemptEvidence
|
||||
RetryDelays []time.Duration
|
||||
Usage domain.ObservedTokenUsage
|
||||
}
|
||||
|
||||
// AttemptEvidence describes one capacity acquisition and any dispatch it
|
||||
// reached. Generated content remains on the separate response return value.
|
||||
type AttemptEvidence struct {
|
||||
StartedAt time.Time
|
||||
Dispatched bool
|
||||
ResponseReceived bool
|
||||
HTTP llm.HTTPExecutionEvidence
|
||||
CapacityWait time.Duration
|
||||
RequestDuration time.Duration
|
||||
Failure domain.ExecutionFailureKind
|
||||
Usage domain.ObservedTokenUsage
|
||||
ResponseDecoded bool
|
||||
GenerationCompleted bool
|
||||
StructuredOutputRequested *bool
|
||||
}
|
||||
|
||||
func cloneHTTPEvidence(evidence llm.HTTPExecutionEvidence) llm.HTTPExecutionEvidence {
|
||||
if evidence.HTTPStatus != nil {
|
||||
status := *evidence.HTTPStatus
|
||||
evidence.HTTPStatus = &status
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
321
internal/generation/evidence_test.go
Normal file
321
internal/generation/evidence_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package generation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
)
|
||||
|
||||
func TestGenerateWithEvidenceRetainsAttemptsAndUsage(t *testing.T) {
|
||||
var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
now = now.Add(4 * time.Millisecond)
|
||||
return response(http.StatusServiceUnavailable, `{"error":{}}`), nil
|
||||
}
|
||||
now = now.Add(7 * time.Millisecond)
|
||||
return response(http.StatusOK, `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":3}}`), nil
|
||||
})
|
||||
coordinator.now = func() time.Time { return now }
|
||||
coordinator.wait = func(context.Context, time.Duration) error {
|
||||
now = now.Add(19 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(context.Background(), testRequest(1))
|
||||
if err != nil || result == nil || result.Content != "ok" || calls != 2 {
|
||||
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
||||
}
|
||||
if len(evidence.Attempts) != 2 || len(evidence.RetryDelays) != 1 || evidence.RetryDelays[0] != 19*time.Millisecond {
|
||||
t.Fatalf("attempts/delays = %#v / %#v", evidence.Attempts, evidence.RetryDelays)
|
||||
}
|
||||
first, second := evidence.Attempts[0], evidence.Attempts[1]
|
||||
if !first.Dispatched || !first.HTTP.RequestObserved || !first.HTTP.ResponseReceived || first.HTTP.HTTPStatus == nil || *first.HTTP.HTTPStatus != http.StatusServiceUnavailable {
|
||||
t.Fatalf("first attempt evidence = %+v", first)
|
||||
}
|
||||
if first.Failure != domain.ExecutionFailureProviderStatus || first.RequestDuration != 4*time.Millisecond {
|
||||
t.Fatalf("first attempt failure/timing = %v / %v", first.Failure, first.RequestDuration)
|
||||
}
|
||||
if !second.Dispatched || !second.GenerationCompleted || !second.ResponseDecoded || second.HTTP.HTTPStatus == nil || *second.HTTP.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("second attempt evidence = %+v", second)
|
||||
}
|
||||
if second.Failure != domain.ExecutionFailureNone || second.RequestDuration != 7*time.Millisecond {
|
||||
t.Fatalf("second attempt failure/timing = %v / %v", second.Failure, second.RequestDuration)
|
||||
}
|
||||
if !second.Usage.PromptTokens.Known || !second.Usage.PromptTokens.Complete || second.Usage.PromptTokens.Value != 3 {
|
||||
t.Fatalf("per-attempt usage = %+v", second.Usage.PromptTokens)
|
||||
}
|
||||
if !evidence.Usage.PromptTokens.Known || evidence.Usage.PromptTokens.Complete || evidence.Usage.PromptTokens.Value != 3 {
|
||||
t.Fatalf("aggregate usage = %+v", evidence.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceRetainsExhaustedAttempts(t *testing.T) {
|
||||
calls := 0
|
||||
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return response(http.StatusServiceUnavailable, `{"error":{}}`), nil
|
||||
})
|
||||
coordinator.wait = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(context.Background(), testRequest(1))
|
||||
var exhausted *ExhaustedError
|
||||
if result != nil || !errors.As(err, &exhausted) || calls != 2 || exhausted.Attempts() != 2 {
|
||||
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
||||
}
|
||||
if len(evidence.Attempts) != 2 || len(evidence.RetryDelays) != 1 {
|
||||
t.Fatalf("attempt/delay evidence = %#v / %#v", evidence.Attempts, evidence.RetryDelays)
|
||||
}
|
||||
for _, attempt := range evidence.Attempts {
|
||||
if !attempt.Dispatched || attempt.Failure != domain.ExecutionFailureProviderStatus || attempt.HTTP.HTTPStatus == nil || *attempt.HTTP.HTTPStatus != http.StatusServiceUnavailable {
|
||||
t.Fatalf("exhausted attempt evidence = %+v", attempt)
|
||||
}
|
||||
}
|
||||
if evidence.Usage.PromptTokens.Known || evidence.Usage.PromptTokens.Complete {
|
||||
t.Fatalf("missing provider usage was claimed: %+v", evidence.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceRetainsInterruptedBackoffWithoutAttempt(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
now = now.Add(3 * time.Millisecond)
|
||||
return response(http.StatusServiceUnavailable, `{"error":{}}`), nil
|
||||
})
|
||||
coordinator.now = func() time.Time { return now }
|
||||
coordinator.wait = func(ctx context.Context, _ time.Duration) error {
|
||||
now = now.Add(11 * time.Millisecond)
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(ctx, testRequest(2))
|
||||
if result != nil || !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
||||
}
|
||||
if len(evidence.Attempts) != 1 || !evidence.Attempts[0].Dispatched || len(evidence.RetryDelays) != 1 || evidence.RetryDelays[0] != 11*time.Millisecond {
|
||||
t.Fatalf("interrupted evidence = %#v", evidence)
|
||||
}
|
||||
if evidence.Attempts[0].HTTP.HTTPStatus == nil || *evidence.Attempts[0].HTTP.HTTPStatus != http.StatusServiceUnavailable {
|
||||
t.Fatalf("last dispatched response was lost: %+v", evidence.Attempts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceRecordsCanceledReacquisition(t *testing.T) {
|
||||
var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
now = now.Add(2 * time.Millisecond)
|
||||
return response(http.StatusServiceUnavailable, `{"error":{}}`), nil
|
||||
})
|
||||
coordinator.now = func() time.Time { return now }
|
||||
baseContext, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
contextWithCancelOnWait := &cancelOnDoneContext{Context: baseContext, cancel: cancel}
|
||||
var releaseHeld func()
|
||||
coordinator.wait = func(context.Context, time.Duration) error {
|
||||
release, err := coordinator.manager.Acquire(context.Background(), "limited")
|
||||
if err != nil {
|
||||
t.Fatalf("hold permit: %v", err)
|
||||
}
|
||||
releaseHeld = release
|
||||
contextWithCancelOnWait.armed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Signal cancellation only when the second acquisition has queued behind
|
||||
// the held permit, avoiding scheduler- or sleep-based synchronization.
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(contextWithCancelOnWait, testRequest(2))
|
||||
if releaseHeld != nil {
|
||||
releaseHeld()
|
||||
}
|
||||
if result != nil || !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
|
||||
}
|
||||
if len(evidence.Attempts) != 2 || !evidence.Attempts[0].Dispatched || evidence.Attempts[1].Dispatched {
|
||||
t.Fatalf("acquisition evidence = %#v", evidence.Attempts)
|
||||
}
|
||||
if evidence.Attempts[1].Failure != domain.ExecutionFailureCanceled {
|
||||
t.Fatalf("canceled acquisition failure = %v", evidence.Attempts[1].Failure)
|
||||
}
|
||||
if evidence.Attempts[0].HTTP.HTTPStatus == nil || *evidence.Attempts[0].HTTP.HTTPStatus != http.StatusServiceUnavailable {
|
||||
t.Fatalf("last dispatched evidence was lost: %+v", evidence.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceNoDispatchHasCompleteZeroUsage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("canceled generation dispatched")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(ctx, testRequest(0))
|
||||
if result != nil || !errors.Is(err, context.Canceled) || len(evidence.Attempts) != 0 {
|
||||
t.Fatalf("result=%+v error=%v evidence=%+v", result, err, evidence)
|
||||
}
|
||||
if !evidence.Usage.PromptTokens.Known || !evidence.Usage.PromptTokens.Complete || evidence.Usage.PromptTokens.Value != 0 {
|
||||
t.Fatalf("no-dispatch usage = %+v", evidence.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceForInjectedClient(t *testing.T) {
|
||||
opaqueFailure := errors.New("injected client failed")
|
||||
presence := &domain.TokenUsagePresence{PromptTokens: true}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response *domain.GenerateResponse
|
||||
err error
|
||||
wantResponse bool
|
||||
wantGeneration bool
|
||||
wantFailure domain.ExecutionFailureKind
|
||||
wantUsage bool
|
||||
wantStructured bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
response: &domain.GenerateResponse{Content: "private output", Usage: domain.TokenUsage{PromptTokens: 4}, UsagePresence: presence},
|
||||
wantResponse: true,
|
||||
wantGeneration: true,
|
||||
wantUsage: true,
|
||||
wantStructured: true,
|
||||
},
|
||||
{
|
||||
name: "nil response",
|
||||
wantFailure: domain.ExecutionFailureMalformedResponse,
|
||||
},
|
||||
{
|
||||
name: "opaque failure",
|
||||
err: opaqueFailure,
|
||||
wantFailure: domain.ExecutionFailureGeneration,
|
||||
},
|
||||
{
|
||||
name: "response with error",
|
||||
response: &domain.GenerateResponse{Content: "must not be evaluated", Usage: domain.TokenUsage{PromptTokens: 9}, UsagePresence: presence},
|
||||
err: opaqueFailure,
|
||||
wantResponse: true,
|
||||
wantFailure: domain.ExecutionFailureGeneration,
|
||||
wantStructured: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := &evidenceInjectedClient{response: tc.response, err: tc.err}
|
||||
coordinator := newEvidenceInjectedCoordinator(t, client)
|
||||
req := testRequest(7)
|
||||
if tc.wantStructured {
|
||||
req.StructuredOutput = &domain.StructuredOutputSpec{}
|
||||
}
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(context.Background(), req)
|
||||
if client.calls != 1 || len(evidence.Attempts) != 1 || len(evidence.RetryDelays) != 0 {
|
||||
t.Fatalf("calls=%d attempts=%+v retry delays=%v", client.calls, evidence.Attempts, evidence.RetryDelays)
|
||||
}
|
||||
attempt := evidence.Attempts[0]
|
||||
if !attempt.Dispatched || attempt.ResponseReceived != tc.wantResponse || attempt.GenerationCompleted != tc.wantGeneration || attempt.Failure != tc.wantFailure {
|
||||
t.Fatalf("attempt evidence = %+v", attempt)
|
||||
}
|
||||
if attempt.HTTP != (llm.HTTPExecutionEvidence{}) {
|
||||
t.Fatalf("injected call claimed HTTP evidence: %+v", attempt.HTTP)
|
||||
}
|
||||
if attempt.StructuredOutputRequested == nil || *attempt.StructuredOutputRequested != tc.wantStructured {
|
||||
t.Fatalf("structured-output request fact = %v", attempt.StructuredOutputRequested)
|
||||
}
|
||||
if tc.wantFailure == domain.ExecutionFailureNone {
|
||||
if err != nil || result == nil || result.Content != "private output" {
|
||||
t.Fatalf("result=%+v error=%v", result, err)
|
||||
}
|
||||
} else if result != nil || err == nil {
|
||||
t.Fatalf("result=%+v error=%v; expected failed call", result, err)
|
||||
}
|
||||
if attempt.Usage.PromptTokens.Known != tc.wantUsage || evidence.Usage.PromptTokens.Known != tc.wantUsage {
|
||||
t.Fatalf("attempt/aggregate usage = %+v / %+v", attempt.Usage, evidence.Usage)
|
||||
}
|
||||
if tc.wantUsage && (attempt.Usage.PromptTokens.Value != 4 || evidence.Usage.PromptTokens.Value != 4) {
|
||||
t.Fatalf("reported usage was not retained: %+v / %+v", attempt.Usage, evidence.Usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWithEvidenceRetainsInjectedSuccessAfterCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
client := &evidenceInjectedClient{
|
||||
response: &domain.GenerateResponse{Content: "private output"},
|
||||
afterGenerate: func() { cancel() },
|
||||
}
|
||||
coordinator := newEvidenceInjectedCoordinator(t, client)
|
||||
req := testRequest(5)
|
||||
req.StructuredOutput = &domain.StructuredOutputSpec{}
|
||||
|
||||
result, err, evidence := coordinator.GenerateWithEvidence(ctx, req)
|
||||
if !errors.Is(err, context.Canceled) || result == nil || result.Content != "private output" {
|
||||
t.Fatalf("result=%+v error=%v", result, err)
|
||||
}
|
||||
if client.calls != 1 || len(evidence.Attempts) != 1 {
|
||||
t.Fatalf("calls=%d attempts=%+v", client.calls, evidence.Attempts)
|
||||
}
|
||||
attempt := evidence.Attempts[0]
|
||||
if !attempt.ResponseReceived || !attempt.GenerationCompleted || attempt.Failure != domain.ExecutionFailureNone {
|
||||
t.Fatalf("successful generation evidence was lost: %+v", attempt)
|
||||
}
|
||||
if attempt.HTTP != (llm.HTTPExecutionEvidence{}) {
|
||||
t.Fatalf("injected call claimed HTTP evidence: %+v", attempt.HTTP)
|
||||
}
|
||||
}
|
||||
|
||||
type evidenceInjectedClient struct {
|
||||
calls int
|
||||
response *domain.GenerateResponse
|
||||
err error
|
||||
afterGenerate func()
|
||||
}
|
||||
|
||||
func (c *evidenceInjectedClient) Generate(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||
c.calls++
|
||||
if c.afterGenerate != nil {
|
||||
c.afterGenerate()
|
||||
}
|
||||
return c.response, c.err
|
||||
}
|
||||
|
||||
func newEvidenceInjectedCoordinator(t *testing.T, client llm.Client) *Coordinator {
|
||||
t.Helper()
|
||||
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{
|
||||
"limited": {ConcurrencyLimit: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewInjected(manager, client)
|
||||
}
|
||||
|
||||
type cancelOnDoneContext struct {
|
||||
context.Context
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
armed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *cancelOnDoneContext) Done() <-chan struct{} {
|
||||
if c.armed.Load() {
|
||||
c.once.Do(c.cancel)
|
||||
}
|
||||
return c.Context.Done()
|
||||
}
|
||||
191
internal/llm/http_evidence.go
Normal file
191
internal/llm/http_evidence.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
// HTTPExecutionEvidence contains only facts observable at the configured
|
||||
// RoundTripper boundary. It intentionally excludes request headers and bodies.
|
||||
type HTTPExecutionEvidence struct {
|
||||
DispatchInvoked bool
|
||||
RequestObserved bool
|
||||
Endpoint string
|
||||
CredentialsSupplied bool
|
||||
ResponseReceived bool
|
||||
HTTPStatus *int
|
||||
}
|
||||
|
||||
type httpEvidenceCollector struct {
|
||||
mu sync.Mutex
|
||||
evidence HTTPExecutionEvidence
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) observeRequest(request *http.Request) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.evidence.RequestObserved = true
|
||||
c.evidence.Endpoint = endpointWithoutUserInfo(request)
|
||||
c.evidence.CredentialsSupplied = requestHasCredentials(request)
|
||||
c.evidence.ResponseReceived = false
|
||||
c.evidence.HTTPStatus = nil
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) observeResponse(response *http.Response) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
status := response.StatusCode
|
||||
c.evidence.ResponseReceived = true
|
||||
c.evidence.HTTPStatus = &status
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) snapshot() HTTPExecutionEvidence {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evidence := c.evidence
|
||||
if c.evidence.HTTPStatus != nil {
|
||||
status := *c.evidence.HTTPStatus
|
||||
evidence.HTTPStatus = &status
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
type observingRoundTripper struct {
|
||||
next http.RoundTripper
|
||||
evidence *httpEvidenceCollector
|
||||
}
|
||||
|
||||
func (t observingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
t.evidence.observeRequest(request)
|
||||
response, err := t.next.RoundTrip(request)
|
||||
t.evidence.observeResponse(response)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// doWithEvidence executes one request with a shallow copy of client. The copy
|
||||
// preserves timeout, redirect, and cookie-jar behavior without changing client.
|
||||
func doWithEvidence(client *http.Client, request *http.Request) (*http.Response, error, HTTPExecutionEvidence) {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
collector := &httpEvidenceCollector{}
|
||||
clientCopy := *client
|
||||
transport := clientCopy.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
clientCopy.Transport = observingRoundTripper{next: transport, evidence: collector}
|
||||
|
||||
response, err := clientCopy.Do(request)
|
||||
observed := collector.snapshot()
|
||||
observed.DispatchInvoked = true
|
||||
return response, err, observed
|
||||
}
|
||||
|
||||
// GetWithEvidence performs a plain GET with the configured built-in HTTP
|
||||
// client. A positive timeoutSeconds applies to the request through response
|
||||
// body closure, matching the whole-request behavior of http.Client.Timeout.
|
||||
func (c *OpenAICompatibleClient) GetWithEvidence(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
timeoutSeconds int,
|
||||
) (*http.Response, HTTPExecutionEvidence, error) {
|
||||
return c.getWithEvidence(ctx, endpoint, timeoutSeconds, "")
|
||||
}
|
||||
|
||||
// GetWithAuthenticationEvidence performs a plain GET with credentials resolved
|
||||
// from the selected target. Missing credentials are allowed for diagnostic
|
||||
// authentication probes even when generation requires a key.
|
||||
func (c *OpenAICompatibleClient) GetWithAuthenticationEvidence(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
timeoutSeconds int,
|
||||
target domain.ExecutionTarget,
|
||||
) (*http.Response, HTTPExecutionEvidence, error) {
|
||||
return c.getWithEvidence(ctx, endpoint, timeoutSeconds, resolveAPIKey(target))
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) getWithEvidence(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
timeoutSeconds int,
|
||||
apiKey string,
|
||||
) (*http.Response, HTTPExecutionEvidence, error) {
|
||||
requestContext := ctx
|
||||
var cancel context.CancelFunc
|
||||
if timeoutSeconds > 0 {
|
||||
requestContext, cancel = context.WithTimeout(ctx, secondsDuration(timeoutSeconds))
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil, HTTPExecutionEvidence{}, err
|
||||
}
|
||||
if apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(c.httpClient, request)
|
||||
if response == nil || response.Body == nil {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return response, evidence, err
|
||||
}
|
||||
if cancel != nil {
|
||||
response.Body = &cancelOnCloseReadCloser{ReadCloser: response.Body, cancel: cancel}
|
||||
}
|
||||
return response, evidence, err
|
||||
}
|
||||
|
||||
func secondsDuration(seconds int) time.Duration {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
type cancelOnCloseReadCloser struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *cancelOnCloseReadCloser) Close() error {
|
||||
r.once.Do(r.cancel)
|
||||
return r.ReadCloser.Close()
|
||||
}
|
||||
|
||||
func endpointWithoutUserInfo(request *http.Request) string {
|
||||
if request == nil || request.URL == nil {
|
||||
return ""
|
||||
}
|
||||
endpoint := *request.URL
|
||||
endpoint.User = nil
|
||||
return endpoint.String()
|
||||
}
|
||||
|
||||
func requestHasCredentials(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
return true
|
||||
}
|
||||
return request.URL != nil && request.URL.User != nil
|
||||
}
|
||||
|
||||
var _ http.RoundTripper = observingRoundTripper{}
|
||||
var _ io.ReadCloser = (*cancelOnCloseReadCloser)(nil)
|
||||
340
internal/llm/http_evidence_test.go
Normal file
340
internal/llm/http_evidence_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestGetWithEvidenceCapturesResponseAndOmitsURLUserInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") == "" {
|
||||
t.Error("request did not carry URL user credentials")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("construct client: %v", err)
|
||||
}
|
||||
endpoint := strings.Replace(server.URL, "http://", "http://user:secret@", 1) + "/health"
|
||||
response, evidence, err := client.GetWithEvidence(context.Background(), endpoint, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GET endpoint: %v", err)
|
||||
}
|
||||
if response == nil {
|
||||
t.Fatal("GET returned no response")
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
if !evidence.DispatchInvoked || !evidence.RequestObserved || !evidence.ResponseReceived {
|
||||
t.Fatalf("incomplete response evidence: %+v", evidence)
|
||||
}
|
||||
if evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusNoContent {
|
||||
t.Fatalf("status evidence = %v, want %d", evidence.HTTPStatus, http.StatusNoContent)
|
||||
}
|
||||
if !evidence.CredentialsSupplied {
|
||||
t.Fatal("evidence did not record URL credentials")
|
||||
}
|
||||
if strings.Contains(evidence.Endpoint, "user") || strings.Contains(evidence.Endpoint, "secret") {
|
||||
t.Fatalf("exposed endpoint contains URL user info: %q", evidence.Endpoint)
|
||||
}
|
||||
if want := server.URL + "/health"; evidence.Endpoint != want {
|
||||
t.Fatalf("endpoint = %q, want %q", evidence.Endpoint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithAuthenticationEvidenceResolvesOptionalCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target domain.ExecutionTarget
|
||||
environment string
|
||||
wantAuth string
|
||||
}{
|
||||
{
|
||||
name: "direct key takes precedence",
|
||||
target: domain.ExecutionTarget{
|
||||
APIKey: " direct-key ",
|
||||
APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY",
|
||||
},
|
||||
environment: "environment-key",
|
||||
wantAuth: "Bearer direct-key",
|
||||
},
|
||||
{
|
||||
name: "environment key is used",
|
||||
target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY"},
|
||||
environment: " environment-key ",
|
||||
wantAuth: "Bearer environment-key",
|
||||
},
|
||||
{
|
||||
name: "missing required key is optional here",
|
||||
target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_DIAGNOSTIC_KEY", APIKeyRequired: true},
|
||||
environment: " ",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("PROMPTKIT_DIAGNOSTIC_KEY", tc.environment)
|
||||
var authorization string
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
authorization = request.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response, evidence, err := client.GetWithAuthenticationEvidence(
|
||||
context.Background(), "https://example.invalid/key", 0, tc.target,
|
||||
)
|
||||
if err != nil || response == nil {
|
||||
t.Fatalf("response=%v error=%v", response, err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if authorization != tc.wantAuth || evidence.CredentialsSupplied != (tc.wantAuth != "") {
|
||||
t.Fatalf("authorization=%q evidence=%+v", authorization, evidence)
|
||||
}
|
||||
if evidence.Endpoint != "https://example.invalid/key" || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized {
|
||||
t.Fatalf("HTTP evidence = %+v", evidence)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithEvidenceRecordsRequestWithoutResponse(t *testing.T) {
|
||||
transportErr := errors.New("transport unavailable")
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, transportErr
|
||||
})}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(client, request)
|
||||
if response != nil || !errors.Is(err, transportErr) {
|
||||
t.Fatalf("response/error = %v/%v, want nil/transport error", response, err)
|
||||
}
|
||||
if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived {
|
||||
t.Fatalf("unexpected request evidence: %+v", evidence)
|
||||
}
|
||||
if evidence.HTTPStatus != nil || evidence.Endpoint != request.URL.String() || evidence.CredentialsSupplied {
|
||||
t.Fatalf("unexpected evidence values: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithEvidenceRetainsResponseReturnedAlongsideError(t *testing.T) {
|
||||
transportErr := errors.New("transport reported an error with a response")
|
||||
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return responseFor(request, http.StatusBadGateway, ""), transportErr
|
||||
})}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(client, request)
|
||||
if !errors.Is(err, transportErr) {
|
||||
t.Fatalf("error = %v, want transport error", err)
|
||||
}
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
if !evidence.RequestObserved || !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusBadGateway {
|
||||
t.Fatalf("observed response was lost alongside error: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithEvidenceUsesLastRedirectRequest(t *testing.T) {
|
||||
var observedAuthorization []string
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
observedAuthorization = append(observedAuthorization, request.Header.Get("Authorization"))
|
||||
if request.URL.Host == "source.example" {
|
||||
return responseFor(request, http.StatusFound, "https://other.example/final"), nil
|
||||
}
|
||||
return responseFor(request, http.StatusUnauthorized, ""), nil
|
||||
})
|
||||
client := &http.Client{Transport: transport}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer private-token")
|
||||
|
||||
response, err, evidence := doWithEvidence(client, request)
|
||||
if err != nil {
|
||||
t.Fatalf("follow redirect: %v", err)
|
||||
}
|
||||
if response == nil {
|
||||
t.Fatal("redirect returned no response")
|
||||
}
|
||||
response.Body.Close()
|
||||
if len(observedAuthorization) != 2 || observedAuthorization[0] == "" || observedAuthorization[1] != "" {
|
||||
t.Fatalf("authorization across redirect = %#v", observedAuthorization)
|
||||
}
|
||||
if evidence.Endpoint != "https://other.example/final" || evidence.CredentialsSupplied {
|
||||
t.Fatalf("evidence did not describe credential-stripped last hop: %+v", evidence)
|
||||
}
|
||||
if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized {
|
||||
t.Fatalf("last-hop response evidence = %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithEvidenceClearsPriorResponseOnFailedRedirect(t *testing.T) {
|
||||
transportErr := errors.New("redirect target unavailable")
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Host == "source.example" {
|
||||
return responseFor(request, http.StatusTemporaryRedirect, "https://target.example/unavailable"), nil
|
||||
}
|
||||
return nil, transportErr
|
||||
})
|
||||
client := &http.Client{Transport: transport}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(client, request)
|
||||
if response != nil || !errors.Is(err, transportErr) {
|
||||
t.Fatalf("response/error = %v/%v, want nil/transport error", response, err)
|
||||
}
|
||||
if !evidence.RequestObserved || evidence.Endpoint != "https://target.example/unavailable" {
|
||||
t.Fatalf("evidence did not retain failed last request: %+v", evidence)
|
||||
}
|
||||
if evidence.ResponseReceived || evidence.HTTPStatus != nil {
|
||||
t.Fatalf("prior redirect response was not cleared: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoWithEvidenceRetainsResponseWhenRedirectPolicyStops(t *testing.T) {
|
||||
redirectErr := errors.New("redirect rejected")
|
||||
transportCalls := 0
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
transportCalls++
|
||||
return responseFor(request, http.StatusFound, "https://other.example/never-requested"), nil
|
||||
}),
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return redirectErr },
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(client, request)
|
||||
if !errors.Is(err, redirectErr) {
|
||||
t.Fatalf("redirect error = %v, want policy error", err)
|
||||
}
|
||||
if response == nil || response.StatusCode != http.StatusFound {
|
||||
t.Fatalf("redirect response = %v, want original response", response)
|
||||
}
|
||||
response.Body.Close()
|
||||
if transportCalls != 1 || evidence.Endpoint != "https://source.example/start" {
|
||||
t.Fatalf("redirect policy inferred or dispatched another request: calls=%d evidence=%+v", transportCalls, evidence)
|
||||
}
|
||||
if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusFound {
|
||||
t.Fatalf("original response evidence was lost: %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithEvidencePreservesClientAndIsolatesConcurrentCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
redirect := func(*http.Request, []*http.Request) error { return nil }
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("construct cookie jar: %v", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport
|
||||
supplied := &http.Client{Transport: transport, Timeout: 7 * time.Second, CheckRedirect: redirect, Jar: jar}
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{HTTPClient: supplied})
|
||||
if err != nil {
|
||||
t.Fatalf("construct client: %v", err)
|
||||
}
|
||||
|
||||
const calls = 8
|
||||
evidences := make([]HTTPExecutionEvidence, calls)
|
||||
errs := make([]error, calls)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < calls; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
endpoint := server.URL + "/call/" + strconv.Itoa(index)
|
||||
response, evidence, getErr := client.GetWithEvidence(context.Background(), endpoint, 0)
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
evidences[index], errs[index] = evidence, getErr
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
|
||||
if supplied.Transport != transport || supplied.Timeout != 7*time.Second || supplied.CheckRedirect == nil || supplied.Jar != jar {
|
||||
t.Fatal("diagnostic GET mutated the supplied HTTP client")
|
||||
}
|
||||
for index, evidence := range evidences {
|
||||
if errs[index] != nil {
|
||||
t.Fatalf("GET %d: %v", index, errs[index])
|
||||
}
|
||||
want := server.URL + "/call/" + strconv.Itoa(index)
|
||||
if !evidence.RequestObserved || evidence.Endpoint != want {
|
||||
t.Fatalf("GET %d evidence = %+v, want endpoint %q", index, evidence, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithEvidenceAppliesTargetTimeout(t *testing.T) {
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: 5 * time.Second, HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
<-request.Context().Done()
|
||||
return nil, request.Context().Err()
|
||||
}),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct client: %v", err)
|
||||
}
|
||||
|
||||
response, evidence, err := client.GetWithEvidence(context.Background(), "https://diagnostic.example/slow", 1)
|
||||
if response != nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("response/error = %v/%v, want nil/deadline exceeded", response, err)
|
||||
}
|
||||
if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived {
|
||||
t.Fatalf("timeout evidence = %+v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func responseFor(request *http.Request, status int, location string) *http.Response {
|
||||
header := make(http.Header)
|
||||
if location != "" {
|
||||
header.Set("Location", location)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: header,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: request,
|
||||
}
|
||||
}
|
||||
@@ -101,24 +101,29 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
||||
}
|
||||
|
||||
// PreparedGeneration holds one encoded request and resolved authentication for
|
||||
// repeated attempts. Each Execute call creates a fresh HTTP request and context.
|
||||
// repeated attempts. Each execution creates a fresh HTTP request and context.
|
||||
type PreparedGeneration struct {
|
||||
endpoint string
|
||||
payload []byte
|
||||
apiKey string
|
||||
timeoutSeconds int
|
||||
httpClient *http.Client
|
||||
endpoint string
|
||||
payload []byte
|
||||
apiKey string
|
||||
timeoutSeconds int
|
||||
httpClient *http.Client
|
||||
structuredOutputRequested bool
|
||||
}
|
||||
|
||||
// AttemptOutcome describes one HTTP execution without exposing response bodies
|
||||
// or headers to the orchestration layer.
|
||||
type AttemptOutcome struct {
|
||||
Response *domain.GenerateResponse
|
||||
Err error
|
||||
StatusCode int
|
||||
Usage domain.TokenUsage
|
||||
UsagePresence *domain.TokenUsagePresence
|
||||
RetryNotBefore time.Time
|
||||
Response *domain.GenerateResponse
|
||||
Err error
|
||||
StatusCode int
|
||||
Usage domain.TokenUsage
|
||||
UsagePresence *domain.TokenUsagePresence
|
||||
RetryNotBefore time.Time
|
||||
Evidence HTTPExecutionEvidence
|
||||
StructuredOutputRequested *bool
|
||||
ResponseDecoded bool
|
||||
GenerationCompleted bool
|
||||
}
|
||||
|
||||
// PrepareGeneration validates and freezes one built-in request before dispatch.
|
||||
@@ -152,13 +157,15 @@ func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) (
|
||||
|
||||
payload, err := json.Marshal(wirePayload)
|
||||
if err != nil {
|
||||
for key, value := range req.Target.ExtraParams {
|
||||
if _, extraErr := json.Marshal(value); extraErr != nil {
|
||||
return nil, fmt.Errorf("%w: extra_params.%s must be JSON-serializable: %v", ErrInvalidRequest, key, extraErr)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||
}
|
||||
apiKey := strings.TrimSpace(req.Target.APIKey)
|
||||
apiKey := resolveAPIKey(req.Target)
|
||||
envName := strings.TrimSpace(req.Target.APIKeyEnv)
|
||||
if apiKey == "" && envName != "" {
|
||||
apiKey = strings.TrimSpace(os.Getenv(envName))
|
||||
}
|
||||
if apiKey == "" && req.Target.APIKeyRequired {
|
||||
if envName != "" {
|
||||
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
|
||||
@@ -172,12 +179,36 @@ func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) (
|
||||
return &PreparedGeneration{
|
||||
endpoint: endpoint, payload: payload, apiKey: apiKey,
|
||||
timeoutSeconds: req.Target.TimeoutSeconds, httpClient: httpClient,
|
||||
structuredOutputRequested: wireReq.ResponseFormat != nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveAPIKey(target domain.ExecutionTarget) string {
|
||||
apiKey := strings.TrimSpace(target.APIKey)
|
||||
if apiKey == "" {
|
||||
if envName := strings.TrimSpace(target.APIKeyEnv); envName != "" {
|
||||
apiKey = strings.TrimSpace(os.Getenv(envName))
|
||||
}
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// InitialEndpoint returns the frozen request URL before redirects.
|
||||
func (p *PreparedGeneration) InitialEndpoint() string { return p.endpoint }
|
||||
|
||||
// Execute dispatches one attempt. The returned response is non-nil only when
|
||||
// the successful envelope contains a usable first choice.
|
||||
func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||
return p.execute(ctx, false)
|
||||
}
|
||||
|
||||
// ExecuteWithEvidence performs one prepared attempt and retains HTTP and
|
||||
// decoded-generation facts even when a later context check returns an error.
|
||||
func (p *PreparedGeneration) ExecuteWithEvidence(ctx context.Context) AttemptOutcome {
|
||||
return p.execute(ctx, true)
|
||||
}
|
||||
|
||||
func (p *PreparedGeneration) execute(ctx context.Context, captureEvidence bool) AttemptOutcome {
|
||||
requestContext := ctx
|
||||
if p.timeoutSeconds > 0 {
|
||||
var cancel context.CancelFunc
|
||||
@@ -193,13 +224,29 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
httpResp, err := p.httpClient.Do(httpReq)
|
||||
var (
|
||||
httpResp *http.Response
|
||||
outcome AttemptOutcome
|
||||
)
|
||||
if captureEvidence {
|
||||
httpResp, err, outcome.Evidence = doWithEvidence(p.httpClient, httpReq)
|
||||
if outcome.Evidence.DispatchInvoked {
|
||||
requested := p.structuredOutputRequested
|
||||
outcome.StructuredOutputRequested = &requested
|
||||
}
|
||||
} else {
|
||||
httpResp, err = p.httpClient.Do(httpReq)
|
||||
}
|
||||
if httpResp != nil && (captureEvidence || err == nil) {
|
||||
outcome.StatusCode = httpResp.StatusCode
|
||||
}
|
||||
if err != nil {
|
||||
return AttemptOutcome{Err: &requestFailedError{cause: err}}
|
||||
outcome.Err = &requestFailedError{cause: err}
|
||||
return outcome
|
||||
}
|
||||
receivedAt := time.Now()
|
||||
defer httpResp.Body.Close()
|
||||
outcome := AttemptOutcome{StatusCode: httpResp.StatusCode}
|
||||
outcome.StatusCode = httpResp.StatusCode
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
outcome.RetryNotBefore = retryNotBefore(httpResp.Header.Values("Retry-After"), receivedAt)
|
||||
@@ -215,6 +262,11 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||
}
|
||||
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
|
||||
outcome.Err = openAIChatResponseTooLargeError()
|
||||
if captureEvidence {
|
||||
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
|
||||
outcome.Err = contextErr
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -226,6 +278,7 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
outcome.ResponseDecoded = true
|
||||
outcome.Usage = domain.TokenUsage{
|
||||
PromptTokens: tokenCount(wireResp.Usage.PromptTokens), CompletionTokens: tokenCount(wireResp.Usage.CompletionTokens),
|
||||
TotalTokens: tokenCount(wireResp.Usage.TotalTokens), CachedTokens: tokenCount(wireResp.Usage.PromptTokensDetails.CachedTokens),
|
||||
@@ -239,14 +292,30 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||
|
||||
if len(wireResp.Choices) == 0 {
|
||||
outcome.Err = fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||
if captureEvidence {
|
||||
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
|
||||
outcome.Err = contextErr
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
content := wireResp.Choices[0].Message.Content
|
||||
if content == nil {
|
||||
outcome.Err = fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
|
||||
if captureEvidence {
|
||||
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
|
||||
outcome.Err = contextErr
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
outcome.Response = &domain.GenerateResponse{Content: *content, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence}
|
||||
outcome.GenerationCompleted = true
|
||||
if captureEvidence {
|
||||
if contextErr := observedContextError(requestContext, ctx, nil); contextErr != nil {
|
||||
outcome.Err = contextErr
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -446,9 +515,6 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
||||
if IsReservedOpenAIChatRequestField(key) {
|
||||
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||
}
|
||||
if _, err := json.Marshal(value); err != nil {
|
||||
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
|
||||
|
||||
@@ -1113,6 +1113,13 @@ func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *te
|
||||
}{
|
||||
{name: "empty key", extraParams: map[string]any{"": "empty"}, want: "key must not be empty"},
|
||||
{name: "unserializable value", extraParams: map[string]any{"bad": math.Inf(1)}, want: "JSON-serializable"},
|
||||
{
|
||||
name: "nested unserializable value",
|
||||
extraParams: map[string]any{
|
||||
"nested": map[string]any{"values": []any{"valid", math.NaN()}},
|
||||
},
|
||||
want: "extra_params.nested",
|
||||
},
|
||||
}
|
||||
for _, key := range []string{
|
||||
"model",
|
||||
@@ -1161,6 +1168,56 @@ func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientClassifiesSchemaEncodingFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extraParams map[string]any
|
||||
want error
|
||||
unwanted error
|
||||
}{
|
||||
{
|
||||
name: "serializable extra keeps full-request failure",
|
||||
extraParams: map[string]any{"client_option": "accepted"},
|
||||
want: ErrRequestFailed,
|
||||
unwanted: ErrInvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid extra keeps invalid-request failure",
|
||||
extraParams: map[string]any{"nested": map[string]any{"bad": math.Inf(1)}},
|
||||
want: ErrInvalidRequest,
|
||||
unwanted: ErrRequestFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
provider := newRecordingProvider(t)
|
||||
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
|
||||
request := ordinaryGenerateRequest()
|
||||
request.Target.ExtraParams = tc.extraParams
|
||||
request.StructuredOutput = &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: "invalid-schema",
|
||||
Strict: true,
|
||||
Schema: json.RawMessage("{"),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Generate(context.Background(), request)
|
||||
if err == nil || !errors.Is(err, tc.want) {
|
||||
t.Fatalf("error = %v, want identity %v", err, tc.want)
|
||||
}
|
||||
if errors.Is(err, tc.unwanted) {
|
||||
t.Fatalf("error = %v, unexpectedly has identity %v", err, tc.unwanted)
|
||||
}
|
||||
if calls := provider.requestCount(); calls != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkConfiguredModelFallback(t *testing.T) {
|
||||
provider := newRecordingProvider(t)
|
||||
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "default-model"})
|
||||
|
||||
119
internal/llm/prepare_generation_benchmark_test.go
Normal file
119
internal/llm/prepare_generation_benchmark_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
var prepareGenerationBenchmarkSink *PreparedGeneration
|
||||
|
||||
func BenchmarkPrepareGeneration(b *testing.B) {
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: "https://api.example.test/v1",
|
||||
Model: "benchmark-model",
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("construct benchmark client: %v", err)
|
||||
}
|
||||
|
||||
prompt := domain.RenderedPrompt{
|
||||
Messages: []domain.RenderedMessage{{Role: "user", Content: "Summarize this request."}},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
request domain.GenerateRequest
|
||||
}{
|
||||
{
|
||||
name: "no_extras",
|
||||
request: domain.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Target: domain.ExecutionTarget{Model: "benchmark-model"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "small_extras",
|
||||
request: domain.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Target: domain.ExecutionTarget{Model: "benchmark-model", ExtraParams: map[string]any{
|
||||
"priority": "low",
|
||||
"attempt": 3,
|
||||
"enabled": true,
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "1MiB_string_extra",
|
||||
request: domain.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Target: domain.ExecutionTarget{Model: "benchmark-model", ExtraParams: map[string]any{
|
||||
"payload": strings.Repeat("x", 1<<20),
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested_json_extras",
|
||||
request: domain.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Target: domain.ExecutionTarget{Model: "benchmark-model", ExtraParams: map[string]any{
|
||||
"options": map[string]any{
|
||||
"enabled": true,
|
||||
"limits": []any{2, 4, 8},
|
||||
"tags": []string{"summary", "offline"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "structured_output",
|
||||
request: domain.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Target: domain.ExecutionTarget{Model: "benchmark-model"},
|
||||
StructuredOutput: &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: "summary",
|
||||
Strict: true,
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"summary": map[string]any{"type": "string"},
|
||||
"count": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []string{"summary", "count"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
prepared, err := client.PrepareGeneration(tc.request)
|
||||
if err != nil {
|
||||
b.Fatalf("check prepared request: %v", err)
|
||||
}
|
||||
if !json.Valid(prepared.payload) {
|
||||
b.Fatal("prepared request is not valid JSON")
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(prepared.payload)))
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
prepared, err := client.PrepareGeneration(tc.request)
|
||||
if err != nil {
|
||||
b.Fatalf("prepare generation: %v", err)
|
||||
}
|
||||
prepareGenerationBenchmarkSink = prepared
|
||||
}
|
||||
b.StopTimer()
|
||||
runtime.KeepAlive(prepareGenerationBenchmarkSink)
|
||||
})
|
||||
}
|
||||
}
|
||||
222
internal/llm/prepared_generation_evidence_test.go
Normal file
222
internal/llm/prepared_generation_evidence_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestPreparedGenerationEvidenceSeparatesResponseAndGenerationFacts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
structured bool
|
||||
wantDecoded bool
|
||||
wantGeneration bool
|
||||
wantResponse bool
|
||||
wantContent string
|
||||
wantFailure error
|
||||
wantUsagePresence bool
|
||||
wantStructuredFact bool
|
||||
}{
|
||||
{
|
||||
name: "valid content",
|
||||
status: http.StatusOK,
|
||||
body: `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0}}`,
|
||||
structured: true,
|
||||
wantDecoded: true,
|
||||
wantGeneration: true,
|
||||
wantResponse: true,
|
||||
wantContent: "ok",
|
||||
wantUsagePresence: true,
|
||||
wantStructuredFact: true,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
status: http.StatusOK,
|
||||
body: `{"choices":[{"message":{"content":""}}]}`,
|
||||
wantDecoded: true,
|
||||
wantGeneration: true,
|
||||
wantResponse: true,
|
||||
wantStructuredFact: false,
|
||||
},
|
||||
{
|
||||
name: "decoded envelope without generated content",
|
||||
status: http.StatusOK,
|
||||
body: `{"choices":[],"usage":{"prompt_tokens":0}}`,
|
||||
wantDecoded: true,
|
||||
wantUsagePresence: true,
|
||||
wantFailure: ErrMalformedResponse,
|
||||
wantStructuredFact: false,
|
||||
},
|
||||
{
|
||||
name: "malformed envelope",
|
||||
status: http.StatusOK,
|
||||
body: `not json`,
|
||||
wantFailure: ErrMalformedResponse,
|
||||
wantStructuredFact: false,
|
||||
},
|
||||
{
|
||||
name: "provider status",
|
||||
status: http.StatusTooManyRequests,
|
||||
body: `{"error":{"message":"try again"}}`,
|
||||
wantFailure: ErrUnexpectedStatus,
|
||||
wantStructuredFact: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: "https://provider.example/v1",
|
||||
Model: "model",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
response := responseFor(request, test.status, "")
|
||||
response.Body = io.NopCloser(strings.NewReader(test.body))
|
||||
response.ContentLength = int64(len(test.body))
|
||||
return response, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := ordinaryGenerateRequest()
|
||||
if test.structured {
|
||||
request.StructuredOutput = &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: "smoke",
|
||||
Schema: map[string]any{"type": "object"},
|
||||
},
|
||||
}
|
||||
}
|
||||
prepared, err := client.PrepareGeneration(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outcome := prepared.ExecuteWithEvidence(context.Background())
|
||||
if (outcome.Err != nil) != (test.wantFailure != nil) || (test.wantFailure != nil && !errors.Is(outcome.Err, test.wantFailure)) {
|
||||
t.Fatalf("error = %v, want failure %v", outcome.Err, test.wantFailure)
|
||||
}
|
||||
if !outcome.Evidence.DispatchInvoked || !outcome.Evidence.RequestObserved || !outcome.Evidence.ResponseReceived {
|
||||
t.Fatalf("response evidence = %+v", outcome.Evidence)
|
||||
}
|
||||
if outcome.Evidence.HTTPStatus == nil || *outcome.Evidence.HTTPStatus != test.status {
|
||||
t.Fatalf("HTTP evidence status = %v, want %d", outcome.Evidence.HTTPStatus, test.status)
|
||||
}
|
||||
if outcome.GenerationCompleted != test.wantGeneration {
|
||||
t.Fatalf("generation completed = %t, want %t", outcome.GenerationCompleted, test.wantGeneration)
|
||||
}
|
||||
if outcome.ResponseDecoded != test.wantDecoded {
|
||||
t.Fatalf("response decoded = %t, want %t", outcome.ResponseDecoded, test.wantDecoded)
|
||||
}
|
||||
if (outcome.Response != nil) != test.wantResponse {
|
||||
t.Fatalf("response presence = %t, want %t", outcome.Response != nil, test.wantResponse)
|
||||
}
|
||||
if test.wantResponse && outcome.Response.Content != test.wantContent {
|
||||
t.Fatalf("generated content = %q, want %q", outcome.Response.Content, test.wantContent)
|
||||
}
|
||||
if (outcome.UsagePresence != nil && outcome.UsagePresence.PromptTokens) != test.wantUsagePresence {
|
||||
t.Fatalf("usage presence = %+v, want prompt token presence %t", outcome.UsagePresence, test.wantUsagePresence)
|
||||
}
|
||||
if outcome.StructuredOutputRequested == nil || *outcome.StructuredOutputRequested != test.wantStructuredFact {
|
||||
t.Fatalf("structured-output evidence = %v, want %t", outcome.StructuredOutputRequested, test.wantStructuredFact)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedGenerationEvidenceRetainsDecodedResponseOnCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
body := `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0}}`
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: "https://provider.example/v1",
|
||||
Model: "model",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: &cancelAfterReadBody{Reader: strings.NewReader(body), cancel: cancel},
|
||||
ContentLength: int64(len(body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := client.PrepareGeneration(ordinaryGenerateRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outcome := prepared.ExecuteWithEvidence(ctx)
|
||||
if !errors.Is(outcome.Err, context.Canceled) || !errors.Is(outcome.Err, ErrTransport) {
|
||||
t.Fatalf("canceled outcome = %+v", outcome)
|
||||
}
|
||||
if !outcome.Evidence.ResponseReceived || outcome.Evidence.HTTPStatus == nil || *outcome.Evidence.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("response evidence was lost after cancellation: %+v", outcome.Evidence)
|
||||
}
|
||||
if !outcome.ResponseDecoded || !outcome.GenerationCompleted || outcome.Response == nil || outcome.Response.Content != "ok" {
|
||||
t.Fatalf("decoded generation was lost after cancellation: %+v", outcome)
|
||||
}
|
||||
if outcome.UsagePresence == nil || !outcome.UsagePresence.PromptTokens || outcome.Usage.PromptTokens != 0 {
|
||||
t.Fatalf("decoded usage was lost after cancellation: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedGenerationEvidenceRecordsStructuredRequestAtDispatch(t *testing.T) {
|
||||
transportErr := errors.New("provider unavailable")
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: "https://provider.example/v1",
|
||||
Model: "model",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, transportErr
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := ordinaryGenerateRequest()
|
||||
request.StructuredOutput = &domain.StructuredOutputSpec{
|
||||
Type: domain.StructuredOutputJSONSchema,
|
||||
JSONSchema: &domain.StructuredOutputJSONSpec{
|
||||
Name: "smoke",
|
||||
Schema: map[string]any{"type": "object"},
|
||||
},
|
||||
}
|
||||
prepared, err := client.PrepareGeneration(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outcome := prepared.ExecuteWithEvidence(context.Background())
|
||||
if !errors.Is(outcome.Err, transportErr) || !outcome.Evidence.DispatchInvoked || !outcome.Evidence.RequestObserved {
|
||||
t.Fatalf("dispatch outcome = %+v", outcome)
|
||||
}
|
||||
if outcome.Evidence.ResponseReceived || outcome.StructuredOutputRequested == nil || !*outcome.StructuredOutputRequested {
|
||||
t.Fatalf("structured request evidence = %+v, request=%v", outcome.Evidence, outcome.StructuredOutputRequested)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelAfterReadBody struct {
|
||||
io.Reader
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (b *cancelAfterReadBody) Read(buffer []byte) (int, error) {
|
||||
n, err := b.Reader.Read(buffer)
|
||||
if n > 0 {
|
||||
b.cancel()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (b *cancelAfterReadBody) Close() error { return nil }
|
||||
@@ -28,13 +28,13 @@ type Recorder struct {
|
||||
capacityWait time.Duration
|
||||
generationDuration time.Duration
|
||||
retryDelay time.Duration
|
||||
usage usageTotals
|
||||
usage UsageAccumulator
|
||||
}
|
||||
|
||||
// Selection contains resolved identities only. Unresolved fields stay empty.
|
||||
type Selection struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptVersion *string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
Model string
|
||||
@@ -79,8 +79,7 @@ func New(observer domain.ExecutionObserver) (*Recorder, error) {
|
||||
}
|
||||
|
||||
func newRecorder(runID string, now func() time.Time, observer domain.ExecutionObserver) *Recorder {
|
||||
return &Recorder{runID: runID, startedAt: now(), now: now, observer: observer,
|
||||
usage: newUsageTotals()}
|
||||
return &Recorder{runID: runID, startedAt: now(), now: now, observer: observer}
|
||||
}
|
||||
|
||||
// NewRunID creates the identity shared by observations and successful results.
|
||||
@@ -104,7 +103,10 @@ func (r *Recorder) Start() {
|
||||
}
|
||||
|
||||
// UpdateSelection replaces the known resolved identities for later events.
|
||||
func (r *Recorder) UpdateSelection(selection Selection) { r.selection = selection }
|
||||
func (r *Recorder) UpdateSelection(selection Selection) {
|
||||
selection.PromptVersion = copyStringPointer(selection.PromptVersion)
|
||||
r.selection = selection
|
||||
}
|
||||
|
||||
// Attempt records one completed acquisition and optional dispatch.
|
||||
func (r *Recorder) Attempt(data AttemptData) {
|
||||
@@ -119,7 +121,7 @@ func (r *Recorder) Attempt(data AttemptData) {
|
||||
r.lastRepairGeneration = data.Generation
|
||||
}
|
||||
r.generationDuration = addDuration(r.generationDuration, data.GenerationDuration)
|
||||
r.usage.add(data.Usage, data.UsagePresence)
|
||||
r.usage.Add(data.Usage, data.UsagePresence)
|
||||
}
|
||||
event := r.event(domain.ExecutionAttempt)
|
||||
event.Generation = data.Generation
|
||||
@@ -171,18 +173,26 @@ func (r *Recorder) Finish(data FinishData) {
|
||||
if event.Duration < 0 {
|
||||
event.Duration = 0
|
||||
}
|
||||
event.Usage = r.usage.snapshot(r.attempts)
|
||||
event.Usage = r.usage.Observed()
|
||||
r.deliver(event)
|
||||
}
|
||||
|
||||
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
|
||||
return domain.ExecutionEvent{
|
||||
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
|
||||
PromptVersion: r.selection.PromptVersion, ProfileID: r.selection.ProfileID,
|
||||
PromptVersion: copyStringPointer(r.selection.PromptVersion), ProfileID: r.selection.ProfileID,
|
||||
BackendID: r.selection.BackendID, Model: r.selection.Model,
|
||||
}
|
||||
}
|
||||
|
||||
func copyStringPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func (r *Recorder) deliver(event domain.ExecutionEvent) {
|
||||
if r.observer != nil {
|
||||
r.observer(event)
|
||||
@@ -243,7 +253,29 @@ type usageTotals struct {
|
||||
prompt, completion, total, cached, cacheWrite usageField
|
||||
}
|
||||
|
||||
func newUsageTotals() usageTotals {
|
||||
// UsageAccumulator combines presence-aware usage across dispatched attempts.
|
||||
// Its zero value represents no dispatched attempts.
|
||||
type UsageAccumulator struct {
|
||||
totals usageTotals
|
||||
attempts uint64
|
||||
}
|
||||
|
||||
// Add includes the usage reported by one dispatched attempt.
|
||||
func (u *UsageAccumulator) Add(usage domain.TokenUsage, presence *domain.TokenUsagePresence) {
|
||||
if u.attempts == 0 {
|
||||
u.totals = completeUsageTotals()
|
||||
}
|
||||
u.attempts++
|
||||
u.totals.add(usage, presence)
|
||||
}
|
||||
|
||||
// Observed returns aggregate counts, marking every field known and complete
|
||||
// when no attempt has been added.
|
||||
func (u UsageAccumulator) Observed() domain.ObservedTokenUsage {
|
||||
return u.totals.snapshot(u.attempts)
|
||||
}
|
||||
|
||||
func completeUsageTotals() usageTotals {
|
||||
return usageTotals{prompt: usageField{complete: true}, completion: usageField{complete: true},
|
||||
total: usageField{complete: true}, cached: usageField{complete: true}, cacheWrite: usageField{complete: true}}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ func TestRecorderAccountsDispatchedAttemptsAndReportedUsage(t *testing.T) {
|
||||
var events []domain.ExecutionEvent
|
||||
recorder := newRecorder("run-1", now, func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||
recorder.Start()
|
||||
recorder.UpdateSelection(Selection{PromptID: "prompt", PromptVersion: "v1", ProfileID: "profile", BackendID: "backend", Model: "model"})
|
||||
version := "v1"
|
||||
recorder.UpdateSelection(Selection{PromptID: "prompt", PromptVersion: &version, ProfileID: "profile", BackendID: "backend", Model: "model"})
|
||||
recorder.Attempt(AttemptData{
|
||||
Generation: 1, Attempt: 1, Dispatched: true,
|
||||
CapacityWait: 2 * time.Millisecond, GenerationDuration: 10 * time.Millisecond,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user