Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ff9aa3efd | |||
| 78b78ea00b | |||
| 63e9f0b731 | |||
| 20b0f78af2 | |||
| 101a2840d4 | |||
| f083ad5309 | |||
| 19a0604874 | |||
| ea31e71280 | |||
| 27966c1686 | |||
| ea63a7b0ff | |||
| b5b9b1f209 | |||
| 129959b24c | |||
| dfe9d6566f | |||
| bf3d2e19bd | |||
| 4cbd4e2da0 | |||
| b0a16ccce8 | |||
| 8add7c49e6 | |||
| 8563379216 | |||
| 951739eda0 | |||
| a0f696181f | |||
| 3951dcf1f3 | |||
| 363920deb2 | |||
| 06315062b3 | |||
| c669f14610 | |||
| 52287ab2d1 | |||
| a43e0f17ee | |||
| ffaaf552c8 | |||
| d7007eb072 | |||
| 4f3212bfb6 | |||
| 4faf43cc29 | |||
| c9663ec63c | |||
| d8c98ac575 | |||
| 5ee602cc4b | |||
| 96c619f7d4 | |||
| b3b5ec3820 | |||
| 5dd1418953 | |||
| f5c900cb0a | |||
| c5f3de13f3 | |||
| 88725e79ab | |||
| 6c015a17fa | |||
| 9463c9463a | |||
| 1fbd175e53 | |||
| 4b4ce43796 | |||
| d78474a3bf | |||
| acaf2ccd8b | |||
| 97e5739bfb | |||
| 90f796aef7 | |||
| 0dec3dcec0 | |||
| 448e64dc40 | |||
| de46935a0e | |||
| c69703b2b6 | |||
| f660b6e0fc | |||
| 5f56c65427 | |||
| cfa3a85308 | |||
| 1ff6d778af | |||
| 3f93763c9c | |||
| 4aeb58f913 | |||
| ac1e150168 | |||
| ff57dbf8b3 | |||
| beb18cc9d6 | |||
| 7203e5d6a1 | |||
| 356563a6e5 | |||
| 6673284312 | |||
| 6f42e1e007 | |||
| cee9bbc7b2 | |||
| 7db5572b62 | |||
| a91bc1608b | |||
| e7a3665443 | |||
| 59e8010623 | |||
| ada5377f32 | |||
| df208e132b | |||
| 8c0fb15727 | |||
| 2e2fba4993 | |||
| 68668cee56 | |||
| f745b14b71 | |||
| dbba1485a7 | |||
| 7c435032b6 | |||
| e834e7d806 | |||
| a4901cbb0e | |||
| 5dcfc2e953 | |||
| a02561d40f | |||
| 6112ee8fb4 | |||
| 0aceff8c08 | |||
| cddb2b389c | |||
| e056cb1607 | |||
| 1e122f0473 | |||
| 2f0f5fa607 | |||
| 7d500ee62f | |||
| 7be677c80b | |||
| 1c075bd510 | |||
| b8dc8a49a9 | |||
| cd373ee46b | |||
| e881bbdcf4 | |||
| 106c808442 | |||
| 4ec35f5ca6 | |||
| 6a1a32ae49 | |||
| e85ea2db35 | |||
| 3ef2a9e18e | |||
| a85a6788b4 | |||
| 21fea46fb3 | |||
| 62bf82a56b | |||
| bd3dfa992c | |||
| 425f53a62e | |||
| fcc94eb9e9 | |||
| 95730a3c11 | |||
| c542b81666 | |||
| e8e112186e | |||
| aa08d46ef6 | |||
| 668d09997a | |||
| 69203dcb12 | |||
| 3b760310a6 | |||
| b39460a507 | |||
| 5177db0611 | |||
| 31f1e6a89d | |||
| f7085d76f6 | |||
| cf0bfd4d76 | |||
| 6b1323d1fe | |||
| fb87fabc2e | |||
| 5adc1c1e64 | |||
| 0d27252999 | |||
| d9f6aad305 | |||
| fda0338134 | |||
| 2b3d165ce2 | |||
| 43f55edb98 | |||
| 4950e1dee2 | |||
| d240364776 | |||
| 6f57f2f30a | |||
| 2c50eca033 | |||
| ac44a4f325 | |||
| 9e05c3ac8b | |||
| 2c83925c88 | |||
| e0cd63923d | |||
| 75204d6134 | |||
| 5ce6e01f27 | |||
| 5cec25a40c | |||
| 855aae6daa | |||
| 43abbcb414 | |||
| 9f33fed823 | |||
| 7a98feb399 | |||
| 626134e5e1 | |||
| e7ed315671 | |||
| d7d9a7dd65 | |||
| 18f6a474e0 | |||
| fb7ebe6a6c | |||
| a0441fb2d1 | |||
| 32547e1e29 | |||
| bcc9ebd2e0 | |||
| c7d4318122 | |||
| 175140ae5a | |||
| 66b98831d3 | |||
| 3e3cc677fb | |||
| 8896d8733b | |||
| 7bbdb55888 | |||
| 79dfd97afa | |||
| 995c309560 | |||
| ea4fec9b1b | |||
| 1de3e1fe38 | |||
| 061f01c081 | |||
| 937b9723d4 | |||
| 367af5feca | |||
| fd73cd0662 | |||
| 81a55bc62c | |||
| 2bc976de10 | |||
| fc568aa0b2 | |||
| b0bfe857fe | |||
| 2ccbee3758 | |||
| 0eee3ec962 | |||
| 130c4cc447 | |||
| f661176b06 | |||
| 3e67743fbf | |||
| 6b8eefd18b | |||
| 682cb84c82 | |||
| dbc345e566 | |||
| afcfae2aea | |||
| eaca50763b | |||
| cb83b8aca6 | |||
| 0b026861a5 | |||
| da8496b45b | |||
| e01b23259a | |||
| 500b0aa1c8 | |||
| fd6e9742a1 | |||
| a6f4d67e15 | |||
| c3ce15a4ab | |||
| 8e8f61f53f | |||
| b7565affe5 | |||
| aba890aa86 | |||
| 0b271267a0 | |||
| a4e97bf102 | |||
| 6ae005ed3a | |||
| 07a55e45f2 | |||
| 2b3875008b | |||
| a51d12d898 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,7 +22,7 @@
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# Local ReleaseKit executable
|
# Local ReleaseKit delivery credentials
|
||||||
.releasekit/
|
.releasekit/
|
||||||
|
|
||||||
# env file
|
# env file
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -4,7 +4,7 @@ Promptkit is a reusable Go library for preparing and executing prompt-defined
|
|||||||
LLM workflows. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
go.maximumdirect.net/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
The root `promptkit` package provides the supported public engine. Consumers
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
@@ -26,6 +26,8 @@ source-relative prompt content, supplies an inline input, and prepares through
|
|||||||
a scoped view. The example also demonstrates collection inventories and
|
a scoped view. The example also demonstrates collection inventories and
|
||||||
namespace-filtered discovery, without contacting a provider or requiring
|
namespace-filtered discovery, without contacting a provider or requiring
|
||||||
credentials.
|
credentials.
|
||||||
|
The example disables the bundled OpenRouter configuration to keep its
|
||||||
|
inventories limited to its own offline fixtures.
|
||||||
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
The [consumer guide](docs/consumers/pkg-promptkit.md) also shows a local backend,
|
||||||
ordered file/embedded sources, programmatic mappings, and how to replace an
|
ordered file/embedded sources, programmatic mappings, and how to replace an
|
||||||
engine when source files change. Applications adapting the prior keyed
|
engine when source files change. Applications adapting the prior keyed
|
||||||
@@ -43,10 +45,30 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## Release Guidance
|
||||||
|
|
||||||
|
The [v0.20.0 release notes](docs/releases/v0.20.0.md) cover resolved resource
|
||||||
|
inspection, generic JSON/YAML reports, typed prompt sources, and backend
|
||||||
|
inheritance. Upgrading consumers should review the changed inspection result
|
||||||
|
types and profile null-fallback behavior.
|
||||||
|
|
||||||
|
The [v0.19.0 release notes](docs/releases/v0.19.0.md) cover audit remediation
|
||||||
|
and compatibility changes to artifact ownership, programmatic sources, and schemas.
|
||||||
|
|
||||||
|
The [v0.18.0 release notes](docs/releases/v0.18.0.md) cover the default-on
|
||||||
|
OpenRouter backend and profiles, recorded configuration normalization, and
|
||||||
|
ConfigKit v1.1. The notes explain inventory changes and the bundle opt-out;
|
||||||
|
the Go 1.27 minimum remains unchanged.
|
||||||
|
|
||||||
|
The [v0.17.0 release notes](docs/releases/v0.17.0.md) cover the vanity module
|
||||||
|
path, ConfigKit v1, identified resource lists, removal of prompt-version
|
||||||
|
metadata, and the Go 1.27 minimum. This is a breaking pre-v1 migration.
|
||||||
|
|
||||||
|
Consumers using the former Go module path should follow the
|
||||||
|
[module-path migration guidance](docs/consumers/configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
The [v0.16.0 release notes](docs/releases/v0.16.0.md) cover explicit smoke
|
||||||
diagnostics, configuration collections, scoped engine views, and discovery.
|
diagnostics, configuration collections, scoped engine views, and discovery.
|
||||||
Upgrading consumers should review the breaking changes to bundled configuration
|
Upgrading consumers should also review the current identified-resource and
|
||||||
and prompt version selection and follow the linked migration guide.
|
ConfigKit v1 changes in the [migration guide](docs/consumers/configuration-migration.md).
|
||||||
|
|
||||||
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
The [v0.15.0 release notes](docs/releases/v0.15.0.md) cover error-only
|
||||||
validation of concrete requests with `Engine.ValidateRequest`.
|
validation of concrete requests with `Engine.ValidateRequest`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
||||||
|
|||||||
43
artifact_ownership_contract_test.go
Normal file
43
artifact_ownership_contract_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type independentArtifactReader struct{}
|
||||||
|
|
||||||
|
func (independentArtifactReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||||
|
return &promptkit.Artifact{Body: []byte(ref.Body), Hash: ref.Body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPreparationWithTransferredArtifacts(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("ownership", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}},
|
||||||
|
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "inputs": []any{map[string]any{"name": "document", "required": true}}, "messages": []any{map[string]any{"role": "user", "content": `{{input "document"}}`}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithArtifactReader(independentArtifactReader{}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for i := range 24 {
|
||||||
|
group.Go(func() {
|
||||||
|
value := fmt.Sprintf("body-%d", i)
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"document": promptkit.Inline(value)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("prepare: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(prepared.Messages) != 1 || prepared.Messages[0].Content != value || prepared.InputHashes["document"] != value {
|
||||||
|
t.Errorf("prepared = %+v", prepared)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
artifactadapter "go.maximumdirect.net/promptkit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -71,7 +71,7 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
message := "Hello {{.name}}"
|
message := "Hello {{.name}}"
|
||||||
response := "ok"
|
response := "ok"
|
||||||
output := map[string]any{"format": "text", "validation_mode": "none"}
|
output := map[string]any{"format": "text", "validation_mode": "none"}
|
||||||
profile := map[string]any{"endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
profile := map[string]any{"id": "profile", "endpoint": "http://example.test/v1", "model": "benchmark-model"}
|
||||||
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
options := []promptkit.Option{promptkit.WithLLMClient(assembledWorkflowClient{content: response})}
|
||||||
var expectedMessage string
|
var expectedMessage string
|
||||||
|
|
||||||
@@ -99,9 +99,8 @@ func newAssembledWorkflowFixture(b *testing.B, name string) assembledWorkflowFix
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := map[string]any{
|
config := map[string]any{
|
||||||
"profiles": map[string]any{"profile": profile},
|
"profiles": []any{profile},
|
||||||
"prompts": map[string]any{"workflow": map[string]any{
|
"prompts": []any{map[string]any{"id": "workflow",
|
||||||
"version": "v1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": message}},
|
"messages": []any{map[string]any{"role": "user", "content": message}},
|
||||||
"output": output,
|
"output": output,
|
||||||
@@ -128,7 +127,7 @@ func verifyAssembledWorkflowFixture(b *testing.B, fixture assembledWorkflowFixtu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if prepared.PromptID != "workflow" || prepared.PromptVersion == nil || *prepared.PromptVersion != "v1" || len(prepared.Messages) != 1 ||
|
if prepared.PromptID != "workflow" || len(prepared.Messages) != 1 ||
|
||||||
prepared.Messages[0].Content != fixture.expectedMessage {
|
prepared.Messages[0].Content != fixture.expectedMessage {
|
||||||
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
b.Fatalf("prepared fixture output is invalid: %+v", prepared)
|
||||||
}
|
}
|
||||||
|
|||||||
46
backends.go
46
backends.go
@@ -1,8 +1,8 @@
|
|||||||
package promptkit
|
package promptkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
// BackendOpenRouter is the conventional ID for an OpenRouter backend.
|
||||||
@@ -14,13 +14,18 @@ const BackendRakestrawHome = backend.RakestrawHomeID
|
|||||||
// BackendLocal is a conventional ID for a caller-configured local backend.
|
// BackendLocal is a conventional ID for a caller-configured local backend.
|
||||||
const BackendLocal = "local"
|
const BackendLocal = "local"
|
||||||
|
|
||||||
// BackendConfig is a presence-aware convenience value for a keyed backend
|
// BackendConfig is a presence-aware convenience value for an identified backend
|
||||||
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
// definition. Nil pointer fields are omitted, while non-nil pointers preserve
|
||||||
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
// explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil
|
||||||
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a
|
||||||
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping
|
||||||
// inputs for explicit null. BackendConfig has no stable JSON representation.
|
// inputs for explicit null. Extends names a sibling backend in the same list.
|
||||||
|
// BackendConfig has no stable JSON representation.
|
||||||
type BackendConfig struct {
|
type BackendConfig struct {
|
||||||
|
// ID is the local backend identity within its resource list.
|
||||||
|
ID string
|
||||||
|
// Extends names a sibling backend to inherit through ConfigKit.
|
||||||
|
Extends *string
|
||||||
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
// Endpoint is the required absolute HTTP(S) base URL after all layers merge.
|
||||||
Endpoint *string
|
Endpoint *string
|
||||||
// APIKeyEnv optionally names an environment variable, not a credential.
|
// APIKeyEnv optionally names an environment variable, not a credential.
|
||||||
@@ -42,13 +47,13 @@ type BackendSmokeTestConfig struct {
|
|||||||
// DefaultProfile is the exact profile ID selected as the backend's
|
// DefaultProfile is the exact profile ID selected as the backend's
|
||||||
// diagnostic inference default. A supplied value must not be blank; the ID
|
// diagnostic inference default. A supplied value must not be blank; the ID
|
||||||
// is resolved only when that default is selected.
|
// is resolved only when that default is selected.
|
||||||
DefaultProfile *string
|
DefaultProfile *string `json:"default_profile,omitempty" yaml:"default_profile,omitempty"`
|
||||||
// Reachability optionally selects a full HTTP destination for reachability
|
// Reachability optionally selects a full HTTP destination for reachability
|
||||||
// diagnostics. A nil endpoint uses the effective backend endpoint.
|
// diagnostics. A nil endpoint uses the effective backend endpoint.
|
||||||
Reachability *ReachabilityProbeConfig
|
Reachability *ReachabilityProbeConfig `json:"reachability,omitempty" yaml:"reachability,omitempty"`
|
||||||
// Authentication optionally declares a named authentication diagnostic.
|
// Authentication optionally declares a named authentication diagnostic.
|
||||||
// A supplied object requires Kind.
|
// A supplied object requires Kind.
|
||||||
Authentication *AuthenticationProbeConfig
|
Authentication *AuthenticationProbeConfig `json:"authentication,omitempty" yaml:"authentication,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReachabilityProbeConfig configures an optional reachability destination.
|
// ReachabilityProbeConfig configures an optional reachability destination.
|
||||||
@@ -56,7 +61,7 @@ type ReachabilityProbeConfig struct {
|
|||||||
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
|
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
|
||||||
// Nil selects the effective backend endpoint; an explicit blank value is
|
// Nil selects the effective backend endpoint; an explicit blank value is
|
||||||
// invalid.
|
// invalid.
|
||||||
Endpoint *string
|
Endpoint *string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthenticationProbeConfig configures an optional named authentication
|
// AuthenticationProbeConfig configures an optional named authentication
|
||||||
@@ -64,22 +69,27 @@ type ReachabilityProbeConfig struct {
|
|||||||
type AuthenticationProbeConfig struct {
|
type AuthenticationProbeConfig struct {
|
||||||
// Kind selects the authentication adapter. Supported values are "none" and
|
// Kind selects the authentication adapter. Supported values are "none" and
|
||||||
// "openrouter_key"; surrounding whitespace and case are normalized.
|
// "openrouter_key"; surrounding whitespace and case are normalized.
|
||||||
Kind *string
|
Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"`
|
||||||
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
|
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
|
||||||
// Nil selects the adapter's backend-relative destination; an explicit blank
|
// Nil selects the adapter's backend-relative destination; an explicit blank
|
||||||
// value is invalid.
|
// value is invalid.
|
||||||
Endpoint *string
|
Endpoint *string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendSource creates a labeled ConfigKit mapping from keyed backend values.
|
// BackendSource creates a labeled ConfigKit mapping containing backend records
|
||||||
|
// in caller order. IDs are explicit and duplicates are left for loading to reject.
|
||||||
// It encodes fields without validating them; NewEngine validates the final
|
// It encodes fields without validating them; NewEngine validates the final
|
||||||
// effective configuration after all selected source layers are combined.
|
// effective configuration after all selected source layers are combined.
|
||||||
// Mapping captures a copy of supported input values at creation; unsupported
|
// Mapping captures a copy of supported input values at creation; unsupported
|
||||||
// values fail when the source is loaded.
|
// values fail when the source is loaded. ExtraParams must use ConfigKit Mapping
|
||||||
func BackendSource(label string, backends map[string]BackendConfig) configkit.Source {
|
// input forms; callers must explicitly convert numbers or containers as needed.
|
||||||
definitions := make(map[string]any, len(backends))
|
func BackendSource(label string, backends []BackendConfig) configkit.Source {
|
||||||
for id, config := range backends {
|
definitions := make([]any, 0, len(backends))
|
||||||
fields := make(map[string]any)
|
for _, config := range backends {
|
||||||
|
fields := map[string]any{"id": config.ID}
|
||||||
|
if config.Extends != nil {
|
||||||
|
fields["extends"] = map[string]any{"id": *config.Extends}
|
||||||
|
}
|
||||||
if config.Endpoint != nil {
|
if config.Endpoint != nil {
|
||||||
fields["endpoint"] = *config.Endpoint
|
fields["endpoint"] = *config.Endpoint
|
||||||
}
|
}
|
||||||
@@ -93,7 +103,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
|||||||
fields["queue_capacity"] = *config.QueueCapacity
|
fields["queue_capacity"] = *config.QueueCapacity
|
||||||
}
|
}
|
||||||
if config.ExtraParams != nil {
|
if config.ExtraParams != nil {
|
||||||
fields["extra_params"] = providerMapForMapping(config.ExtraParams)
|
fields["extra_params"] = config.ExtraParams
|
||||||
}
|
}
|
||||||
if config.SmokeTests != nil {
|
if config.SmokeTests != nil {
|
||||||
smokeTests := make(map[string]any)
|
smokeTests := make(map[string]any)
|
||||||
@@ -119,7 +129,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So
|
|||||||
}
|
}
|
||||||
fields["smoke_tests"] = smokeTests
|
fields["smoke_tests"] = smokeTests
|
||||||
}
|
}
|
||||||
definitions[id] = fields
|
definitions = append(definitions, fields)
|
||||||
}
|
}
|
||||||
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
return configkit.Mapping(label, map[string]any{"backends": definitions})
|
||||||
}
|
}
|
||||||
|
|||||||
114
bundled_composition_contract_test.go
Normal file
114
bundled_composition_contract_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
|
||||||
|
underlay := configkit.Input{Source: configkit.Mapping("caller underlay", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "gpt-sol-latest", "model": "underlay-model"}},
|
||||||
|
})}
|
||||||
|
underlayEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
underlayProfile, err := underlayEngine.InspectProfile(context.Background(), "gpt-sol-latest")
|
||||||
|
if err != nil || underlayProfile.Model == nil || *underlayProfile.Model != "underlay-model" || underlayProfile.Backend != nil {
|
||||||
|
t.Fatalf("underlay replacement = %+v, %v", underlayProfile, err)
|
||||||
|
}
|
||||||
|
main := configkit.Input{Source: configkit.Mapping("caller main", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}, map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "gpt-sol-latest", "model": "main-model"},
|
||||||
|
map[string]any{"id": "custom", "extends": map[string]any{"id": "gpt-luna-latest"}, "model": "custom-model"},
|
||||||
|
map[string]any{"id": "auto", "backend": "other", "model": "other-model"},
|
||||||
|
},
|
||||||
|
"collections": map[string]any{"openrouter": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://collection.invalid/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "profile", "backend": "openrouter/local", "model": "collection-model"}},
|
||||||
|
}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Main: &main}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inspect := func(id string) *promptkit.ProfileInspection {
|
||||||
|
t.Helper()
|
||||||
|
profile, err := engine.InspectProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
if target := inspect("gpt-sol-latest"); target.Model == nil || *target.Model != "main-model" || target.Backend != nil {
|
||||||
|
t.Fatalf("replacement = %+v", target)
|
||||||
|
}
|
||||||
|
if target := inspect("custom"); target.Model == nil || *target.Model != "custom-model" || target.Backend == nil || *target.Backend != "openrouter" || target.Endpoint != nil {
|
||||||
|
t.Fatalf("inherited = %+v", target)
|
||||||
|
}
|
||||||
|
if backend, err := engine.InspectBackend(context.Background(), "openrouter"); err != nil || backend.Endpoint != "https://local.invalid/v1" || backend.APIKeyEnv != nil {
|
||||||
|
t.Fatalf("backend replacement = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
if target := inspect("openrouter/profile"); target.Backend == nil || *target.Backend != "openrouter/local" || target.Model == nil || *target.Model != "collection-model" {
|
||||||
|
t.Fatalf("collection = %+v", target)
|
||||||
|
}
|
||||||
|
target := inspect("auto-low")
|
||||||
|
wantPlugins := map[string]any{"plugins": []any{map[string]any{"id": "auto-router", "cost_tier": "low"}}}
|
||||||
|
if target.Backend == nil || *target.Backend != "other" || target.Model == nil || *target.Model != "other-model" || target.ExtraParams == nil || !reflect.DeepEqual(*target.ExtraParams, wantPlugins) {
|
||||||
|
t.Errorf("inherited tier = %+v", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledCompositionSequenceModes(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, mode, id string
|
||||||
|
wantErr bool
|
||||||
|
hasAuto bool
|
||||||
|
}{
|
||||||
|
{"duplicate append", "append", "auto", true, false},
|
||||||
|
{"distinct append", "append", "custom", false, true},
|
||||||
|
{"replace", "replace", "custom", false, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("sequence", map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": test.mode}}},
|
||||||
|
"profiles": []any{map[string]any{"id": test.id, "model": "custom-model"}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate rejection")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles, err := engine.ListProfiles(context.Background())
|
||||||
|
if err != nil || !slices.Contains(profiles, "custom") || slices.Contains(profiles, "auto") != test.hasAuto {
|
||||||
|
t.Fatalf("profiles = %v, %v", profiles, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledCompositionRejectsStrictConflictsAndMissingBackends(t *testing.T) {
|
||||||
|
profile := configkit.Input{Source: configkit.Mapping("profile", map[string]any{"profiles": []any{map[string]any{"id": "auto", "model": "caller"}}})}
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile, profile}}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("strict group accepted duplicate records")
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("replace backends", map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"backends"}, "mode": "replace"}}},
|
||||||
|
"backends": []any{map[string]any{"id": "other", "endpoint": "https://other.invalid/v1"}},
|
||||||
|
})}
|
||||||
|
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}); err == nil {
|
||||||
|
t.Fatal("missing bundled backend was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
89
bundled_execution_contract_test.go
Normal file
89
bundled_execution_contract_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bundledPromptConfig(client *http.Client) promptkit.Config {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "prompt", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
}}})}
|
||||||
|
return promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledProfilePreparationAndExecution(t *testing.T) {
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
||||||
|
engine, err := promptkit.NewEngine(bundledPromptConfig(nil), promptkit.WithLLMClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrProfileRequired) {
|
||||||
|
t.Fatalf("missing profile = %v", err)
|
||||||
|
}
|
||||||
|
temperature := 0.3
|
||||||
|
request := promptkit.RunRequest{PromptID: "prompt", ProfileID: "gpt-luna-latest", Execution: &promptkit.ExecutionTargetOverride{Temperature: &temperature}}
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
details := prepared.Details()
|
||||||
|
if details.SelectedProfileID != "gpt-luna-latest" || details.EffectiveModelParams.Model != "~openai/gpt-luna-latest" || details.EffectiveModelParams.BackendID != "openrouter" || details.EffectiveModelParams.Temperature != temperature {
|
||||||
|
t.Fatalf("prepared target = %+v", details.EffectiveModelParams)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 1 || client.requests[0].Target.Model != "~openai/gpt-luna-latest" || client.requests[0].Target.BackendID != "openrouter" {
|
||||||
|
t.Fatalf("requests = %+v", client.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledProfileHTTPPayloads(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
|
||||||
|
for _, test := range []struct{ id, model, tier string }{
|
||||||
|
{"auto-low", "openrouter/auto", "low"}, {"gpt-luna-latest", "~openai/gpt-luna-latest", ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.id, func(t *testing.T) {
|
||||||
|
var body map[string]any
|
||||||
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
if request.Header.Get("Authorization") != "Bearer synthetic-key" {
|
||||||
|
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(bundledPromptConfig(&http.Client{Transport: transport}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", ProfileID: test.id}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["model"] != test.model || body["extra_params"] != nil {
|
||||||
|
t.Fatalf("request = %+v", body)
|
||||||
|
}
|
||||||
|
if test.tier == "" {
|
||||||
|
if _, exists := body["plugins"]; exists {
|
||||||
|
t.Fatalf("unexpected plugins = %+v", body)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := []any{map[string]any{"id": "auto-router", "cost_tier": test.tier}}
|
||||||
|
if !reflect.DeepEqual(body["plugins"], want) {
|
||||||
|
t.Fatalf("plugins = %#v, want %#v", body["plugins"], want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
117
bundled_openrouter_contract_test.go
Normal file
117
bundled_openrouter_contract_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rejectBundleTransport struct{}
|
||||||
|
|
||||||
|
func (rejectBundleTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("unexpected HTTP request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledOpenRouterOptionsAndInventory(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
options []promptkit.Option
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{"default", nil, true}, {"enabled", []promptkit.Option{promptkit.WithBundledOpenRouter(true)}, true},
|
||||||
|
{"disabled", []promptkit.Option{promptkit.WithBundledOpenRouter(false)}, false},
|
||||||
|
{"last disables", []promptkit.Option{promptkit.WithBundledOpenRouter(true), promptkit.WithBundledOpenRouter(false)}, false},
|
||||||
|
{"last enables", []promptkit.Option{promptkit.WithBundledOpenRouter(false), promptkit.WithBundledOpenRouter(true)}, true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: rejectBundleTransport{}}}, test.options...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles, err := engine.ListProfiles(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backends, err := engine.ListBackends(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prompts, err := engine.ListPrompts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
collections, err := engine.ListCollections(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if (len(profiles) > 0) != test.enabled || len(backends) != boolCount(test.enabled) || len(prompts) != 0 || len(collections) != 0 {
|
||||||
|
t.Fatalf("inventories: %v %v %v %v", profiles, backends, prompts, collections)
|
||||||
|
}
|
||||||
|
if test.enabled {
|
||||||
|
if !reflect.DeepEqual(backends, []string{"openrouter"}) {
|
||||||
|
t.Fatalf("bundle inventory: %v %v", profiles, backends)
|
||||||
|
}
|
||||||
|
profile, err := engine.InspectProfile(context.Background(), "auto")
|
||||||
|
if err != nil || profile.Backend == nil || *profile.Backend != "openrouter" {
|
||||||
|
t.Fatalf("bundle profile = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
view, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmtSources(view.Sources()), "Promptkit bundled OpenRouter") {
|
||||||
|
t.Fatal("bundle source missing")
|
||||||
|
}
|
||||||
|
if explanation, found := view.Explain(configkit.Keys("backends").Index(0).Key("endpoint")); !found || !strings.Contains(explanation.Effective.YAML, "https://openrouter.ai/api/v1") || len(explanation.History) == 0 {
|
||||||
|
t.Fatalf("bundle explanation = %+v", explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolCount(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
func fmtSources(sources []configkit.SourceInfo) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, source := range sources {
|
||||||
|
b.WriteString(source.Label)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundlePreservesCallerUnderlayBackingArray(t *testing.T) {
|
||||||
|
source := configkit.Mapping("caller", map[string]any{"profiles": []any{map[string]any{"id": "caller", "model": "model"}}})
|
||||||
|
groups := make([]configkit.Underlay, 1, 2)
|
||||||
|
groups[0] = configkit.Underlay{Inputs: []configkit.Input{{Source: source}}}
|
||||||
|
before := append([]configkit.Underlay(nil), groups...)
|
||||||
|
if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: groups}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(groups, before) || !reflect.DeepEqual(groups[:2][1], configkit.Underlay{}) {
|
||||||
|
t.Fatal("caller underlays were modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledBundleKeepsCallerOpenRouter(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("caller", map[string]any{"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}}})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backends, err := engine.ListBackends(context.Background())
|
||||||
|
if err != nil || !reflect.DeepEqual(backends, []string{"openrouter"}) {
|
||||||
|
t.Fatalf("backends = %v, %v", backends, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
bundled_smoke_contract_test.go
Normal file
181
bundled_smoke_contract_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cancelingAuthenticationBody struct {
|
||||||
|
remaining int
|
||||||
|
cancel context.CancelFunc
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *cancelingAuthenticationBody) Read(p []byte) (int, error) {
|
||||||
|
n := min(len(p), b.remaining)
|
||||||
|
for i := range p[:n] {
|
||||||
|
p[i] = 'x'
|
||||||
|
}
|
||||||
|
b.remaining -= n
|
||||||
|
if b.remaining == 0 {
|
||||||
|
b.cancel()
|
||||||
|
return n, context.Canceled
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
func (b *cancelingAuthenticationBody) Close() error { b.closed = true; return nil }
|
||||||
|
|
||||||
|
func TestAuthenticationReadCancellationWinsOverOverflow(t *testing.T) {
|
||||||
|
for _, size := range []int{1024, (1 << 20) + 1} {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
body := &cancelingAuthenticationBody{remaining: size, cancel: cancel}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: body}, nil
|
||||||
|
})}})
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := engine.SmokeTest(ctx, promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter"})
|
||||||
|
cancel()
|
||||||
|
if !errors.Is(err, context.Canceled) || report == nil || report.Completed || report.Failure != promptkit.ExecutionFailureCanceled || report.Facts.CredentialsAccepted != nil || report.Facts.ResponseReceived == nil || !*report.Facts.ResponseReceived || !body.closed {
|
||||||
|
t.Fatalf("size %d: report=%+v error=%v closed=%v", size, report, err, body.closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeHTTPDefaults(t *testing.T) {
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "synthetic-key")
|
||||||
|
var urls []string
|
||||||
|
var models []string
|
||||||
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||||
|
urls = append(urls, request.URL.String())
|
||||||
|
body := `{"data":{"label":"member","is_free_tier":false,"usage":0}}`
|
||||||
|
if request.Method == http.MethodPost {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
models = append(models, payload["model"].(string))
|
||||||
|
content := "PROMPTKIT_OK"
|
||||||
|
if _, structured := payload["response_format"]; structured {
|
||||||
|
content = `{"status":"ok"}`
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": content}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = string(encoded)
|
||||||
|
} else if strings.HasSuffix(request.URL.Path, "/key") && request.Header.Get("Authorization") != "Bearer synthetic-key" {
|
||||||
|
t.Errorf("authentication header missing")
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := engine.ListProfiles(context.Background())
|
||||||
|
inspectionBefore, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []promptkit.SmokeTestKind{promptkit.SmokeTestReachability, promptkit.SmokeTestAuthentication, promptkit.SmokeTestTextInference, promptkit.SmokeTestStructuredOutput} {
|
||||||
|
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: kind, BackendID: "openrouter"})
|
||||||
|
if err != nil || report == nil || !report.Completed || report.Failure != "" {
|
||||||
|
t.Fatalf("%s report = %+v, %v", kind, report, err)
|
||||||
|
}
|
||||||
|
if kind == promptkit.SmokeTestAuthentication && (report.Facts.CredentialsAccepted == nil || !*report.Facts.CredentialsAccepted) {
|
||||||
|
t.Fatalf("authentication facts = %+v", report.Facts)
|
||||||
|
}
|
||||||
|
if kind == promptkit.SmokeTestTextInference || kind == promptkit.SmokeTestStructuredOutput {
|
||||||
|
if report.Target.ProfileID != "gpt-luna-latest" || report.Target.EffectiveSettings.Model != "~openai/gpt-luna-latest" {
|
||||||
|
t.Fatalf("target = %+v", report.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantURLs := []string{"https://openrouter.ai/api/v1", "https://openrouter.ai/api/v1/key", "https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions"}
|
||||||
|
if !reflect.DeepEqual(urls, wantURLs) || !reflect.DeepEqual(models, []string{"~openai/gpt-luna-latest", "~openai/gpt-luna-latest"}) {
|
||||||
|
t.Fatalf("urls %v, models %v", urls, models)
|
||||||
|
}
|
||||||
|
after, _ := engine.ListProfiles(context.Background())
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
t.Fatalf("inventory changed: %v %v", before, after)
|
||||||
|
}
|
||||||
|
inspectionAfter, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diff, err := inspectionBefore.Diff(inspectionAfter)
|
||||||
|
if err != nil || len(diff) != 0 {
|
||||||
|
t.Fatalf("inspection changed: %+v, %v", diff, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) {
|
||||||
|
replacement := configkit.Input{Source: configkit.Mapping("replacement", map[string]any{"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}}})}
|
||||||
|
replacementClient := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
|
||||||
|
replacementEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &replacement}}, promptkit.WithLLMClient(replacementClient))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacementReport, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
|
||||||
|
if err != nil || replacementReport.Target.ProfileID != "gpt-luna-latest" || replacementReport.Target.EffectiveSettings.Model != "replacement" {
|
||||||
|
t.Fatalf("replaced default = %+v, %v", replacementReport, err)
|
||||||
|
}
|
||||||
|
replacementClient.response.Content = "unexpected"
|
||||||
|
mismatch, err := replacementEngine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, BackendID: "openrouter"})
|
||||||
|
if err != nil || mismatch == nil || !mismatch.Completed {
|
||||||
|
t.Fatalf("concluded mismatch = %+v, %v", mismatch, err)
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://example.invalid/v1", "smoke_tests": map[string]any{"default_profile": "custom"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "gpt-luna-latest", "backend": "openrouter", "model": "replacement"}, map[string]any{"id": "custom", "backend": "openrouter", "model": "custom-model"}},
|
||||||
|
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "free", "messages": []any{map[string]any{"role": "user", "content": "private application marker"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
|
||||||
|
})}
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "PROMPTKIT_OK"}}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithLLMClient(client))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
request promptkit.SmokeTestRequest
|
||||||
|
profile, model string
|
||||||
|
}{
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter"}, "custom", "custom-model"},
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt"}, "free", "openrouter/free"},
|
||||||
|
{promptkit.SmokeTestRequest{BackendID: "openrouter", PromptID: "prompt", ProfileID: "gpt-luna-latest"}, "gpt-luna-latest", "replacement"},
|
||||||
|
} {
|
||||||
|
test.request.Kind = promptkit.SmokeTestTextInference
|
||||||
|
report, err := engine.SmokeTest(context.Background(), test.request)
|
||||||
|
if err != nil || report.Target.ProfileID != test.profile || report.Target.EffectiveSettings.Model != test.model {
|
||||||
|
t.Fatalf("report = %+v, %v", report, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(client.requests[len(client.requests)-1].Prompt.Messages[0].Content, "private application marker") {
|
||||||
|
t.Fatal("application content used by diagnostic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBundledSmokeAuthenticationResponseOverflow(t *testing.T) {
|
||||||
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1)))}, nil
|
||||||
|
})
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter", APIKey: "private-key-marker"})
|
||||||
|
if err != nil || report == nil || !report.Completed || report.Reason != promptkit.SmokeTestReasonAuthenticationResponseLimitExceeded || report.Failure != promptkit.ExecutionFailureOther || report.Facts.CredentialsAccepted != nil {
|
||||||
|
t.Fatalf("overflow = %+v, %v", report, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
||||||
@@ -247,11 +247,12 @@ func newBackendCapacityEngine(
|
|||||||
promptID, content = "input-prompt", `{{input "input"}}`
|
promptID, content = "input-prompt", `{{input "input"}}`
|
||||||
inputs = []any{map[string]any{"name": "input", "required": true}}
|
inputs = []any{map[string]any{"name": "input", "required": true}}
|
||||||
}
|
}
|
||||||
backend := map[string]any{"endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
backend := map[string]any{"id": "limited", "endpoint": "http://backend.example/v1", "concurrency_limit": limit}
|
||||||
if queueCapacity != nil {
|
if queueCapacity != nil {
|
||||||
backend["queue_capacity"] = *queueCapacity
|
backend["queue_capacity"] = *queueCapacity
|
||||||
}
|
}
|
||||||
definition := map[string]any{
|
definition := map[string]any{
|
||||||
|
"id": promptID,
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
@@ -265,9 +266,9 @@ func newBackendCapacityEngine(
|
|||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("capacity fixture", map[string]any{
|
Source: configkit.Mapping("capacity fixture", map[string]any{
|
||||||
"backends": map[string]any{"limited": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"profile": map[string]any{"backend": "limited", "model": "model"}},
|
"profiles": []any{map[string]any{"id": "profile", "backend": "limited", "model": "model"}},
|
||||||
"prompts": map[string]any{promptID: promptDefinitionWithVersion("1", definition)},
|
"prompts": []any{definition},
|
||||||
}),
|
}),
|
||||||
}}}, options...)
|
}}}, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestConfigurationInspectionDiffsCapturedValues(t *testing.T) {
|
|||||||
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
t.Fatalf("difference count = %d, want 1: %+v", len(differences), differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
if got, want := difference.Path.String(), "/profiles/offline/model"; got != want {
|
if got, want := difference.Path.String(), "/profiles"; got != want {
|
||||||
t.Fatalf("difference path = %q, want %q", got, want)
|
t.Fatalf("difference path = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
if !strings.Contains(difference.Left.YAML, "earlier-model") || !strings.Contains(difference.Right.YAML, "later-model") {
|
||||||
@@ -81,6 +81,34 @@ func TestConfigurationInspectionDiffIgnoresSourceHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationInspectionDiffUsesNormalizedValues(t *testing.T) {
|
||||||
|
inspect := func(endpoint string) promptkit.ConfigurationInspection {
|
||||||
|
t.Helper()
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("backend", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": endpoint}},
|
||||||
|
})}}}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := engine.InspectConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
left := inspect(" HTTPS://example.invalid/v1 ")
|
||||||
|
equal := inspect("https://example.invalid/v1")
|
||||||
|
differences, err := left.Diff(equal)
|
||||||
|
if err != nil || len(differences) != 0 {
|
||||||
|
t.Fatalf("normalized diff = %+v, %v", differences, err)
|
||||||
|
}
|
||||||
|
changed := inspect("https://other.invalid/v1")
|
||||||
|
differences, err = left.Diff(changed)
|
||||||
|
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
|
t.Fatalf("changed diff = %+v, %v", differences, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
||||||
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
left := comparisonInspectionFrom(t, "before", "same-model", map[string]any{
|
||||||
"sequence": []any{"earlier", "unchanged"},
|
"sequence": []any{"earlier", "unchanged"},
|
||||||
@@ -94,18 +122,14 @@ func TestConfigurationInspectionDiffReportsPresenceAndSequences(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare presence and sequence values: %v", err)
|
t.Fatalf("compare presence and sequence values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("atomic backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
difference := differences[0]
|
||||||
nullable, ok := byPath["/backends/local/extra_params/nullable"]
|
if !difference.Left.Present || !difference.Right.Present ||
|
||||||
if !ok || nullable.Left.Present || !nullable.Right.Present || strings.TrimSpace(nullable.Right.YAML) != "null" {
|
strings.Contains(difference.Left.YAML, "nullable:") || !strings.Contains(difference.Right.YAML, "nullable: null") ||
|
||||||
t.Fatalf("absent-to-null difference = (%+v, %t)", nullable, ok)
|
!strings.Contains(difference.Left.YAML, "earlier") || !strings.Contains(difference.Right.YAML, "later") {
|
||||||
}
|
t.Fatalf("atomic sequence snapshots = %+v", difference)
|
||||||
sequence, ok := byPath["/backends/local/extra_params/sequence"]
|
|
||||||
if !ok || !sequence.Left.Present || !sequence.Right.Present ||
|
|
||||||
!strings.Contains(sequence.Left.YAML, "earlier") || !strings.Contains(sequence.Right.YAML, "later") {
|
|
||||||
t.Fatalf("atomic sequence difference = (%+v, %t)", sequence, ok)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,22 +147,19 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive values: %v", err)
|
t.Fatalf("compare sensitive values: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 2 {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("difference count = %d, want 2: %+v", len(differences), differences)
|
t.Fatalf("sensitive backend list difference = %+v", differences)
|
||||||
}
|
}
|
||||||
byPath := differencesByPath(differences)
|
secret := differences[0]
|
||||||
secret, ok := byPath["/backends/local/extra_params/token"]
|
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
||||||
if !ok || !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged {
|
t.Fatalf("sensitive difference = %+v", secret)
|
||||||
t.Fatalf("sensitive difference = (%+v, %t)", secret, ok)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
if strings.Contains(secret.Left.YAML, "left-secret-sentinel") || strings.Contains(secret.Left.YAML, "right-secret-sentinel") ||
|
||||||
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
strings.Contains(secret.Right.YAML, "left-secret-sentinel") || strings.Contains(secret.Right.YAML, "right-secret-sentinel") {
|
||||||
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
t.Fatalf("sensitive snapshots disclosed a value: %+v", secret)
|
||||||
}
|
}
|
||||||
visible, ok := byPath["/backends/local/extra_params/visible"]
|
if !strings.Contains(secret.Left.YAML, "before-value") || !strings.Contains(secret.Right.YAML, "after-value") {
|
||||||
if !ok || visible.Left.Redacted || visible.Right.Redacted || visible.HiddenChanged ||
|
t.Fatalf("public sibling missing from snapshots: %+v", secret)
|
||||||
!strings.Contains(visible.Left.YAML, "before-value") || !strings.Contains(visible.Right.YAML, "after-value") {
|
|
||||||
t.Fatalf("public sibling difference = (%+v, %t)", visible, ok)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
differences[0].Path = configkit.Keys("caller", "mutation")
|
differences[0].Path = configkit.Keys("caller", "mutation")
|
||||||
@@ -148,18 +169,15 @@ func TestConfigurationInspectionDiffProtectsSensitiveValuesAndReturnedRecords(t
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
t.Fatalf("repeat comparison after mutating returned records: %v", err)
|
||||||
}
|
}
|
||||||
if len(again) != 2 {
|
if len(again) != 1 {
|
||||||
t.Fatalf("repeat difference count = %d, want 2: %+v", len(again), again)
|
t.Fatalf("repeat difference count = %d, want 1: %+v", len(again), again)
|
||||||
}
|
}
|
||||||
againByPath := differencesByPath(again)
|
againByPath := differencesByPath(again)
|
||||||
if _, exists := againByPath["/caller/mutation"]; exists {
|
if _, exists := againByPath["/caller/mutation"]; exists {
|
||||||
t.Fatal("mutating returned path reached retained inspection")
|
t.Fatal("mutating returned path reached retained inspection")
|
||||||
}
|
}
|
||||||
if _, exists := againByPath["/backends/local/extra_params/token"]; !exists {
|
if _, exists := againByPath["/backends"]; !exists {
|
||||||
t.Fatalf("original sensitive path was lost after mutation: %+v", again)
|
t.Fatalf("original backend path was lost after mutation: %+v", again)
|
||||||
}
|
|
||||||
if _, exists := againByPath["/backends/local/extra_params/visible"]; !exists {
|
|
||||||
t.Fatalf("original public path was lost after mutation: %+v", again)
|
|
||||||
}
|
}
|
||||||
for _, difference := range again {
|
for _, difference := range again {
|
||||||
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
if difference.Left.YAML == "caller mutation" || difference.Right.YAML == "caller mutation" {
|
||||||
@@ -183,7 +201,7 @@ func TestConfigurationInspectionDiffMasksSensitiveShapeReplacement(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compare sensitive shape replacement: %v", err)
|
t.Fatalf("compare sensitive shape replacement: %v", err)
|
||||||
}
|
}
|
||||||
if len(differences) != 1 || differences[0].Path.String() != "/backends/local/extra_params/details" {
|
if len(differences) != 1 || differences[0].Path.String() != "/backends" {
|
||||||
t.Fatalf("shape replacement differences = %+v", differences)
|
t.Fatalf("shape replacement differences = %+v", differences)
|
||||||
}
|
}
|
||||||
difference := differences[0]
|
difference := differences[0]
|
||||||
@@ -202,16 +220,16 @@ func comparisonInspection(t *testing.T, model string) promptkit.ConfigurationIns
|
|||||||
|
|
||||||
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
func comparisonInspectionFrom(t *testing.T, source, model string, extraParams map[string]any) promptkit.ConfigurationInspection {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backend := map[string]any{"endpoint": "https://example.invalid/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}
|
||||||
if extraParams != nil {
|
if extraParams != nil {
|
||||||
backend["extra_params"] = extraParams
|
backend["extra_params"] = extraParams
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping(source, map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
"profiles": map[string]any{"offline": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local",
|
"id": "offline", "backend": "local",
|
||||||
"model": model,
|
"model": model,
|
||||||
}},
|
}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}})
|
}})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigurationInspection is a read-only view of one Engine's captured
|
// ConfigurationInspection is a read-only view of one Engine's captured
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,34 +12,37 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{
|
||||||
"endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY",
|
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "api_key_env": "OPTIONAL_KEY",
|
||||||
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
"extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"},
|
||||||
}},
|
}},
|
||||||
"collections": map[string]any{"literal": map[string]any{
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{
|
||||||
"endpoint": "https://example.invalid/v1",
|
map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "namespaced-private-sentinel"}},
|
||||||
"extra_params": map[string]any{"token": "namespaced-private-sentinel"},
|
map[string]any{"id": "child", "extends": map[string]any{"id": "local"}},
|
||||||
}},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base.v1": map[string]any{"backend": "local", "model": "default-model"},
|
map[string]any{"id": "base.v1", "backend": "local", "model": "default-model"},
|
||||||
"id": map[string]any{"extends": "base.v1"},
|
map[string]any{"id": "id", "extends": map[string]any{"id": "base.v1"}},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{
|
||||||
"collections": map[string]any{"literal": map[string]any{"profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}}},
|
"collections": map[string]any{"literal": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1", "extra_params": map[string]any{"token": "replacement-private-sentinel"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "base.v1", "backend": "local", "model": "operator-model"}},
|
||||||
|
}},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct layered engine: %v", err)
|
t.Fatalf("construct layered engine: %v", err)
|
||||||
}
|
}
|
||||||
@@ -46,29 +50,43 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("inspect configuration: %v", err)
|
t.Fatalf("inspect configuration: %v", err)
|
||||||
}
|
}
|
||||||
modelPath := configkit.Keys("collections", "literal", "profiles", "base.v1", "model")
|
modelPath := configkit.Keys("collections", "literal", "profiles").Index(0).Key("model")
|
||||||
model, found := view.Explain(modelPath)
|
model, found := view.Explain(modelPath)
|
||||||
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 {
|
if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) == 0 {
|
||||||
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
t.Fatalf("layered model explanation = (%+v, %t)", model, found)
|
||||||
}
|
}
|
||||||
inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model")
|
inheritedPath := configkit.Keys("collections", "literal", "profiles").Index(1).Key("model")
|
||||||
inherited, found := view.Explain(inheritedPath)
|
inherited, found := view.Explain(inheritedPath)
|
||||||
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) {
|
||||||
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found)
|
||||||
}
|
}
|
||||||
if inheritedPath.String() != "/collections/literal/profiles/id/model" {
|
if inheritedPath.String() != "/collections/literal/profiles/1/model" {
|
||||||
t.Fatalf("literal-key path = %q", inheritedPath)
|
t.Fatalf("literal-key path = %q", inheritedPath)
|
||||||
}
|
}
|
||||||
secretPath := configkit.Keys("backends", "local", "extra_params", "token")
|
secretPath := configkit.Keys("backends").Index(0).Key("extra_params").Key("token")
|
||||||
|
endpointPath := configkit.Keys("backends").Index(0).Key("endpoint")
|
||||||
|
endpoint, found := view.Explain(endpointPath)
|
||||||
|
if !found || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") || strings.Contains(fmt.Sprintf("%+v", view.Metadata().History(endpointPath)), "private-sentinel") {
|
||||||
|
t.Fatalf("normalized endpoint explanation = %+v", endpoint)
|
||||||
|
}
|
||||||
secret, found := view.Explain(secretPath)
|
secret, found := view.Explain(secretPath)
|
||||||
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") {
|
||||||
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found)
|
||||||
}
|
}
|
||||||
namespacedSecretPath := configkit.Keys("collections", "literal", "backends", "local", "extra_params", "token")
|
namespacedSecretPath := configkit.Keys("collections", "literal", "backends").Index(0).Key("extra_params").Key("token")
|
||||||
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
namespacedSecret, found := view.Explain(namespacedSecretPath)
|
||||||
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") {
|
||||||
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found)
|
||||||
}
|
}
|
||||||
|
inheritedSecretPath := configkit.Keys("collections", "literal", "backends").Index(1).Key("extra_params").Key("token")
|
||||||
|
inheritedSecret, found := view.Explain(inheritedSecretPath)
|
||||||
|
if !found || !inheritedSecret.Effective.Redacted || strings.Contains(inheritedSecret.Effective.YAML, "replacement-private-sentinel") {
|
||||||
|
t.Fatalf("inherited provider value was not redacted: (%+v, %t)", inheritedSecret, found)
|
||||||
|
}
|
||||||
|
metadata := fmt.Sprintf("%+v", view.Metadata().History(inheritedSecretPath))
|
||||||
|
if strings.Contains(metadata, "replacement-private-sentinel") || strings.Contains(metadata, "namespaced-private-sentinel") {
|
||||||
|
t.Fatal("metadata disclosed a sensitive provider value")
|
||||||
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
if err := view.WriteEffective(&effective); err != nil {
|
if err := view.WriteEffective(&effective); err != nil {
|
||||||
t.Fatalf("write effective configuration: %v", err)
|
t.Fatalf("write effective configuration: %v", err)
|
||||||
@@ -104,9 +122,26 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigurationInspectionFailureDoesNotDiscloseSensitiveValues(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
|
Source: configkit.Mapping("invalid backend", map[string]any{
|
||||||
|
"collections": map[string]any{"private": map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "backend", "endpoint": "invalid endpoint", "extra_params": map[string]any{"token": "failure-private-sentinel"}},
|
||||||
|
}}},
|
||||||
|
}),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid backend error = %v, want load error", err)
|
||||||
|
}
|
||||||
|
if text := fmt.Sprintf("%v %+v", err, loadErr.Diagnostics()); strings.Contains(text, "failure-private-sentinel") {
|
||||||
|
t.Fatalf("failure diagnostics disclosed a sensitive value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("main", map[string]any{"version": "1"}),
|
Source: configkit.Mapping("main", map[string]any{"version": "2"}),
|
||||||
}}})
|
}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine with accepted declaration: %v", err)
|
t.Fatalf("construct engine with accepted declaration: %v", err)
|
||||||
@@ -117,7 +152,7 @@ func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T
|
|||||||
}
|
}
|
||||||
path := configkit.Keys("version")
|
path := configkit.Keys("version")
|
||||||
field, found := inspection.Explain(path)
|
field, found := inspection.Explain(path)
|
||||||
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "1") {
|
if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "2") {
|
||||||
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found)
|
||||||
}
|
}
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
@@ -150,9 +185,8 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
t.Fatalf("zero explanation writer = %v", err)
|
t.Fatalf("zero explanation writer = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, model := "https://configured.example/v1", "configured-model"
|
profile := configkit.Input{Source: configkit.Mapping("inspection test profile", map[string]any{
|
||||||
profile := configkit.Input{Source: promptkit.ProfileSource("inspection test profile", map[string]promptkit.ProfileConfig{
|
"profiles": []any{map[string]any{"id": "inspection-profile", "endpoint": "https://configured.example/v1", "model": "configured-model"}},
|
||||||
"inspection-profile": {Endpoint: &endpoint, Model: &model},
|
|
||||||
})}
|
})}
|
||||||
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
valid, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{profile}}},
|
||||||
@@ -169,14 +203,15 @@ func TestConfigurationInspectionZeroAndWriterErrors(t *testing.T) {
|
|||||||
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
if err := view.WriteEffective(writer); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("effective writer error = %v", err)
|
t.Fatalf("effective writer error = %v", err)
|
||||||
}
|
}
|
||||||
if err := view.WriteExplanation(writer, configkit.Keys("profiles", "inspection-profile", "model")); !errors.Is(err, writeErr) {
|
if err := view.WriteExplanation(writer, configkit.Keys("profiles").Index(0).Key("model")); !errors.Is(err, writeErr) {
|
||||||
t.Fatalf("explanation writer error = %v", err)
|
t.Fatalf("explanation writer error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testing.T) {
|
||||||
|
const promptDocument = "prompts:\n - id: captured\n default_profile: offline\n messages:\n - role: user\n content_file: body.tmpl\n output:\n format: text\n validation_mode: none\n"
|
||||||
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
inputs, err := promptkit.FilesystemInputs("inspection source", source, "config")
|
||||||
@@ -184,7 +219,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("select inputs: %v", err)
|
t.Fatalf("select inputs: %v", err)
|
||||||
}
|
}
|
||||||
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
otherSource := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(promptDocument)},
|
||||||
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("hello")},
|
||||||
}}
|
}}
|
||||||
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
otherInputs, err := promptkit.FilesystemInputs("comparison source", otherSource, "config")
|
||||||
@@ -195,16 +230,16 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
client := countingLLMClient{calls: &modelCalls}
|
client := countingLLMClient{calls: &modelCalls}
|
||||||
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
endpoint, model, otherModel := "https://example.invalid/v1", "offline-model", "comparison-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: inputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("offline profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": model}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct engine: %v", err)
|
t.Fatalf("construct engine: %v", err)
|
||||||
}
|
}
|
||||||
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
otherEngine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: promptkit.ProfileSource("comparison profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: otherInputs}, {Inputs: []configkit.Input{{Source: configkit.Mapping("comparison profile", map[string]any{
|
||||||
"offline": {Endpoint: &endpoint, Model: &otherModel},
|
"profiles": []any{map[string]any{"id": "offline", "endpoint": endpoint, "model": otherModel}},
|
||||||
})}}}},
|
})}}}},
|
||||||
}}, promptkit.WithLLMClient(client))
|
}}, promptkit.WithLLMClient(client))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,7 +261,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
if err != nil || len(differences) != 1 {
|
if err != nil || len(differences) != 1 {
|
||||||
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
t.Fatalf("compare captured configurations = (%+v, %v), want one difference", differences, err)
|
||||||
}
|
}
|
||||||
if differences[0].Path.String() != "/profiles/offline/model" ||
|
if differences[0].Path.String() != "/profiles" ||
|
||||||
!strings.Contains(differences[0].Left.YAML, model) ||
|
!strings.Contains(differences[0].Left.YAML, model) ||
|
||||||
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
!strings.Contains(differences[0].Right.YAML, otherModel) {
|
||||||
t.Fatalf("captured difference = %+v", differences[0])
|
t.Fatalf("captured difference = %+v", differences[0])
|
||||||
@@ -238,7 +273,7 @@ func TestConfigurationInspectionUsesCapturedSourcesWithoutProviderCalls(t *testi
|
|||||||
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
t.Fatalf("captured difference changed between reads: first=%+v next=%+v", expected, differences[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys("prompts", "captured", "messages").Index(0).Key("content_file")); !found {
|
if _, found := view.Explain(configkit.Keys("prompts").Index(0).Key("messages").Index(0).Key("content_file")); !found {
|
||||||
t.Fatal("captured prompt content path was not inspectable")
|
t.Fatal("captured prompt content path was not inspectable")
|
||||||
}
|
}
|
||||||
var report bytes.Buffer
|
var report bytes.Buffer
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectionInput mounts source at the selected collection's configuration
|
// CollectionInput mounts source at the selected collection's configuration
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceDocument struct {
|
type sourceDocument struct {
|
||||||
@@ -37,10 +37,10 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "collection.yaml")
|
mainPath := filepath.Join(directory, "collection.yaml")
|
||||||
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
defaultsPath := filepath.Join(directory, "defaults.yaml")
|
||||||
if err := os.WriteFile(mainPath, []byte("configkit:\n imports:\n - path: defaults.yaml\n"), 0o600); err != nil {
|
if err := os.WriteFile(mainPath, []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(defaultsPath, []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
if err := os.WriteFile(defaultsPath, []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
input, err := promptkit.CollectionInput("docs", configkit.File(mainPath))
|
||||||
@@ -50,7 +50,7 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -58,15 +58,15 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with native collection import: %v", err)
|
t.Fatalf("construct engine with native collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || inspection.Inputs == nil || len(*inspection.Inputs) != 1 || (*inspection.Inputs)[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"collection.yaml": {Data: []byte("configkit:\n imports:\n - path: defaults.yaml\n")},
|
"collection.yaml": {Data: []byte("config:\n imports:\n - path: defaults.yaml\n required: true\n")},
|
||||||
"defaults.yaml": {Data: []byte("prompts:\n greeting:\n version: imported\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
"defaults.yaml": {Data: []byte("prompts:\n - id: greeting\n messages:\n - role: user\n content: Hello\n output:\n format: text\n validation_mode: none\n")},
|
||||||
}
|
}
|
||||||
filesystem := configkit.NewFilesystem("embedded collection", files)
|
filesystem := configkit.NewFilesystem("embedded collection", files)
|
||||||
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
input, err := promptkit.CollectionInput("docs", filesystem.File("collection.yaml"))
|
||||||
@@ -77,7 +77,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||||
Main: &configkit.Input{
|
Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("later collection override", map[string]any{"prompts": map[string]any{"greeting": map[string]any{"version": "overridden"}}}),
|
Source: configkit.Mapping("later collection override", map[string]any{"prompts": []any{map[string]any{"id": "greeting", "inputs": []any{map[string]any{"name": "overridden"}}, "messages": []any{map[string]any{"role": "user", "content": "Updated"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}}),
|
||||||
Destination: configkit.Keys("collections", "docs"),
|
Destination: configkit.Keys("collections", "docs"),
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -85,7 +85,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
|
|||||||
t.Fatalf("construct engine with embedded collection import: %v", err)
|
t.Fatalf("construct engine with embedded collection import: %v", err)
|
||||||
}
|
}
|
||||||
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
|
||||||
if err != nil || inspection.PromptVersion == nil || *inspection.PromptVersion != "overridden" {
|
if err != nil || inspection.PromptID != "docs/greeting" || inspection.Inputs == nil || len(*inspection.Inputs) != 1 || (*inspection.Inputs)[0].Name != "overridden" {
|
||||||
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
t.Fatalf("inspect imported and overlaid prompt = (%+v, %v)", inspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
||||||
@@ -38,18 +38,19 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := simpleConfig("hello")
|
cfg := simpleConfig("hello")
|
||||||
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}}
|
||||||
|
endpoint, model := "http://example.test/v1", "model"
|
||||||
lowerName := name
|
lowerName := name
|
||||||
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{
|
||||||
Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{
|
Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &lowerName},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &lowerName},
|
||||||
}),
|
}),
|
||||||
}}})
|
}}})
|
||||||
env := " " + name + " "
|
env := " " + name + " "
|
||||||
if test.clear {
|
if test.clear {
|
||||||
env = ""
|
env = ""
|
||||||
}
|
}
|
||||||
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{
|
cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{
|
||||||
"profile": {APIKeyEnv: &env},
|
{ID: "profile", Endpoint: &endpoint, Model: &model, APIKeyEnv: &env},
|
||||||
})}
|
})}
|
||||||
var calls int
|
var calls int
|
||||||
var auth string
|
var auth string
|
||||||
@@ -73,21 +74,23 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) {
|
|||||||
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) {
|
||||||
backendID, model := "workstation", "offline-model"
|
backendID, model := "workstation", "offline-model"
|
||||||
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
prompt := configkit.Input{Source: configkit.Mapping("prompt", map[string]any{
|
||||||
"prompts": map[string]any{"hello": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "1",
|
"id": "hello",
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}},
|
}},
|
||||||
})}
|
})}
|
||||||
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}}
|
||||||
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
|
localBackend.ID = backendID
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend,
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Backend: &backendID, Model: &model},
|
{ID: "offline", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
Main: &prompt,
|
Main: &prompt,
|
||||||
@@ -113,13 +116,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
|
|||||||
model := "configured-model"
|
model := "configured-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{
|
||||||
openRouterID: {Endpoint: &openRouterEndpoint},
|
{ID: openRouterID, Endpoint: &openRouterEndpoint},
|
||||||
rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint},
|
{ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{
|
||||||
"openrouter-profile": {Backend: &openRouterID, Model: &model},
|
{ID: "openrouter-profile", Backend: &openRouterID, Model: &model},
|
||||||
"rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model},
|
{ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -131,7 +134,7 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
|
|||||||
"rakestrawhome-profile": rakestrawhomeID,
|
"rakestrawhome-profile": rakestrawhomeID,
|
||||||
} {
|
} {
|
||||||
inspection, err := engine.InspectProfile(context.Background(), profileID)
|
inspection, err := engine.InspectProfile(context.Background(), profileID)
|
||||||
if err != nil || inspection.EffectiveModelParams.BackendID != backendID {
|
if err != nil || inspection.Backend == nil || *inspection.Backend != backendID {
|
||||||
t.Fatalf("inspect %q = (%+v, %v), want backend %q", profileID, inspection, err, backendID)
|
t.Fatalf("inspect %q = (%+v, %v), want backend %q", profileID, inspection, err, backendID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,12 +144,11 @@ func TestPublicConstructionCombinesEmbeddedOperatorAndProgrammaticInputs(t *test
|
|||||||
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
embedded, err := promptkit.FilesystemInputs("embedded defaults", fstest.MapFS{
|
||||||
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
"config/default.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
profiles:
|
profiles:
|
||||||
application:
|
- id: application
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
model: default-model
|
model: default-model
|
||||||
prompts:
|
prompts:
|
||||||
hello:
|
- id: hello
|
||||||
version: "1"
|
|
||||||
default_profile: application
|
default_profile: application
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -160,16 +162,16 @@ prompts:
|
|||||||
t.Fatalf("select embedded defaults: %v", err)
|
t.Fatalf("select embedded defaults: %v", err)
|
||||||
}
|
}
|
||||||
operatorDirectory := t.TempDir()
|
operatorDirectory := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n application:\n model: operator-model\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(operatorDirectory, "override.yaml"), []byte("profiles:\n - id: application\n endpoint: https://example.invalid/v1\n model: operator-model\n"), 0o644); err != nil {
|
||||||
t.Fatalf("write operator input: %v", err)
|
t.Fatalf("write operator input: %v", err)
|
||||||
}
|
}
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("select operator inputs: %v", err)
|
t.Fatalf("select operator inputs: %v", err)
|
||||||
}
|
}
|
||||||
programmaticModel := "programmatic-model"
|
programmaticModel, programmaticEndpoint := "programmatic-model", "https://example.invalid/v1"
|
||||||
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{
|
programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{
|
||||||
"application": {Model: &programmaticModel},
|
{ID: "application", Endpoint: &programmaticEndpoint, Model: &programmaticModel},
|
||||||
})}
|
})}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -205,7 +207,7 @@ prompts:
|
|||||||
|
|
||||||
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
func TestPublicConstructionPreservesConfigKitLoadError(t *testing.T) {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{
|
||||||
Source: configkit.Mapping("invalid", map[string]any{"profiles": map[string]any{"broken": map[string]any{"unknown": true}}}),
|
Source: configkit.Mapping("invalid", map[string]any{"profiles": []any{map[string]any{"id": "broken", "unknown": true}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadErr *configkit.LoadError
|
var loadErr *configkit.LoadError
|
||||||
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
if engine != nil || !errors.Is(err, promptkit.ErrInvalidConfig) || !errors.As(err, &loadErr) {
|
||||||
@@ -225,11 +227,11 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
|
|||||||
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
{Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{
|
{Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{
|
||||||
backendID: {Endpoint: &endpoint},
|
{ID: backendID, Endpoint: &endpoint},
|
||||||
})},
|
})},
|
||||||
{Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{
|
{Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{
|
||||||
"application-profile": {Backend: &backendID, Model: &model},
|
{ID: "application-profile", Backend: &backendID, Model: &model},
|
||||||
})},
|
})},
|
||||||
}}},
|
}}},
|
||||||
}})
|
}})
|
||||||
@@ -240,8 +242,9 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("inspect configured profile: %v", err)
|
t.Fatalf("inspect configured profile: %v", err)
|
||||||
}
|
}
|
||||||
if inspection.EffectiveModelParams.Endpoint != endpoint {
|
backendInspection, err := engine.InspectBackend(context.Background(), backendID)
|
||||||
t.Fatalf("effective endpoint = %q, want %q", inspection.EffectiveModelParams.Endpoint, endpoint)
|
if err != nil || inspection.Endpoint != nil || backendInspection.Endpoint != endpoint {
|
||||||
|
t.Fatalf("profile/backend = %+v / %+v, %v", inspection, backendInspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,8 +254,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) {
|
|||||||
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
reachabilityEndpoint := " https://probe.example/ready?check=1 "
|
||||||
authenticationKind := " OPENROUTER_KEY "
|
authenticationKind := " OPENROUTER_KEY "
|
||||||
authenticationEndpoint := "https://probe.example/key?scope=current"
|
authenticationEndpoint := "https://probe.example/key?scope=current"
|
||||||
source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{
|
source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{
|
||||||
"local": {
|
{
|
||||||
|
ID: "local",
|
||||||
Endpoint: &backendEndpoint,
|
Endpoint: &backendEndpoint,
|
||||||
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
SmokeTests: &promptkit.BackendSmokeTestConfig{
|
||||||
DefaultProfile: &defaultProfile,
|
DefaultProfile: &defaultProfile,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeLLMClient struct {
|
type fakeLLMClient struct {
|
||||||
@@ -60,11 +60,10 @@ func newSimpleEngine(t *testing.T, content string, options ...promptkit.Option)
|
|||||||
func simpleConfig(content string) promptkit.Config {
|
func simpleConfig(content string) promptkit.Config {
|
||||||
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
return promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("consumer test", map[string]any{
|
||||||
"profiles": map[string]any{"profile": map[string]any{
|
"profiles": []any{map[string]any{"id": "profile",
|
||||||
"endpoint": "http://example.test/v1", "model": "model",
|
"endpoint": "http://example.test/v1", "model": "model",
|
||||||
}},
|
}},
|
||||||
"prompts": map[string]any{"prompt": map[string]any{
|
"prompts": []any{map[string]any{"id": "prompt",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "profile",
|
"default_profile": "profile",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": content}},
|
"messages": []any{map[string]any{"role": "user", "content": content}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
|||||||
160
convert.go
160
convert.go
@@ -2,11 +2,11 @@ package promptkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/configuration"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
)
|
)
|
||||||
|
|
||||||
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
@@ -72,7 +72,6 @@ func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
|||||||
}
|
}
|
||||||
return &PreparedRun{
|
return &PreparedRun{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: copyStringPtr(prepared.PromptVersion),
|
|
||||||
PromptHash: prepared.PromptHash,
|
PromptHash: prepared.PromptHash,
|
||||||
SelectedProfileID: prepared.SelectedProfileID,
|
SelectedProfileID: prepared.SelectedProfileID,
|
||||||
SelectedBackendID: prepared.SelectedBackendID,
|
SelectedBackendID: prepared.SelectedBackendID,
|
||||||
@@ -99,7 +98,6 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
|||||||
RawOutput: result.RawOutput,
|
RawOutput: result.RawOutput,
|
||||||
Validation: fromDomainValidationResult(result.Validation),
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
PromptID: result.PromptID,
|
PromptID: result.PromptID,
|
||||||
PromptVersion: copyStringPtr(result.PromptVersion),
|
|
||||||
PromptHash: result.PromptHash,
|
PromptHash: result.PromptHash,
|
||||||
SessionID: result.SessionID,
|
SessionID: result.SessionID,
|
||||||
RenderedPromptHash: result.RenderedPromptHash,
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
@@ -231,38 +229,58 @@ func fromDomainExecutionTargetWithOwnedParams(target domain.ExecutionTarget, ext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fromDomainProfileInspection(inspection *domain.ProfileInspection) *ProfileInspection {
|
func fromProfileResource(v *configuration.ProfileResource) *ProfileInspection {
|
||||||
if inspection == nil {
|
out := &ProfileInspection{ProfileID: v.ID, Backend: copyStringPtr(v.Backend), Endpoint: copyStringPtr(v.Endpoint), Model: copyStringPtr(v.Model), Temperature: copyFloat64Ptr(v.Temperature), TopP: copyFloat64Ptr(v.TopP), MaxTokens: copyIntPtr(v.MaxTokens), TimeoutSeconds: copyIntPtr(v.TimeoutSeconds), ServiceTier: copyStringPtr(v.ServiceTier), ReasoningEffort: copyStringPtr(v.ReasoningEffort), APIKeyEnv: copyStringPtr(v.APIKeyEnv), APIKeyRequired: copyBoolPtr(v.APIKeyRequired)}
|
||||||
return nil
|
if v.ExtraParams != nil {
|
||||||
}
|
data := copyAnyMap(v.ExtraParams)
|
||||||
return &ProfileInspection{
|
out.ExtraParams = &data
|
||||||
ProfileID: inspection.ProfileID,
|
|
||||||
EffectiveModelParams: fromDomainExecutionTarget(inspection.EffectiveModelParams),
|
|
||||||
APIKeyRequired: inspection.APIKeyRequired,
|
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInspection {
|
func fromBackendResource(v *configuration.BackendResource) *BackendInspection {
|
||||||
if inspection == nil {
|
out := &BackendInspection{BackendID: v.ID, Endpoint: v.Endpoint, APIKeyEnv: copyStringPtr(v.APIKeyEnv), ConcurrencyLimit: copyIntPtr(v.ConcurrencyLimit), QueueCapacity: copyIntPtr(v.QueueCapacity)}
|
||||||
return nil
|
if v.ExtraParams != nil {
|
||||||
|
data := copyAnyMap(v.ExtraParams)
|
||||||
|
out.ExtraParams = &data
|
||||||
}
|
}
|
||||||
inputs := make([]PromptInputDefinition, len(inspection.Inputs))
|
if v.SmokeTests != nil {
|
||||||
for i, input := range inspection.Inputs {
|
out.SmokeTests = &BackendSmokeTestConfig{DefaultProfile: copyStringPtr(v.SmokeTests.DefaultProfile)}
|
||||||
inputs[i] = PromptInputDefinition{
|
if v.SmokeTests.Reachability != nil {
|
||||||
Name: input.Name,
|
out.SmokeTests.Reachability = &ReachabilityProbeConfig{Endpoint: copyStringPtr(v.SmokeTests.Reachability.Endpoint)}
|
||||||
Required: input.Required,
|
}
|
||||||
ContentType: input.ContentType,
|
if v.SmokeTests.Authentication != nil {
|
||||||
Description: input.Description,
|
out.SmokeTests.Authentication = &AuthenticationProbeConfig{Kind: copyStringPtr(v.SmokeTests.Authentication.Kind), Endpoint: copyStringPtr(v.SmokeTests.Authentication.Endpoint)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &PromptInspection{
|
return out
|
||||||
PromptID: inspection.PromptID,
|
}
|
||||||
PromptVersion: copyStringPtr(inspection.PromptVersion),
|
|
||||||
PromptHash: inspection.PromptHash,
|
func fromPromptResource(v *configuration.PromptResource) *PromptInspection {
|
||||||
DefaultProfileID: inspection.DefaultProfileID,
|
out := &PromptInspection{PromptID: v.ID, PromptHash: v.Hash, DefaultProfileID: copyStringPtr(v.DefaultProfile), Description: copyStringPtr(v.Description), SessionID: copyStringPtr(v.SessionID), OutputContract: PromptOutputInspection{Format: OutputFormat(v.Output.Format), ValidationMode: ValidationMode(v.Output.ValidationMode), SchemaPath: copyStringPtr(v.Output.SchemaPath), RepairAttempts: copyIntPtr(v.Output.RepairAttempts)}}
|
||||||
Inputs: inputs,
|
if v.Inputs != nil {
|
||||||
OutputContract: fromDomainOutputContract(inspection.OutputContract),
|
inputs := make([]PromptInputInspection, len(*v.Inputs))
|
||||||
|
for i, input := range *v.Inputs {
|
||||||
|
inputs[i] = PromptInputInspection{Name: input.Name, Required: copyBoolPtr(input.Required), ContentType: copyStringPtr(input.ContentType), Description: copyStringPtr(input.Description)}
|
||||||
|
}
|
||||||
|
out.Inputs = &inputs
|
||||||
}
|
}
|
||||||
|
out.Messages = make([]PromptMessageInspection, len(v.Messages))
|
||||||
|
for i, message := range v.Messages {
|
||||||
|
out.Messages[i] = PromptMessageInspection{Role: message.Role, Content: message.Content}
|
||||||
|
if message.CacheControl != nil {
|
||||||
|
out.Messages[i].CacheControl = &PromptCacheControlInspection{Type: CacheControlType(message.CacheControl.Type), TTL: copyStringPtr(message.CacheControl.TTL)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBoolPtr(src *bool) *bool {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := *src
|
||||||
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
||||||
@@ -398,88 +416,10 @@ func copyAnyMap(src map[string]any) map[string]any {
|
|||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]any, len(src))
|
return jsonvalue.CloneTrusted(src).(map[string]any)
|
||||||
for k, v := range src {
|
|
||||||
out[k] = copyAny(v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyAny(value any) any {
|
func copyAny(value any) any { return jsonvalue.CloneTrusted(value) }
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
return copyAnyMap(v)
|
|
||||||
case []any:
|
|
||||||
out := make([]any, len(v))
|
|
||||||
for i, item := range v {
|
|
||||||
out[i] = copyAny(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
return copyStringSlice(v)
|
|
||||||
case []byte:
|
|
||||||
return copyBytes(v)
|
|
||||||
default:
|
|
||||||
return copyReflectValue(reflect.ValueOf(value)).Interface()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyReflectValue(value reflect.Value) reflect.Value {
|
|
||||||
if !value.IsValid() {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Interface:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
copied := copyReflectValue(value.Elem())
|
|
||||||
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
|
|
||||||
return copied
|
|
||||||
}
|
|
||||||
out := reflect.New(value.Type()).Elem()
|
|
||||||
out.Set(copied)
|
|
||||||
return out
|
|
||||||
case reflect.Pointer:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.New(value.Type().Elem())
|
|
||||||
out.Elem().Set(copyReflectValue(value.Elem()))
|
|
||||||
return out
|
|
||||||
case reflect.Map:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
|
||||||
iter := value.MapRange()
|
|
||||||
for iter.Next() {
|
|
||||||
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case reflect.Slice:
|
|
||||||
if value.IsNil() {
|
|
||||||
return reflect.Zero(value.Type())
|
|
||||||
}
|
|
||||||
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case reflect.Array:
|
|
||||||
out := reflect.New(value.Type()).Elem()
|
|
||||||
for i := 0; i < value.Len(); i++ {
|
|
||||||
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyStringSlice(src []string) []string {
|
func copyStringSlice(src []string) []string {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToDomainAppendedMessages(t *testing.T) {
|
func TestToDomainAppendedMessages(t *testing.T) {
|
||||||
|
|||||||
26
doc.go
26
doc.go
@@ -4,17 +4,17 @@
|
|||||||
// Applications construct an [Engine] with [NewEngine], select ConfigKit
|
// Applications construct an [Engine] with [NewEngine], select ConfigKit
|
||||||
// sources for prompts, profiles, and backends, and
|
// sources for prompts, profiles, and backends, and
|
||||||
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
|
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
|
||||||
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.ListProfiles],
|
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.InspectBackend],
|
||||||
// [Engine.ListPrompts], [Engine.Prepare], [Engine.PrepareExecution],
|
// [Engine.ListProfiles], [Engine.ListPrompts], [Engine.Prepare],
|
||||||
// [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
|
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
|
||||||
// validators, and the built-in OpenAI-compatible client remain internal
|
// validators, and the built-in OpenAI-compatible client remain internal
|
||||||
// implementation details.
|
// implementation details.
|
||||||
//
|
//
|
||||||
// # Concurrency and ownership
|
// # Concurrency and ownership
|
||||||
//
|
//
|
||||||
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
|
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
|
||||||
// InspectRequirements, InspectProfile, ListProfiles, ListPrompts, Prepare,
|
// InspectRequirements, InspectProfile, InspectBackend, ListProfiles, ListPrompts,
|
||||||
// PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
|
// Prepare, PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
|
||||||
// bound admitted Run and RunPrepared calls and model generations where
|
// bound admitted Run and RunPrepared calls and model generations where
|
||||||
// configured, while different backend pools and unlimited backends continue
|
// configured, while different backend pools and unlimited backends continue
|
||||||
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
|
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
// ConfigKit captures the selected configuration when NewEngine constructs an
|
// ConfigKit captures the selected configuration when NewEngine constructs an
|
||||||
// Engine. Prepare, PrepareExecution, and Run copy request maps, slices, pointer
|
// Engine. Prepare, PrepareExecution, and Run copy request maps, slices, pointer
|
||||||
// values, and JSON-compatible extra parameters before using them. InspectPrompt,
|
// values, and JSON-compatible extra parameters before using them. InspectPrompt,
|
||||||
// InspectRequirements, and InspectProfile return copied inspection values.
|
// InspectRequirements, InspectProfile, and InspectBackend return copied
|
||||||
|
// inspection values.
|
||||||
// ListProfiles and ListPrompts return caller-owned identity slices.
|
// ListProfiles and ListPrompts return caller-owned identity slices.
|
||||||
// Returned values and values passed to extension interfaces are likewise
|
// Returned values and values passed to extension interfaces are likewise
|
||||||
// isolated from engine state.
|
// isolated from engine state.
|
||||||
@@ -56,17 +57,22 @@
|
|||||||
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||||
// used by those values.
|
// used by those values.
|
||||||
//
|
//
|
||||||
// Construction, inspection, handle, and error values, including [Config],
|
// Resource inspection results support direct JSON and YAML encoding.
|
||||||
|
// [PromptInspection], [ProfileInspection], [BackendInspection], and their nested
|
||||||
|
// values define field names, nesting, and omission of absent optional fields.
|
||||||
|
// Formatting belongs to the selected encoder.
|
||||||
|
//
|
||||||
|
// Other construction, inspection, handle, and error values, including [Config],
|
||||||
// [ConfigurationSources], [ConfigurationInspection], [BackendConfig],
|
// [ConfigurationSources], [ConfigurationInspection], [BackendConfig],
|
||||||
// [ProfileConfig], [RunRequest],
|
// [ProfileConfig], [RunRequest],
|
||||||
// [ArtifactRef], [ExecutionTargetOverride], [ProfileInspection],
|
// [ArtifactRef], [ExecutionTargetOverride],
|
||||||
// [PromptIdentity], [PromptInputDefinition], [PromptInspection],
|
// [PromptIdentity], [PromptInputDefinition],
|
||||||
// [RequirementInspectionRequest], [RequirementInspection], [PreparedExecution],
|
// [RequirementInspectionRequest], [RequirementInspection], [PreparedExecution],
|
||||||
// [CapacityError], [GenerationError], [RetryExhaustedError], and
|
// [CapacityError], [GenerationError], [RetryExhaustedError], and
|
||||||
// [ExecutionEvent], do not have stable JSON
|
// [ExecutionEvent], do not have stable JSON
|
||||||
// representations. Direct API keys are nevertheless excluded from JSON for
|
// representations. Direct API keys are nevertheless excluded from JSON for
|
||||||
// every public value.
|
// every public value.
|
||||||
|
//
|
||||||
// Provider-derived [GenerationError] accessor values are untrusted and can
|
// Provider-derived [GenerationError] accessor values are untrusted and can
|
||||||
// contain sensitive request or schema fragments. Applications must apply their
|
// contain sensitive request or schema fragments. Applications must apply their
|
||||||
// own disclosure policy before logging, displaying, or returning them.
|
// own disclosure policy before logging, displaying, or returning them.
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
# Configuration And API Migration
|
# Configuration And API Migration
|
||||||
|
|
||||||
Promptkit no longer installs a provider catalog or selects prompt definitions
|
This breaking pre-v1 minor migration adopts Promptkit's vanity module path,
|
||||||
by descriptive version labels. Applications now choose configuration inputs
|
changes the v0.16-era keyed resources to identified lists, adopts ConfigKit v1,
|
||||||
explicitly, and execution selects one prompt by its exact ID. This guide
|
and removes prompt-version labels.
|
||||||
|
Promptkit installs a default-on bundled OpenRouter catalog; applications choose configuration
|
||||||
|
inputs explicitly, and execution selects one prompt by its exact ID. This guide
|
||||||
explains the shape changes and decisions an application must make; it is not an
|
explains the shape changes and decisions an application must make; it is not an
|
||||||
automatic migration tool. The [framework format reference](../formats.md) owns
|
automatic migration tool. The [framework format reference](../formats.md) owns
|
||||||
the current configuration contract, and the root package GoDoc owns exact API
|
the current configuration contract, and the root package GoDoc owns exact API
|
||||||
signatures and errors.
|
signatures and errors.
|
||||||
|
|
||||||
|
## Update Go Imports
|
||||||
|
|
||||||
|
Promptkit's module and public package import path is now
|
||||||
|
`go.maximumdirect.net/promptkit`, replacing
|
||||||
|
`gitea.maximumdirect.net/eric/promptkit`. Update application, library, test, and
|
||||||
|
generated imports, then select a release using the new declaration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@VERSION
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `VERSION` with a release that declares the vanity module path.
|
||||||
|
Earlier tags, including v0.16.0, retain their original module declaration and
|
||||||
|
use the former import path. No permanent `replace` directive is needed for the
|
||||||
|
cutover. Source browser, licensing, and hosted-release links continue to use
|
||||||
|
the Forge repository URLs, independently of Go's module identifier.
|
||||||
|
|
||||||
|
The package name remains `promptkit`. The path change itself does not rename
|
||||||
|
exported APIs or change their behavior, but Go treats packages imported through
|
||||||
|
the two paths as distinct. Update dependent packages that exchange Promptkit
|
||||||
|
types together and review any old-path dependencies retained after tidying.
|
||||||
|
The configuration and API adaptations below are also part of this migration.
|
||||||
|
|
||||||
## Select And Compose The Configuration You Need
|
## Select And Compose The Configuration You Need
|
||||||
|
|
||||||
Supply each backend, profile, prompt, schema, and collection source that the
|
Supply each backend, profile, prompt, schema, and collection source that the
|
||||||
@@ -30,21 +56,19 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Collection resource keys are local keys, while their runtime identities are
|
Collection resource IDs are local IDs, while their runtime identities are
|
||||||
qualified. For example, `collections.openrouter.profiles.gemini-flash-latest`
|
qualified. For example, a profile with `id: gemini-flash-latest` in collection
|
||||||
is selected as `openrouter/gemini-flash-latest`. Stored backend, prompt-default,
|
`openrouter` is selected as `openrouter/gemini-flash-latest`. Stored backend,
|
||||||
and other resource references must use their exact intended IDs; Promptkit does
|
prompt-default, and other resource references must use their exact intended IDs;
|
||||||
not rewrite references when a source is mounted. Profile `extends` remains a
|
Promptkit does not rewrite references when a source is mounted. `extends`
|
||||||
sibling local key in the same collection, not a cross-collection reference.
|
selects a sibling ID in the same list, not a cross-collection reference.
|
||||||
Review collisions and all references when moving resources into collections.
|
Review collisions and all references when moving resources into collections.
|
||||||
|
|
||||||
The root `version` is an optional ConfigKit source header. A collection's
|
The optional source-root `version` header now accepts `2` or `"2"`; headerless
|
||||||
`release_version` and a prompt's `version` are optional descriptive labels;
|
sources remain valid. A collection's `release_version` remains an optional
|
||||||
neither selects a resource or changes execution. Preserve a label only when it
|
label. Prompt-version labels have been removed: choose one definition per ID
|
||||||
is useful to your application. A formerly version-keyed prompt mapping must be
|
and store any application-specific release label outside Promptkit. In a
|
||||||
reduced to one deliberately chosen definition per prompt ID, with its old label
|
standalone imported prompt item, root `version` is a source header, not a label.
|
||||||
retained as prompt metadata if desired. Promptkit cannot choose a winner from a
|
|
||||||
consumer's version history.
|
|
||||||
|
|
||||||
For example, change a version-keyed definition:
|
For example, change a version-keyed definition:
|
||||||
|
|
||||||
@@ -60,12 +84,11 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
to one direct definition. Its optional `version` is only a label:
|
to one identified definition:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
report:
|
- id: report
|
||||||
version: "2.0"
|
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
content: Review this report.
|
content: Review this report.
|
||||||
@@ -74,6 +97,55 @@ prompts:
|
|||||||
validation_mode: none
|
validation_mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The same conversion applies to backends and profiles. Sibling inheritance uses
|
||||||
|
identity references, and a later same-ID record replaces the whole lower record:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
- id: base
|
||||||
|
endpoint: https://example.invalid/v1
|
||||||
|
model: example-model
|
||||||
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
|
timeout_seconds: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a lower resource's fields, add a distinct inherited variant as
|
||||||
|
above; when replacing the same ID, supply the complete intended record. Generic
|
||||||
|
append, replace, imports, and inheritance are described by the
|
||||||
|
[ConfigKit v1 configuration contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/config.md).
|
||||||
|
Source-root controls are now `config:`, not `configkit:`. Imports are optional
|
||||||
|
unless the selected import declares `required: true`.
|
||||||
|
|
||||||
|
Update ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0: its Go
|
||||||
|
types are incompatible with the old module path. Promptkit now requires Go
|
||||||
|
1.27.0 or newer. Source builders take ordered slices with explicit IDs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
endpoint, model := "https://example.invalid/v1", "example-model"
|
||||||
|
base := "base"
|
||||||
|
source := promptkit.ProfileSource("application", []promptkit.ProfileConfig{
|
||||||
|
{ID: "base", Endpoint: &endpoint, Model: &model},
|
||||||
|
{ID: "fast", Extends: &base},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace former maps passed to `BackendSource` in the same way, setting each
|
||||||
|
`BackendConfig.ID`. Duplicates are retained for loading to reject. Resource
|
||||||
|
inspection paths now address list indexes, for example
|
||||||
|
`configkit.Keys("profiles").Index(0).Key("model")`. Configuration inspection
|
||||||
|
and diffs expose resolved values, recorded normalization and provenance.
|
||||||
|
`InspectProfile` reports the selected resolved profile without backend completion.
|
||||||
|
Use `Prepare` or `PreparedExecution.Details` for completed request settings,
|
||||||
|
including backend defaults and request overrides; see the
|
||||||
|
[resource inspection guidance](pkg-promptkit.md#inspect-a-profile-before-prompt-work).
|
||||||
|
|
||||||
|
Remove `PromptVersion` and `PromptIdentity.Version` uses and the corresponding
|
||||||
|
`prompt_version` JSON members. Prompt definition hashes are opaque equality
|
||||||
|
values and may also change because redundant captured fields were removed.
|
||||||
|
Invalidate persisted caches or comparisons that span this change; hashes from
|
||||||
|
inspection and preparation of the same captured definition still agree.
|
||||||
|
|
||||||
## Identity Mapping
|
## Identity Mapping
|
||||||
|
|
||||||
The reviewed catalog identities have slash-free profile keys. Their documented
|
The reviewed catalog identities have slash-free profile keys. Their documented
|
||||||
@@ -100,8 +172,8 @@ Gemini profile in the same `openrouter` collection:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
standard:
|
- id: standard
|
||||||
extends: gemini-flash-latest
|
extends: {id: gemini-flash-latest}
|
||||||
```
|
```
|
||||||
|
|
||||||
An optional auto profile may instead select the exact backend and model values:
|
An optional auto profile may instead select the exact backend and model values:
|
||||||
@@ -110,7 +182,7 @@ An optional auto profile may instead select the exact backend and model values:
|
|||||||
collections:
|
collections:
|
||||||
openrouter:
|
openrouter:
|
||||||
profiles:
|
profiles:
|
||||||
auto:
|
- id: auto
|
||||||
backend: openrouter/default
|
backend: openrouter/default
|
||||||
model: openrouter/auto
|
model: openrouter/auto
|
||||||
```
|
```
|
||||||
@@ -142,10 +214,8 @@ if err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
If the former format contained multiple versions under one ID, choose the one
|
If the former format contained multiple versions under one ID, choose the one
|
||||||
definition the application should install, and optionally keep its label in
|
definition the application should install. Promptkit no longer retains or
|
||||||
the prompt's `version` field. That label remains descriptive metadata and is
|
returns its former descriptive label.
|
||||||
reported by inspection or results where applicable; it never chooses the
|
|
||||||
definition.
|
|
||||||
|
|
||||||
The public inventory APIs can help an operator inspect a captured effective
|
The public inventory APIs can help an operator inspect a captured effective
|
||||||
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
configuration: `ListProfiles`, `ListPrompts`, and `ListBackends` return exact
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ file contracts.
|
|||||||
Import the package as:
|
Import the package as:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "gitea.maximumdirect.net/eric/promptkit"
|
import "go.maximumdirect.net/promptkit"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For migration from the former module path, see
|
||||||
|
[Update Go Imports](configuration-migration.md#update-go-imports).
|
||||||
|
|
||||||
|
Configuration APIs use `go.maximumdirect.net/configkit` v1.1.0. Its types are
|
||||||
|
not interchangeable with the old ConfigKit module path.
|
||||||
|
|
||||||
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
The maintained [offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
demonstrates explicit collection composition and inventory. Applications
|
demonstrates explicit collection composition and inventory. Applications
|
||||||
adapting older provider or versioned-prompt configuration can use the
|
adapting older provider or versioned-prompt configuration can use the
|
||||||
@@ -28,7 +34,8 @@ programs.
|
|||||||
## Go Compatibility
|
## Go Compatibility
|
||||||
|
|
||||||
The `go` directive in the selected release's [module metadata](../../go.mod)
|
The `go` directive in the selected release's [module metadata](../../go.mod)
|
||||||
declares its minimum supported consumer Go version. Maintainers build and
|
declares its minimum supported consumer Go version, currently Go 1.27.0.
|
||||||
|
Maintainers build and
|
||||||
validate releases with the locally selected Go patch release; the minimum
|
validate releases with the locally selected Go patch release; the minimum
|
||||||
does not simply track that patch version.
|
does not simply track that patch version.
|
||||||
|
|
||||||
@@ -45,6 +52,32 @@ Create an engine with [`NewEngine`](../../engine.go). Select each directory
|
|||||||
explicitly and place its inputs in an ordered ConfigKit underlay. A group
|
explicitly and place its inputs in an ordered ConfigKit underlay. A group
|
||||||
strictly composes its files; later groups can overlay earlier ones:
|
strictly composes its files; later groups can overlay earlier ones:
|
||||||
|
|
||||||
|
The bundled OpenRouter backend and profiles are installed in a lower underlay
|
||||||
|
by default. Select a profile explicitly, for example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
input := configkit.Input{Source: configkit.Mapping("application", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{
|
||||||
|
"id": "my-prompt",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "Hello"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
|
}},
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
|
||||||
|
if err != nil { return err }
|
||||||
|
result, err := engine.Run(ctx, promptkit.RunRequest{PromptID: "my-prompt", ProfileID: "gpt-luna-latest"})
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend looks up
|
||||||
|
`OPENROUTER_API_KEY` when available; credentials are not required at engine
|
||||||
|
construction. For the complete inventory and diagnostic defaults see the
|
||||||
|
[format reference](../formats.md#bundled-openrouter-configuration). Use
|
||||||
|
`promptkit.WithBundledOpenRouter(false)` for application-only inventories.
|
||||||
|
Caller records replace matching bundled records in full; use `extends` with a
|
||||||
|
distinct ID to inherit. Replacing `auto` also changes its cost-tier children's
|
||||||
|
inherited target, while their plugins remain. External collections remain
|
||||||
|
supported.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
promptInputs, err := promptkit.DirectoryInputs("prompts")
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
@@ -61,7 +94,7 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
|||||||
|
|
||||||
For a single highest-priority document, set `Sources.Main` to a
|
For a single highest-priority document, set `Sources.Main` to a
|
||||||
`*configkit.Input`; omit it for ordinary layered construction. A single file
|
`*configkit.Input`; omit it for ordinary layered construction. A single file
|
||||||
can use `configkit.File` directly. `ProfileSource`, `BackendSource`, and raw
|
can use `configkit.File` directly. `PromptSource`, `ProfileSource`, `BackendSource`, and raw
|
||||||
`configkit.Mapping` supply programmatic definitions. Schema and runtime
|
`configkit.Mapping` supply programmatic definitions. Schema and runtime
|
||||||
collaborator options remain available. Construction validates the final
|
collaborator options remain available. Construction validates the final
|
||||||
effective configuration and captures referenced prompt content; construct a
|
effective configuration and captures referenced prompt content; construct a
|
||||||
@@ -108,8 +141,8 @@ defaults, err := promptkit.FilesystemInputs("embedded defaults", applicationFS,
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
operator, err := promptkit.DirectoryInputs(operatorDirectory)
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
profile := promptkit.ProfileSource("application choices", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("application choices", []promptkit.ProfileConfig{
|
||||||
"summary": {Model: &selectedModel},
|
{ID: "summary", Endpoint: &selectedEndpoint, Model: &selectedModel},
|
||||||
})
|
})
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
@@ -120,7 +153,9 @@ engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.Configura
|
|||||||
}})
|
}})
|
||||||
```
|
```
|
||||||
|
|
||||||
Only add the operator group when the application selects that directory; an
|
Each later same-ID resource replaces the complete earlier record; use a distinct
|
||||||
|
inherited sibling for field-level customization. Only add the operator group
|
||||||
|
when the application selects that directory; an
|
||||||
explicitly selected missing or unreadable root is an error. Each helper finds
|
explicitly selected missing or unreadable root is an error. Each helper finds
|
||||||
`.yaml` and `.yml` files recursively and disables imports by default. The
|
`.yaml` and `.yml` files recursively and disables imports by default. The
|
||||||
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
caller may enable imports on individual inputs. A nil `Sources.Main` is an
|
||||||
@@ -131,8 +166,8 @@ labeled mapping as an input. A mapping owns a snapshot of supported values:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
document := configkit.Mapping("application configuration", map[string]any{
|
document := configkit.Mapping("application configuration", map[string]any{
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"local-summary": map[string]any{
|
map[string]any{"id": "local-summary",
|
||||||
"backend": "local", "model": "example-model",
|
"backend": "local", "model": "example-model",
|
||||||
"temperature": nil, // Suppress an inherited provider control.
|
"temperature": nil, // Suppress an inherited provider control.
|
||||||
},
|
},
|
||||||
@@ -142,8 +177,17 @@ input := configkit.Input{Source: document}
|
|||||||
// Add input to a later underlay, or set Sources.Main = &input.
|
// Add input to a later underlay, or set Sources.Main = &input.
|
||||||
```
|
```
|
||||||
|
|
||||||
`ProfileSource` and `BackendSource` are smaller typed conveniences when null
|
`PromptSource`, `ProfileSource`, and `BackendSource` are typed conveniences when
|
||||||
is not needed. Backend and profile definitions are supplied by the caller;
|
null is not needed. Prompt and backend helpers, like profiles, support sibling
|
||||||
|
inheritance via `Extends`; partial records can be completed by other layers.
|
||||||
|
For relative prompt `content_file` references, supply the ordinary ConfigKit
|
||||||
|
`Input.Filesystem` context for that source. The helpers do not read files or
|
||||||
|
validate final definitions. Profile and backend provider maps use ConfigKit Mapping input forms: built-in
|
||||||
|
scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or
|
||||||
|
typed containers explicitly to an appropriate supported value, choosing numeric
|
||||||
|
precision for your application. Mapping captures supported values immediately;
|
||||||
|
unsupported or cyclic values are reported when loaded. Runtime override values
|
||||||
|
follow their separate rules. Backend and profile definitions are supplied by the caller;
|
||||||
there is no registration-only backend namespace.
|
there is no registration-only backend namespace.
|
||||||
|
|
||||||
When configuration or message files change, build and validate a new engine,
|
When configuration or message files change, build and validate a new engine,
|
||||||
@@ -202,14 +246,15 @@ sensitive application data.
|
|||||||
## Inspect The Captured Configuration
|
## Inspect The Captured Configuration
|
||||||
|
|
||||||
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
Use [`Engine.InspectConfiguration`](../../configuration_inspection.go) for an
|
||||||
operator-selected report of effective configuration, source descriptions,
|
operator-selected report of resolved configuration, source descriptions,
|
||||||
diagnostics, and path history. For a literal ID containing a slash, construct
|
diagnostics, and path history. Configuration inspection uses list indexes in
|
||||||
the semantic path with `configkit.Keys`:
|
the collection's nested path rather than qualified runtime IDs. For a profile
|
||||||
|
at index `i` in collection `team`, construct the path with `configkit.Keys`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
path := configkit.Keys("profiles", "team/summary", "model")
|
path := configkit.Keys("collections", "team", "profiles").Index(i).Key("model")
|
||||||
explanation, found := view.Explain(path)
|
explanation, found := view.Explain(path)
|
||||||
if found && explanation.Effective.Redacted {
|
if found && explanation.Effective.Redacted {
|
||||||
// Apply the application's disclosure policy before sharing the report.
|
// Apply the application's disclosure policy before sharing the report.
|
||||||
@@ -220,10 +265,13 @@ if found && explanation.Effective.Redacted {
|
|||||||
the application decides whether those reports may be disclosed. Credential-
|
the application decides whether those reports may be disclosed. Credential-
|
||||||
shaped provider keys are redacted, but arbitrary prompt and provider content
|
shaped provider keys are redacted, but arbitrary prompt and provider content
|
||||||
is not necessarily safe to share. Inspection uses the engine's captured result
|
is not necessarily safe to share. Inspection uses the engine's captured result
|
||||||
and does not reopen sources or resolve credentials. `InspectProfile` instead
|
and does not reopen sources or resolve credentials. It retains effective values,
|
||||||
shows backend-completed execution settings for one profile, and `Prepare` or
|
including recorded field normalization and queue defaulting, alongside source
|
||||||
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
|
provenance.
|
||||||
and validation resources. None of those views is a substitute for the others.
|
`InspectPrompt`, `InspectProfile`, and `InspectBackend` instead show individual
|
||||||
|
resolved resources without backend-to-profile execution completion or provenance.
|
||||||
|
`Prepare` or `PreparedExecution.Details` shows completed request-specific settings,
|
||||||
|
rendered messages, inputs, and validation resources. None substitutes for the others.
|
||||||
|
|
||||||
To review changes between two independently captured configurations, compare
|
To review changes between two independently captured configurations, compare
|
||||||
their inspection views:
|
their inspection views:
|
||||||
@@ -252,11 +300,13 @@ for _, difference := range differences {
|
|||||||
```
|
```
|
||||||
|
|
||||||
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
See the [`ConfigurationInspection.Diff` GoDoc](../../configuration_inspection.go)
|
||||||
and ConfigKit's [v0.8.0 effective-configuration diff guidance](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs)
|
and ConfigKit's [v1.1.0 effective-configuration diff guidance](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs)
|
||||||
for the exact comparison contract. The operation compares only frozen effective
|
for the exact comparison contract. The operation compares only frozen effective
|
||||||
configuration values: it does not reread external files or compare their
|
configuration values: it does not reread external files or compare their
|
||||||
contents, resolved credential contents, later execution preparation, or
|
contents, resolved credential contents, later execution preparation, or
|
||||||
request-specific overrides. An empty diff is not proof of equivalent execution.
|
request-specific overrides. Inputs normalizing to the same effective value do
|
||||||
|
not create a value diff; lists are compared atomically. An empty diff is not
|
||||||
|
proof of equivalent execution.
|
||||||
Records can contain ordinary application data, so use the application's
|
Records can contain ordinary application data, so use the application's
|
||||||
disclosure policy when presenting them.
|
disclosure policy when presenting them.
|
||||||
|
|
||||||
@@ -335,8 +385,10 @@ if err != nil {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, input := range inspection.Inputs {
|
if inspection.Inputs != nil {
|
||||||
// Compare the declared input with application configuration.
|
for _, input := range *inspection.Inputs {
|
||||||
|
// Compare the declared input with application configuration.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -620,7 +672,7 @@ copy, and credential behavior. The
|
|||||||
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
interact with prompt definitions, layered profiles, built-ins, schemas, and
|
||||||
framework defaults.
|
framework defaults.
|
||||||
|
|
||||||
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with keyed
|
For programmatic profiles, use [`ProfileSource`](../../profiles.go) with identified
|
||||||
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
`ProfileConfig` values. Nil fields are omitted; pointers preserve explicit
|
||||||
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
zero, false, and blank values. Raw `configkit.Mapping` expresses explicit null.
|
||||||
|
|
||||||
@@ -633,13 +685,13 @@ and `WithBackend` options have no compatibility wrappers. Use
|
|||||||
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
`DirectoryInputs` or `FilesystemInputs` for a strict-composition file group,
|
||||||
ConfigKit file inputs for individual files, and `ProfileSource`,
|
ConfigKit file inputs for individual files, and `ProfileSource`,
|
||||||
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
`BackendSource`, or `configkit.Mapping` for programmatic values. Backend IDs
|
||||||
are keys in configuration, not registrations. Choose caller underlays in
|
are fields in configuration, not registrations. Choose caller underlays in
|
||||||
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
fallback-to-operator-to-programmatic order; use `Sources.Main` for one final
|
||||||
highest-priority input.
|
highest-priority input.
|
||||||
|
|
||||||
Move embedded IDs into keyed `profiles` and `prompts` maps; each prompt ID now
|
Move resource IDs into identified `backends`, `profiles`, and `prompts` lists;
|
||||||
contains one installed definition, with any descriptive `version` label inside
|
each prompt ID identifies one installed definition without a version label.
|
||||||
that definition. Convert `base_profile` to ConfigKit `extends`. For the current
|
Convert `base_profile` to ConfigKit `extends: {id: parent}`. For the current
|
||||||
collection identity mapping, ID-only prompt selection, and required
|
collection identity mapping, ID-only prompt selection, and required
|
||||||
caller-owned choices, see the
|
caller-owned choices, see the
|
||||||
[configuration migration guide](configuration-migration.md). Exact current
|
[configuration migration guide](configuration-migration.md). Exact current
|
||||||
@@ -651,21 +703,20 @@ the engine to pick up changed definitions or message files.
|
|||||||
### Choose A Profile Ownership Model
|
### Choose A Profile Ownership Model
|
||||||
|
|
||||||
Supply backend and profile definitions through the configuration sources your
|
Supply backend and profile definitions through the configuration sources your
|
||||||
application selects. PromptKit does not install provider catalogs or built-in
|
application selects, alongside the default-on bundled OpenRouter inventory.
|
||||||
profile inventories; conventional backend ID constants are names only. Define
|
Conventional backend ID constants are names only. Define or replace the model,
|
||||||
the model, endpoint, credentials lookup, and settings that your application
|
endpoint, credentials lookup, and settings that your application
|
||||||
intends to support. A profile may inherit a sibling profile by its local key in
|
intends to support. A profile may inherit a sibling profile by its local key in
|
||||||
the same root or collection mapping; its backend reference uses the exact
|
the same root or collection list; its backend reference uses the exact
|
||||||
configured identity. Caller-selected source layers determine the final values. The
|
configured identity. Caller-selected source layers determine the final values. The
|
||||||
[format reference](../formats.md) describes the definition fields and
|
[format reference](../formats.md) describes the definition fields and
|
||||||
composition rules.
|
composition rules.
|
||||||
|
|
||||||
### Inspect A Profile Before Prompt Work
|
### Inspect A Profile Before Prompt Work
|
||||||
|
|
||||||
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
Use [`Engine.InspectProfile`](../../engine.go) to inspect a resolved profile
|
||||||
profile without constructing a synthetic prompt or placeholder inputs. It
|
without constructing a synthetic prompt or placeholder inputs. It reports the
|
||||||
resolves the profile's effective target but does not prepare or execute a
|
profile's own fields, not a backend-completed target:
|
||||||
prompt:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
inspection, err := engine.InspectProfile(ctx, profileID)
|
inspection, err := engine.InspectProfile(ctx, profileID)
|
||||||
@@ -673,23 +724,48 @@ if err != nil {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
target := inspection.EffectiveModelParams
|
if inspection.APIKeyRequired != nil && *inspection.APIKeyRequired {
|
||||||
if target.APIKeyEnv != "" {
|
// Arrange a usable direct or configured environment credential.
|
||||||
// This is a configured optional environment lookup source.
|
}
|
||||||
} else if inspection.APIKeyRequired {
|
if inspection.APIKeyEnv != nil {
|
||||||
// Arrange a direct credential before later execution.
|
// This is the configured environment lookup source, not its value.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use this configuration-time boundary when only the profile and its target need
|
Use [`InspectBackend`](../../engine.go) to inspect the referenced backend
|
||||||
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
separately. Its diagnostics are configured metadata, not live checks or derived
|
||||||
or rendering work; use prepared execution when that work must remain tied to a
|
destinations; `ErrBackendNotFound` identifies an absent backend. References in
|
||||||
later execution. A reported `APIKeyEnv` is a configured optional source, while
|
all three resource reports remain as authored. Use `Prepare` for completed
|
||||||
`APIKeyRequired` is the explicit local requirement. The
|
request settings and rendering, or prepared execution when that work must remain
|
||||||
|
tied to later execution. A reported `APIKeyEnv` identifies a configured source,
|
||||||
|
not its value; `APIKeyRequired` can also be present. The
|
||||||
[credential format reference](../formats.md#credentials) and the method's
|
[credential format reference](../formats.md#credentials) and the method's
|
||||||
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
|
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
|
||||||
contracts.
|
contracts.
|
||||||
|
|
||||||
|
Resource inspection pointers represent optional fields: nil is absent, while a
|
||||||
|
non-nil pointer preserves zero, false, or an empty string. Pointer-to-collection
|
||||||
|
fields distinguish omission from an empty map or list. Results and nested mutable
|
||||||
|
values belong to the caller. Ordinary generic encoders need no per-field adapter:
|
||||||
|
|
||||||
|
```go
|
||||||
|
jsonBytes, err := json.Marshal(inspection)
|
||||||
|
if err != nil { return err }
|
||||||
|
yamlBytes, err := yaml.Marshal(inspection) // go.yaml.in/yaml/v4
|
||||||
|
if err != nil { return err }
|
||||||
|
_, _ = jsonBytes, yamlBytes
|
||||||
|
```
|
||||||
|
|
||||||
|
For migration, replace `ProfileInspection.EffectiveModelParams` accesses with
|
||||||
|
the optional resource fields, and use `Prepare` or `PreparedExecution.Details`
|
||||||
|
where backend completion and request overrides are needed. Prompt inspection's
|
||||||
|
`DefaultProfileID`, `Inputs`, nested input metadata, and output fields now use
|
||||||
|
optional pointers where presence matters; check for nil before dereferencing.
|
||||||
|
Consumers such as CommandKit can render the results directly instead of copying
|
||||||
|
each field into a parallel report. See the result types' [GoDoc](../../resource_inspection.go)
|
||||||
|
for keys and ownership; resource inspection does not replace `InspectConfiguration`
|
||||||
|
provenance, `InspectRequirements`, or concrete-request `ValidateRequest`.
|
||||||
|
|
||||||
### Set A Per-Run Session And Reasoning
|
### Set A Per-Run Session And Reasoning
|
||||||
|
|
||||||
Supply a direct session ID when one prompt should be correlated with a
|
Supply a direct session ID when one prompt should be correlated with a
|
||||||
@@ -728,8 +804,8 @@ it and shared backend identity or capacity policy is unnecessary:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, model := "http://localhost:8000/v1", "example-model"
|
endpoint, model := "http://localhost:8000/v1", "example-model"
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Endpoint: &endpoint, Model: &model},
|
{ID: "local-summary", Endpoint: &endpoint, Model: &model},
|
||||||
})
|
})
|
||||||
// Add configkit.Input{Source: profile} to a caller underlay.
|
// Add configkit.Input{Source: profile} to a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -744,11 +820,13 @@ identity, endpoint, and concurrency limit:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
backendID, model := promptkit.BackendLocal, "example-model"
|
backendID, model := promptkit.BackendLocal, "example-model"
|
||||||
backend := promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{
|
localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2)
|
||||||
backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
localBackend.ID = backendID
|
||||||
|
backend := promptkit.BackendSource("local backend", []promptkit.BackendConfig{
|
||||||
|
localBackend,
|
||||||
})
|
})
|
||||||
profile := promptkit.ProfileSource("local profile", map[string]promptkit.ProfileConfig{
|
profile := promptkit.ProfileSource("local profile", []promptkit.ProfileConfig{
|
||||||
"local-summary": {Backend: &backendID, Model: &model},
|
{ID: "local-summary", Backend: &backendID, Model: &model},
|
||||||
})
|
})
|
||||||
// Add both sources as inputs in a caller underlay.
|
// Add both sources as inputs in a caller underlay.
|
||||||
```
|
```
|
||||||
@@ -759,13 +837,14 @@ capacity of 1024.
|
|||||||
|
|
||||||
#### Configure A Complete Backend
|
#### Configure A Complete Backend
|
||||||
|
|
||||||
Use a keyed `BackendConfig` value for authentication, extra request parameters, an
|
Use an identified `BackendConfig` value for authentication, extra request parameters, an
|
||||||
explicit queue capacity, a custom ID, or multiple local endpoints:
|
explicit queue capacity, a custom ID, or multiple local endpoints:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
endpoint, keyName, limit, noWaiting := "http://gpu-host:8000/v1", "LOCAL_GPU_API_KEY", 2, 0
|
||||||
backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendConfig{
|
backend := promptkit.BackendSource("GPU backend", []promptkit.BackendConfig{
|
||||||
"local-gpu": {
|
{
|
||||||
|
ID: "local-gpu",
|
||||||
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
Endpoint: &endpoint, APIKeyEnv: &keyName,
|
||||||
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
ConcurrencyLimit: &limit, QueueCapacity: &noWaiting,
|
||||||
ExtraParams: map[string]any{"provider_option": "enabled"},
|
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||||
@@ -774,7 +853,7 @@ backend := promptkit.BackendSource("GPU backend", map[string]promptkit.BackendCo
|
|||||||
```
|
```
|
||||||
|
|
||||||
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
Place `backend` in a caller underlay and select `"local-gpu"` from a
|
||||||
`ProfileConfig.Backend` pointer or a keyed file profile. All backend
|
`ProfileConfig.Backend` pointer or an identified file profile. All backend
|
||||||
contributions use the same ConfigKit layering rules, including overlays of
|
contributions use the same ConfigKit layering rules, including overlays of
|
||||||
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
maintained definitions. The [`BackendConfig` and `LocalBackend` GoDoc](../../backends.go)
|
||||||
defines exact omission and construction behavior.
|
defines exact omission and construction behavior.
|
||||||
@@ -804,7 +883,7 @@ backend := promptkit.BackendConfig{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The pointer distinguishes an explicit zero from omission. Use keyed
|
The pointer distinguishes an explicit zero from omission. Use identified
|
||||||
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
`BackendConfig` literals inside `BackendSource` for programmatic definitions.
|
||||||
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
||||||
profiles and custom backends without a configured limit remain unrestricted.
|
profiles and custom backends without a configured limit remain unrestricted.
|
||||||
@@ -836,6 +915,12 @@ secret-handling policies.
|
|||||||
|
|
||||||
## Extension Interfaces
|
## Extension Interfaces
|
||||||
|
|
||||||
|
JSON Schema validation and native structured output use a self-contained root
|
||||||
|
document. Internal references are supported; references to other files or
|
||||||
|
network resources fail during preparation. `WithSchemaFile` selects one root
|
||||||
|
file, while `WithSchemaFS` and `SchemaDir` select a root source. Prepared
|
||||||
|
executions retain their captured schema even if the source later changes.
|
||||||
|
|
||||||
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
||||||
behavior does not fit the application. Their GoDoc defines concurrent use,
|
behavior does not fit the application. Their GoDoc defines concurrent use,
|
||||||
context handling, ownership of copied values, nil responses, and preservation
|
context handling, ownership of copied values, nil responses, and preservation
|
||||||
@@ -843,6 +928,11 @@ of collaborator errors. Implementations must honor cancellation, safely manage
|
|||||||
copies they retain, avoid unsafe logging of content or credentials, and enforce
|
copies they retain, avoid unsafe logging of content or credentials, and enforce
|
||||||
the application policy that motivated the injection.
|
the application policy that motivated the injection.
|
||||||
|
|
||||||
|
A successful `ArtifactReader.Read` transfers the returned artifact and mutable
|
||||||
|
body storage to Promptkit. Readers that previously reused a returned buffer
|
||||||
|
must instead return independent storage and stop mutating it after return;
|
||||||
|
private scratch buffers may still be reused when the returned bytes are copied.
|
||||||
|
|
||||||
## Handle Errors
|
## Handle Errors
|
||||||
|
|
||||||
Use `errors.Is` with the
|
Use `errors.Is` with the
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ exist.
|
|||||||
|
|
||||||
## Maintainer Validation
|
## Maintainer Validation
|
||||||
|
|
||||||
|
Use the newest Go installation already available locally for ordinary
|
||||||
|
development checks. Put its `bin` directory first on `PATH` and set
|
||||||
|
`GOTOOLCHAIN=local` to keep that toolchain selected. For repository-isolated
|
||||||
|
checks, set `GOWORK=off`; consumers may use their own workspaces. The selected
|
||||||
|
validation patch does not by itself change the module's consumer minimum. See
|
||||||
|
the [release toolchain policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||||
|
and [release procedure](release.md#prepare-the-candidate) for release selection.
|
||||||
|
|
||||||
For ordinary work, select validation proportionate to the change. Do not repeat
|
For ordinary work, select validation proportionate to the change. Do not repeat
|
||||||
successful checks for unchanged content merely to create a commit. During
|
successful checks for unchanged content merely to create a commit. During
|
||||||
development, run relevant Go tests and vet, format changed Go files, and execute
|
development, run relevant Go tests and vet, format changed Go files, and execute
|
||||||
@@ -67,7 +75,6 @@ the maintained examples when they are affected:
|
|||||||
go test ./...
|
go test ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -w path/to/changed.go
|
gofmt -w path/to/changed.go
|
||||||
python3 scripts/check-examples.py
|
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,12 +84,12 @@ describes its requirements and recovery. For an optional diagnostic of a
|
|||||||
committed candidate without publication, run `./scripts/releasekit.sh check
|
committed candidate without publication, run `./scripts/releasekit.sh check
|
||||||
VERSION`; this is not a required pre-publication step.
|
VERSION`; this is not a required pre-publication step.
|
||||||
|
|
||||||
The standalone `python3 scripts/check-examples.py` command checks semantic
|
The example workflows are covered by the Go suite; use
|
||||||
expectations for the three offline consumer examples. The focused
|
`go test ./examples/go-library/...` to run them separately during development.
|
||||||
`python3 scripts/test-release.py` command exercises the release tooling against
|
The focused `python3 scripts/test-release.py` command exercises the release
|
||||||
disposable local repositories; use it when changing that tooling, not as a
|
tooling against disposable local repositories; use it when changing that
|
||||||
recurring release gate. Tests and examples require no provider credentials or
|
tooling, not as a recurring release gate. Tests and examples require no provider
|
||||||
live inference calls.
|
credentials or live inference calls.
|
||||||
|
|
||||||
Ordinary validation can run on intended uncommitted work. Release validation
|
Ordinary validation can run on intended uncommitted work. Release validation
|
||||||
uses committed candidate contents and the selected local Go installation.
|
uses committed candidate contents and the selected local Go installation.
|
||||||
|
|||||||
194
docs/formats.md
194
docs/formats.md
@@ -6,31 +6,28 @@ This is the canonical reference for Promptkit's backend, profile, prompt, and
|
|||||||
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
schema definitions. The [Go consumer guide](consumers/pkg-promptkit.md) shows
|
||||||
how to select sources and use an engine; the
|
how to select sources and use an engine; the
|
||||||
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
owns outbound wire behavior. [ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit)
|
owns outbound wire behavior. [ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md)
|
||||||
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
owns the generic YAML grammar, imports, layering, inheritance, and diagnostics.
|
||||||
This reference describes what Promptkit does with the final effective values.
|
This reference describes what Promptkit does with the final effective values.
|
||||||
|
|
||||||
A configuration document may contain keyed top-level `backends`, `profiles`,
|
A configuration document may contain top-level `backends`, `profiles`, and
|
||||||
and `prompts` mappings, plus a `collections` mapping of namespaced resource
|
`prompts` lists, plus a `collections` mapping of namespaced resource groups.
|
||||||
groups. Resource keys are literal IDs, not filename-derived identities. Each
|
Each resource has an explicit literal `id`, not a filename-derived identity.
|
||||||
prompt ID maps directly to one definition; an optional `version` string is
|
IDs and references must be nonblank and remain exact: Promptkit does not trim or
|
||||||
descriptive metadata, not a selection key. Keys and references must be nonblank
|
re-key them. Unknown fields are errors. Promptkit installs a default-on bundled
|
||||||
and remain exact: Promptkit does not trim or re-key them. Old embedded `id` and
|
OpenRouter underlay; callers still explicitly select a profile.
|
||||||
`base_profile` fields are invalid. Unknown fields are errors. Promptkit installs
|
|
||||||
no provider or profile catalog; callers select the definitions they need.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
local:
|
- id: local
|
||||||
endpoint: http://localhost:8000/v1
|
endpoint: http://localhost:8000/v1
|
||||||
concurrency_limit: 2
|
concurrency_limit: 2
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -42,17 +39,17 @@ collections:
|
|||||||
support:
|
support:
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
prompts:
|
prompts:
|
||||||
answer:
|
- id: answer
|
||||||
default_profile: support/fast
|
default_profile: support/fast
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
@@ -66,31 +63,34 @@ collections:
|
|||||||
|
|
||||||
An input may omit a source header. When present, the source-root `version` is
|
An input may omit a source header. When present, the source-root `version` is
|
||||||
reserved for ConfigKit's specification header and accepts only the exact string
|
reserved for ConfigKit's specification header and accepts only the exact string
|
||||||
`"1"` or strict decimal integer `1`; null and other values are invalid. This
|
`"2"` or strict decimal integer `2`; null and other values are invalid. This
|
||||||
header is distinct from a prompt's nested `version` label and a collection's
|
header is distinct from a collection's `release_version` and ConfigKit's own
|
||||||
`release_version`. ConfigKit checks and consumes accepted headers on non-main
|
software version. ConfigKit checks and consumes accepted headers on non-main
|
||||||
inputs; a declaration on the main input remains visible in explicit
|
inputs; a declaration on the main input remains visible in explicit
|
||||||
configuration inspection. Headerless inputs still undergo ordinary final-value
|
configuration inspection. Headerless inputs still undergo ordinary final-value
|
||||||
validation. ConfigKit owns generic import and source-history behavior.
|
validation. ConfigKit owns generic import and source-history behavior.
|
||||||
|
For a standalone prompt item, a root `version` is only a source header, not
|
||||||
|
prompt metadata.
|
||||||
|
|
||||||
The root resource mappings contain unqualified IDs. Each `collections` entry
|
The root resource lists contain unqualified IDs. Each `collections` entry
|
||||||
has a namespace key and may contain `release_version` plus the same three
|
has a namespace key and may contain `release_version` plus the same three
|
||||||
resource mappings. Collection resource keys are local keys; their runtime IDs
|
resource lists. Collection resource IDs are local keys; their runtime IDs
|
||||||
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
are `<namespace>/<local-key>`. Root and collection keys may be the same. A
|
||||||
namespace and local key must be nonblank and slash-free, and references must
|
namespace and local key must be nonblank and slash-free, and references must
|
||||||
name their intended exact ID; mounting a source does not rewrite references.
|
name their intended exact ID; mounting a source does not rewrite references.
|
||||||
For example, the `support/fast` profile above stores `support/offline` as its
|
For example, the `support/fast` profile above stores `support/offline` as its
|
||||||
backend ID. In `collections.support.profiles.fast`, ConfigKit `extends: base`
|
backend ID. `extends: {id: base}` refers to a sibling in the same resource
|
||||||
refers to a sibling profile in that same mapping. It is not a qualified or
|
list. It is not a qualified or cross-collection reference.
|
||||||
cross-collection reference.
|
|
||||||
|
|
||||||
`release_version` and prompt `version` are optional opaque labels. Absent or
|
`release_version` is an optional opaque collection label. Absent or null means
|
||||||
null means no label; an explicitly empty string is retained as a declared
|
no label; an explicitly empty string is retained. It does not select a resource
|
||||||
label. Neither label selects a resource or changes execution. Runtime lookups
|
or change execution. Runtime lookups
|
||||||
use the captured effective resource indexes. `InspectConfiguration` is an
|
use the captured effective resource indexes. `InspectConfiguration` is an
|
||||||
explicit operator view of effective settings and provenance; source history is
|
explicit operator view of resolved settings and provenance. Selected field
|
||||||
not a second runtime resolver, and the retained effective shape is not flattened
|
normalizations and the derived queue default are recorded in that tree, with
|
||||||
or rewritten into those indexes.
|
source history retained. Request-time completion remains separate. Source
|
||||||
|
history is not a second runtime resolver, and the
|
||||||
|
retained effective shape is not flattened or rewritten into those indexes.
|
||||||
|
|
||||||
For examples of explicitly selecting native collection inputs and inspecting
|
For examples of explicitly selecting native collection inputs and inspecting
|
||||||
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
inventories, see the [consumer guide](consumers/pkg-promptkit.md) and its
|
||||||
@@ -110,18 +110,17 @@ and static template syntax at construction. It does not reread configuration or
|
|||||||
message files on ordinary runs; construct a new engine to observe edits.
|
message files on ordinary runs; construct a new engine to observe edits.
|
||||||
Schema and request artifact reads still happen during preparation. Use
|
Schema and request artifact reads still happen during preparation. Use
|
||||||
`InspectConfiguration` for an explicit effective-configuration or provenance
|
`InspectConfiguration` for an explicit effective-configuration or provenance
|
||||||
report, `InspectProfile` for backend-completed profile settings, and preparation
|
report, resource inspection for resolved prompt, profile, and backend fields,
|
||||||
for request-specific rendered details. For identity discovery before inspection,
|
and preparation for completed request-specific details. For identity discovery before inspection,
|
||||||
see the consumer guide's [resource and collection listing](consumers/pkg-promptkit.md#discover-available-resources-and-collections).
|
see the consumer guide's [resource and collection listing](consumers/pkg-promptkit.md#discover-available-resources-and-collections).
|
||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
The `prompts` mapping selects a literal prompt ID whose value is the definition:
|
The `prompts` list contains definitions selected by their literal IDs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
prompts:
|
prompts:
|
||||||
meeting.summary:
|
- id: meeting.summary
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: local-summary
|
default_profile: local-summary
|
||||||
description: Summarize a synthetic meeting note.
|
description: Summarize a synthetic meeting note.
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
@@ -237,7 +236,8 @@ It may contain `default_profile`, `reachability`, and `authentication`:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backends:
|
backends:
|
||||||
openrouter:
|
- id: openrouter
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
smoke_tests:
|
smoke_tests:
|
||||||
default_profile: application-profile
|
default_profile: application-profile
|
||||||
reachability:
|
reachability:
|
||||||
@@ -266,21 +266,22 @@ These values are configuration only: engine construction does not contact a
|
|||||||
backend.
|
backend.
|
||||||
|
|
||||||
These fields are resolved through the ordinary ConfigKit source layers before
|
These fields are resolved through the ordinary ConfigKit source layers before
|
||||||
Promptkit validates the final effective values. A higher-priority mapping can
|
Promptkit validates the final effective values. Sibling inheritance can overlay
|
||||||
override individual nested fields; an explicit null can clear the whole
|
individual nested fields; a later same-ID resource replaces the whole record.
|
||||||
`smoke_tests` block or one of its nested objects. An endpoint set to null uses
|
An explicit null can clear the whole `smoke_tests` block or one of its nested
|
||||||
|
objects. An endpoint set to null uses
|
||||||
its documented fallback, while an explicit blank endpoint is invalid. The
|
its documented fallback, while an explicit blank endpoint is invalid. The
|
||||||
metadata does not change ordinary generation credential requirements or
|
metadata does not change ordinary generation credential requirements or
|
||||||
provider settings.
|
provider settings.
|
||||||
|
|
||||||
## Profile Definitions
|
## Profile Definitions
|
||||||
|
|
||||||
Profiles are keyed by literal ID and may inherit a named parent with
|
Profiles have literal IDs and may inherit a sibling parent with
|
||||||
`extends`:
|
`extends`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
profiles:
|
profiles:
|
||||||
local-summary:
|
- id: local-summary
|
||||||
backend: local
|
backend: local
|
||||||
model: example-model
|
model: example-model
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
@@ -288,8 +289,8 @@ profiles:
|
|||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
extra_params:
|
extra_params:
|
||||||
provider_option: enabled
|
provider_option: enabled
|
||||||
local-summary-fast:
|
- id: local-summary-fast
|
||||||
extends: local-summary
|
extends: {id: local-summary}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
reasoning_effort: low
|
reasoning_effort: low
|
||||||
```
|
```
|
||||||
@@ -314,26 +315,91 @@ only a model or backend does not clear unrelated explicitly inherited fields.
|
|||||||
| Profile field | Absent | Explicit value | Explicit null |
|
| Profile field | Absent | Explicit value | Explicit null |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `backend` | No backend | Exact backend ID; blank means none | No backend |
|
| `backend` | No backend | Exact backend ID; blank means none | No backend |
|
||||||
| `endpoint` | Selected backend endpoint | Valid nonblank URL; empty clears | Clear, without backend fallback |
|
| `endpoint` | Selected backend endpoint | Valid nonblank URL; empty clears | Selected backend endpoint |
|
||||||
| `model` | Unspecified | Nonblank model; empty clears | Unspecified |
|
| `model` | Unspecified | Nonblank model; empty clears | Unspecified |
|
||||||
| `temperature`, `top_p`, `max_tokens` | Unspecified | Valid value, including outbound zero | Suppress outbound control |
|
| `temperature`, `top_p`, `max_tokens` | Unspecified | Valid value, including outbound zero | Suppress outbound control |
|
||||||
| `service_tier`, `reasoning_effort` | Unspecified | Normalized value; blank suppresses | Suppress outbound control |
|
| `service_tier`, `reasoning_effort` | Unspecified | Normalized value; blank suppresses | Suppress outbound control |
|
||||||
| `timeout_seconds` | 600 seconds | Nonnegative; zero disables generation deadline | Reset to 600 seconds |
|
| `timeout_seconds` | 600 seconds | Nonnegative; zero disables generation deadline | Reset to 600 seconds |
|
||||||
| `api_key_env` | Backend lookup name, if any | Trimmed name; blank disables | Disable backend lookup |
|
| `api_key_env` | Backend lookup name, if any | Trimmed name; blank disables | Backend lookup name, if any |
|
||||||
| `api_key_required` | False | Boolean, including false | False |
|
| `api_key_required` | False | Boolean, including false | False |
|
||||||
| `extra_params` | Backend defaults | Recursively overlay profile map on backend map | Suppress configured provider map |
|
| `extra_params` | Backend defaults | Recursively overlay profile map on backend map | Backend defaults |
|
||||||
|
|
||||||
|
An empty profile mapping does not clear backend map entries. After ConfigKit
|
||||||
|
composition and inheritance, a whole-map null and omission both permit backend
|
||||||
|
defaults. Nested maps merge recursively, while scalar and array values replace;
|
||||||
|
nulls within provider maps or arrays remain literal data.
|
||||||
|
When `api_key_required` is true and the profile source is absent or null,
|
||||||
|
execution suppresses the backend credential source; an explicitly configured
|
||||||
|
profile source remains in use.
|
||||||
|
|
||||||
|
For example, if the backend provides an endpoint, API-key environment name,
|
||||||
|
and provider map, a profile containing `endpoint: null`, `api_key_env: null`,
|
||||||
|
and `extra_params: null` now inherits those backend values after handoff (unless
|
||||||
|
the required-key rule suppresses the credential source). Earlier versions used
|
||||||
|
those nulls to suppress the corresponding fallback. Use `endpoint: ""` or
|
||||||
|
`api_key_env: ""` to clear those individual strings. There is no profile-level
|
||||||
|
whole-map suppression replacement; change the backend map through ordinary
|
||||||
|
ConfigKit composition when different provider defaults are needed. ConfigKit's
|
||||||
|
retained configuration and provenance continue to show the nulls.
|
||||||
|
|
||||||
An empty mapping does not clear inherited map entries; null clears the whole
|
|
||||||
map. Nested maps merge recursively, while scalar and array values replace.
|
|
||||||
Runtime nonempty `ExtraParams` overrides replace the entire configured map.
|
Runtime nonempty `ExtraParams` overrides replace the entire configured map.
|
||||||
Request overrides otherwise follow the [public value GoDoc](../types.go).
|
Request overrides otherwise follow the [public value GoDoc](../types.go).
|
||||||
For example, a runtime `extra_params["extends"]` key is ordinary provider
|
For example, a runtime `extra_params["extends"]` key is ordinary provider
|
||||||
data, not a configuration inheritance instruction. An explicit zero timeout
|
data, not a configuration inheritance instruction. An explicit zero timeout
|
||||||
does not disable the caller context or HTTP transport timeout.
|
does not disable the caller context or HTTP transport timeout.
|
||||||
|
|
||||||
|
### Bundled OpenRouter Configuration
|
||||||
|
|
||||||
|
The default bundle installs root backend `openrouter`, endpoint
|
||||||
|
`https://openrouter.ai/api/v1`, optional credential environment name
|
||||||
|
`OPENROUTER_API_KEY`, and smoke metadata selecting `gpt-luna-latest` with the
|
||||||
|
`openrouter_key` authentication adapter. Existing diagnostic endpoint fallback
|
||||||
|
applies. No prompt, implicit profile selection, or tuning defaults are installed.
|
||||||
|
Disable the entire bundle with `WithBundledOpenRouter(false)`.
|
||||||
|
|
||||||
|
The root profile IDs and their models are:
|
||||||
|
|
||||||
|
| Profile | Model |
|
||||||
|
| --- | --- |
|
||||||
|
| `gpt-astra-latest` | `~openai/gpt-astra-latest` |
|
||||||
|
| `gpt-sol-latest` | `~openai/gpt-sol-latest` |
|
||||||
|
| `gpt-terra-latest` | `~openai/gpt-terra-latest` |
|
||||||
|
| `gpt-luna-latest` | `~openai/gpt-luna-latest` |
|
||||||
|
| `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
||||||
|
| `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
||||||
|
| `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
||||||
|
| `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
||||||
|
| `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
||||||
|
| `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
||||||
|
| `auto` | `openrouter/auto` |
|
||||||
|
| `free` | `openrouter/free` |
|
||||||
|
|
||||||
|
The profiles `auto-low`, `auto-medium`, `auto-high`, `auto-xhigh`, and
|
||||||
|
`auto-max` extend `auto` and add an `auto-router` plugin with the corresponding
|
||||||
|
`cost_tier`. Replacing `auto` changes their inherited backend and model while
|
||||||
|
retaining their plugins. These are root IDs even when a model contains `/`.
|
||||||
|
For example, a caller can replace `auto` as a complete record and extend another
|
||||||
|
bundled profile under a new ID:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
- id: auto
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/free
|
||||||
|
- id: my-luna
|
||||||
|
extends: {id: gpt-luna-latest}
|
||||||
|
timeout_seconds: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
See OpenRouter's [alias examples](https://openrouter.ai/~openai/gpt-luna-latest),
|
||||||
|
[auto router and cost tiers](https://openrouter.ai/docs/guides/routing/routers/auto-router),
|
||||||
|
and [free router](https://openrouter.ai/docs/guides/routing/routers/free-router)
|
||||||
|
for provider behavior and availability.
|
||||||
|
|
||||||
### Source And Profile Precedence
|
### Source And Profile Precedence
|
||||||
|
|
||||||
`NewEngine` composes caller underlays in their given order, followed by an
|
`NewEngine` composes the bundled OpenRouter underlay first by default, then
|
||||||
|
caller underlays in their given order, followed by an
|
||||||
optional main input. Later layers overlay earlier ones. Inputs within one
|
optional main input. Later layers overlay earlier ones. Inputs within one
|
||||||
underlay group are strictly composed: conflicting contributions are errors,
|
underlay group are strictly composed: conflicting contributions are errors,
|
||||||
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
not priority signals. The directory helpers discover `.yaml` and `.yml` files recursively
|
||||||
@@ -343,34 +409,42 @@ nothing. Imports are disabled by these helpers unless the caller enables them
|
|||||||
on an input. Single files can use ConfigKit inputs directly.
|
on an input. Single files can use ConfigKit inputs directly.
|
||||||
|
|
||||||
Root backends, profiles, and prompts, as well as collection declarations and
|
Root backends, profiles, and prompts, as well as collection declarations and
|
||||||
their resource mappings, follow these same source rules. `CollectionInput`
|
their resource lists, follow these same source rules. `CollectionInput`
|
||||||
mounts one explicitly selected source at a collection destination and enables
|
mounts one explicitly selected source at a collection destination and enables
|
||||||
ConfigKit imports for that input; it does not discover or select sources.
|
ConfigKit imports for that input; it does not discover or select sources.
|
||||||
A later profile or backend can change individual fields of a lower definition.
|
A later same-ID record replaces the lower definition in full. Use a distinct
|
||||||
|
inherited sibling when a variant should retain its parent's fields.
|
||||||
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
A request `ProfileID` takes precedence over a prompt `default_profile`; if
|
||||||
neither is selected, preparation fails. Exact profile inspection selects one
|
neither is selected, preparation fails. Exact profile inspection selects one
|
||||||
explicit ID and does not consult a prompt default.
|
explicit ID and does not consult a prompt default.
|
||||||
|
|
||||||
### Profile Inheritance
|
### Resource Inheritance
|
||||||
|
|
||||||
ConfigKit resolves `extends` on effective profile definitions. One profile
|
ConfigKit resolves `extends: {id: parent}` for backends, profiles, and prompts.
|
||||||
may inherit a sibling profile by local key in the same root or collection
|
Each child inherits a surviving sibling in the same root or collection list;
|
||||||
mapping; collection inheritance cannot cross namespaces. Presence, explicit
|
collection inheritance cannot cross namespaces. Parents are ordinary validated
|
||||||
|
resources, and profiles may remain partial until request completion. Presence, explicit
|
||||||
null, and recursively merged maps determine the final fields. Other resource
|
null, and recursively merged maps determine the final fields. Other resource
|
||||||
references are exact IDs and use their qualified form when they select a
|
references are exact IDs and use their qualified form when they select a
|
||||||
collection resource. There is no Promptkit-specific depth limit or second
|
collection resource. There is no Promptkit-specific depth limit or second
|
||||||
base-chain resolver. Missing references and cycles fail at construction. For
|
base-chain resolver. Missing references and cycles fail at construction. For
|
||||||
generic inheritance and layer mechanics, consult
|
generic inheritance and layer mechanics, consult
|
||||||
[ConfigKit](https://gitea.maximumdirect.net/eric/ConfigKit).
|
[ConfigKit](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md).
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
Schemas are JSON documents selected by a prompt or request `schema_path`.
|
Schemas are JSON documents selected by a prompt or request `schema_path`.
|
||||||
Directory and `fs.FS` schema paths resolve within the configured root;
|
Directory and `fs.FS` schema paths resolve within the configured root. Each
|
||||||
referenced nested schemas resolve relative to the owning schema document.
|
schema must be self-contained: in-document references, IDs, and anchors are
|
||||||
|
supported, but references requiring another file or network resource fail.
|
||||||
|
References must also work without the source filename as a base; use fragment
|
||||||
|
references or explicit schema IDs rather than relying on that filename.
|
||||||
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
||||||
invalid, or unresolvable schema causes an operational validation error.
|
invalid, or unresolvable schema causes an operational validation error.
|
||||||
Valid JSON that fails the schema instead yields a failed validation result.
|
Valid JSON that fails the schema instead yields a failed validation result.
|
||||||
|
Schema documents are detached without applying the provider extra-parameter
|
||||||
|
copy limits; this does not change the schema decoder, compiler, or
|
||||||
|
self-contained-resource rules.
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
|||||||
Before the client is called, the engine resolves framework, backend, profile,
|
Before the client is called, the engine resolves framework, backend, profile,
|
||||||
and request values into one execution target. Endpoint configuration is trimmed
|
and request values into one execution target. Endpoint configuration is trimmed
|
||||||
and must be an absolute HTTP or HTTPS URL with a host and without user
|
and must be an absolute HTTP or HTTPS URL with a host and without user
|
||||||
information, a query, or a fragment. A non-empty endpoint from the target
|
information, a query, or a fragment. The target's endpoint is validated again
|
||||||
overrides the client's configured base URL. The final selected endpoint is
|
before transport.
|
||||||
validated again before transport.
|
|
||||||
|
|
||||||
The completion URL is composed through parsed URL path operations. Nested base
|
The completion URL is composed through parsed URL path operations. Nested base
|
||||||
paths are retained, repeated trailing slashes are normalized, and the result
|
paths are retained, repeated trailing slashes are normalized, and the result
|
||||||
has exactly one appended `/chat/completions` suffix. Generation fails before
|
has exactly one appended `/chat/completions` suffix. Generation fails before
|
||||||
sending when neither source supplies a valid endpoint.
|
sending when the target does not supply a valid endpoint.
|
||||||
|
|
||||||
The target's backend ID is routing metadata for prepared values, results, and
|
The target's backend ID is routing metadata for prepared values, results, and
|
||||||
injected clients. The built-in client does not derive the URL from that ID and
|
injected clients. The built-in client does not derive the URL from that ID and
|
||||||
@@ -70,7 +69,9 @@ and numeric `usage`; unrelated fields and account limits are ignored. This is
|
|||||||
only a minimal identifying subset of OpenRouter's documented
|
only a minimal identifying subset of OpenRouter's documented
|
||||||
[current-key response](https://github.com/OpenRouterTeam/docs/blob/main/api_reference/limits.mdx#checking-your-limits),
|
[current-key response](https://github.com/OpenRouterTeam/docs/blob/main/api_reference/limits.mdx#checking-your-limits),
|
||||||
not validation of the full account document. Other statuses and unrecognized
|
not validation of the full account document. Other statuses and unrecognized
|
||||||
200 responses do not establish acceptance.
|
200 responses do not establish acceptance. A 200 response exceeding the bounded
|
||||||
|
recognition allowance reports `authentication_response_limit_exceeded`, leaving
|
||||||
|
credential acceptance unknown; see the [public reason](../../smoke_diagnostics.go).
|
||||||
|
|
||||||
Diagnostic HTTP evidence describes the last request observed at Promptkit's
|
Diagnostic HTTP evidence describes the last request observed at Promptkit's
|
||||||
RoundTripper boundary and any response associated with it. It includes the
|
RoundTripper boundary and any response associated with it. It includes the
|
||||||
@@ -87,8 +88,7 @@ transport for HTTP-only diagnostics.
|
|||||||
## Request Body
|
## Request Body
|
||||||
|
|
||||||
The request body always contains `model` and `messages`. The execution
|
The request body always contains `model` and `messages`. The execution
|
||||||
target's model takes precedence over the client's configured model, and one
|
target's model must be available.
|
||||||
must be available.
|
|
||||||
|
|
||||||
Each ordinary message contains its `role` and string `content`. A
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
cache-controlled message instead uses a text content block containing `type`,
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
@@ -115,9 +115,10 @@ The client conditionally includes:
|
|||||||
strict flag, and schema document. Plain JSON validation does not add an
|
strict flag, and schema document. Plain JSON validation does not add an
|
||||||
object-only response constraint.
|
object-only response constraint.
|
||||||
|
|
||||||
The engine resolves backend, profile, and request extra-parameter maps by
|
Configured profile maps recursively overlay backend defaults, while a nonempty
|
||||||
whole-map replacement rather than key merging. The resulting effective map is
|
runtime override replaces the complete configured map. The [format reference](../formats.md#defaults-and-overrides)
|
||||||
merged into the top-level body. Empty keys, values that cannot be encoded, and
|
owns these resolution rules. The resulting effective map is merged into the
|
||||||
|
top-level body. Empty keys, values that cannot be encoded, and
|
||||||
collisions with these reserved fields are rejected before any provider call:
|
collisions with these reserved fields are rejected before any provider call:
|
||||||
|
|
||||||
- `model`
|
- `model`
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ Preparation and validation never hold an active permit.
|
|||||||
|
|
||||||
The manager's `Acquire` operation owns that active-permit acquisition and
|
The manager's `Acquire` operation owns that active-permit acquisition and
|
||||||
returns an idempotent release function. The coordinator uses it around
|
returns an idempotent release function. The coordinator uses it around
|
||||||
individual attempts. The earlier one-shot wrapper still delegates to the same
|
individual attempts. There is one queue and one active limit per limited backend
|
||||||
operation for focused internal callers and tests, but engine assembly no
|
pool.
|
||||||
longer uses it. There is one queue and one active limit per limited backend pool.
|
|
||||||
|
|
||||||
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
||||||
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
||||||
@@ -121,7 +120,7 @@ validation, bounded admission, idempotent release, context handling, and
|
|||||||
unlimited admission. The
|
unlimited admission. The
|
||||||
[client tests](../../internal/capacity/client_test.go) own peak enforcement,
|
[client tests](../../internal/capacity/client_test.go) own peak enforcement,
|
||||||
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
||||||
unlimited calls, passthrough behavior, and panic release.
|
unlimited calls, and panic release using test-only one-shot calls.
|
||||||
|
|
||||||
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
||||||
admission, lease lifetime, failure release, and shared initial/repair
|
admission, lease lifetime, failure release, and shared initial/repair
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ client uses its endpoint, credential metadata, generation fields, and extra
|
|||||||
parameters. `BackendID` remains routing metadata
|
parameters. `BackendID` remains routing metadata
|
||||||
for the generation boundary and is not mapped into the provider payload.
|
for the generation boundary and is not mapped into the provider payload.
|
||||||
|
|
||||||
Construction trims and validates a nonempty configured base URL and clones any
|
Construction clones any supplied `http.Client` so Promptkit can apply its
|
||||||
supplied `http.Client` so Promptkit can apply its timeout default without
|
timeout default without mutating the caller's client. Generation preparation
|
||||||
mutating the caller's client. An empty configured base remains valid because a
|
then:
|
||||||
resolved request target may supply the endpoint. Generation preparation then:
|
|
||||||
|
|
||||||
1. validates shared execution-setting invariants and the final selected base
|
1. validates shared execution-setting invariants and the final selected base
|
||||||
endpoint;
|
endpoint;
|
||||||
@@ -42,6 +41,10 @@ resolved request target may supply the endpoint. Generation preparation then:
|
|||||||
the complete-request failure remains a request failure;
|
the complete-request failure remains a request failure;
|
||||||
6. resolves authentication and freezes the URL, body, and optional credential.
|
6. resolves authentication and freezes the URL, body, and optional credential.
|
||||||
|
|
||||||
|
Provider values are detached with trusted copying before encoding. Custom JSON
|
||||||
|
encoding methods and per-value encoding-failure diagnostics operate on those
|
||||||
|
private copies rather than retained prepared or diagnostic metadata.
|
||||||
|
|
||||||
Each execution of that prepared request creates a fresh HTTP request, body
|
Each execution of that prepared request creates a fresh HTTP request, body
|
||||||
reader, and attempt timeout context. It closes the response body and returns
|
reader, and attempt timeout context. It closes the response body and returns
|
||||||
the HTTP status, decoded response or error, reported token values and presence,
|
the HTTP status, decoded response or error, reported token values and presence,
|
||||||
@@ -77,7 +80,8 @@ not retain headers, credentials, raw request or response bodies, or a redirect
|
|||||||
trace. These are observations at Promptkit's RoundTripper boundary, not proof
|
trace. These are observations at Promptkit's RoundTripper boundary, not proof
|
||||||
that bytes reached a peer or that a custom transport did no hidden dispatch.
|
that bytes reached a peer or that a custom transport did no hidden dispatch.
|
||||||
Reachability and named authentication probes use the same configured built-in
|
Reachability and named authentication probes use the same configured built-in
|
||||||
client but discard their response bodies after local recognition. The
|
client. The OpenRouter adapter owns endpoint derivation, bounded account-response
|
||||||
|
recognition, and body closure; the smoke coordinator interprets its evidence. The
|
||||||
[integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics)
|
[integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics)
|
||||||
owns their observable HTTP semantics.
|
owns their observable HTTP semantics.
|
||||||
|
|
||||||
@@ -105,14 +109,13 @@ public ownership and redaction semantics belong to the
|
|||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
|
|
||||||
The package preserves distinct error identities for invalid client
|
The package preserves distinct error identities for invalid generation
|
||||||
configuration, invalid generation requests, request execution failures,
|
requests, request execution failures, non-success provider statuses, and
|
||||||
non-success provider statuses, and malformed successful responses. Provider
|
malformed successful responses. Provider response bodies are never exposed in
|
||||||
response bodies are never exposed in raw form through non-success errors.
|
raw form through non-success errors.
|
||||||
|
|
||||||
Invalid nonempty configured endpoints are configuration failures. A missing or
|
A missing or invalid final selected endpoint is an invalid generation request
|
||||||
invalid final selected endpoint is an invalid generation request and is
|
and is rejected before transport.
|
||||||
rejected before transport.
|
|
||||||
|
|
||||||
Authentication resolves a trimmed direct key before a trimmed configured
|
Authentication resolves a trimmed direct key before a trimmed configured
|
||||||
environment value. Optional missing, empty, or whitespace-only sources do not
|
environment value. Optional missing, empty, or whitespace-only sources do not
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ lifecycle instrumentation spans the facade and runner boundaries.
|
|||||||
Prepared execution starts only after its private payload is atomically claimed;
|
Prepared execution starts only after its private payload is atomically claimed;
|
||||||
the facade receives that invocation's recorder privately and emits the terminal
|
the facade receives that invocation's recorder privately and emits the terminal
|
||||||
event after public error mapping. Failed claims and preparation emit nothing.
|
event after public error mapping. Failed claims and preparation emit nothing.
|
||||||
|
The public projection retains private operation-owner metadata separately from
|
||||||
|
inspectable child causes. Terminal classification uses that owner, preserving
|
||||||
|
context precedence and generation-specific transport/provider details rather
|
||||||
|
than mistaking a collaborator's nested lookup error for the parent's operation.
|
||||||
|
|
||||||
The facade masks inherited recorder and prepared-claim state when entering
|
The facade masks inherited recorder and prepared-claim state when entering
|
||||||
preparation or execution. Delegated calls from an injected collaborator retain
|
preparation or execution. Delegated calls from an injected collaborator retain
|
||||||
@@ -25,8 +29,7 @@ other content. Events are values with no mutable containers. Callback panics
|
|||||||
propagate to the caller.
|
propagate to the caller.
|
||||||
Resolved prompt and profile IDs are the exact runtime identities selected for
|
Resolved prompt and profile IDs are the exact runtime identities selected for
|
||||||
the invocation, including namespace qualification when supplied by an engine
|
the invocation, including namespace qualification when supplied by an engine
|
||||||
view. A prompt's optional version label is descriptive metadata, not a
|
view.
|
||||||
selection key.
|
|
||||||
Per-operation callbacks are ordered and synchronous; different operations may
|
Per-operation callbacks are ordered and synchronous; different operations may
|
||||||
invoke the same callback concurrently. The recorder marks each measured phase's
|
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||||
end before delivery, so callback work does not inflate provider duration.
|
end before delivery, so callback work does not inflate provider duration.
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 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) |
|
| Root `promptkit` package | Provides the supported engine facade, ConfigKit source helpers, collection and resource mapping conveniences, injection options, public requests and results, resource and 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/prepare` | Demonstrates offline preparation from two explicitly selected synthetic collections, source-relative imported content, a scoped view, filtered listings, and operator inventory. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go), [collection inputs](../../examples/go-library/prepare/collections/support/collection.yaml) |
|
||||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||||
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
|
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
|
||||||
| `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
|
| `internal/backend` | Holds a defensive, immutable lookup of final backend definitions, including diagnostic metadata, plus capacity policies validated by the configuration adapter. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
| `internal/configuration` | Loads root and namespaced collection resources through ConfigKit; records effective field presence, validates final values including exact references and optional diagnostic metadata, captures indexed prompt definitions and source-relative message content, compiled templates, input metadata snapshots, sorted backend/profile/prompt identities, resource namespace sets, effective collection declarations, backend and presence-aware profile snapshots, and the unchanged read-only inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
|
| `internal/configuration` | Loads root and namespaced resources through ConfigKit; validates final values, captures indexed resource views and source-relative prompt content, compiled templates, requirement metadata, identity inventories, backend and presence-aware execution profiles, and the read-only provenance inspection view. | [Typed loader](../../internal/configuration/load.go), [identity listing](../../internal/configuration/listing.go), [internal sources](sources.md#typed-configuration-loading) |
|
||||||
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, requirement inspection, profiles, execution targets, rendering, generation, 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/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/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 resource inspection and identity listing directly to the captured configuration 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/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible provider values, and detaches previously accepted values without repeating validation. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||||
| `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
|
| `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) |
|
||||||
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
|
| `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) |
|
||||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) |
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) |
|
||||||
|
|||||||
@@ -36,21 +36,19 @@ The internal engine applies each namespace view to slashless caller selections
|
|||||||
before they reach the runner. The runner therefore selects captured prompts and
|
before they reach the runner. The runner therefore selects captured prompts and
|
||||||
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
profiles by the resulting exact runtime IDs. Already-qualified selections pass
|
||||||
through, while configured defaults and stored references are used as authored
|
through, while configured defaults and stored references are used as authored
|
||||||
and are not requalified. Prompt version labels are returned as optional
|
and are not requalified.
|
||||||
descriptive metadata; they are not prompt selectors.
|
|
||||||
|
|
||||||
Ordinary preparation selects the full prompt definition and construction-time
|
Ordinary preparation selects the full prompt definition and construction-time
|
||||||
hash. It retains its early request-ID check before direct-session
|
hash. It retains its early request-ID check before direct-session
|
||||||
normalization. Referenced message content and static template syntax were
|
normalization. Referenced message content and static template syntax were
|
||||||
captured when the engine was built.
|
captured when the engine was built.
|
||||||
|
Rendering uses those captured parses, cloning them per call to bind request
|
||||||
|
input helpers; it does not parse templates during execution.
|
||||||
|
|
||||||
`InspectPrompt` selects captured metadata through an optional repository
|
Resource inspection bypasses the runner: the internal engine selects a captured
|
||||||
capability, falling back to the full definition and existing hash resolution
|
configuration resource, and the facade detaches its values for the caller.
|
||||||
for internal repositories without it. It returns owned metadata without
|
Prompt resource inspection includes captured message text without rendering or
|
||||||
rendering templates or touching profile, artifact, schema, renderer, validator,
|
touching profile, artifact, schema, admission, or model collaborators.
|
||||||
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
|
`InspectRequirements` uses the same metadata selection to resolve the
|
||||||
request-profile/default identity, then obtains the captured requirement
|
request-profile/default identity, then obtains the captured requirement
|
||||||
@@ -58,24 +56,18 @@ snapshot by the selected prompt ID. The analyzer receives only
|
|||||||
those identity fields rather than an execution definition. It analyzes the
|
those identity fields rather than an execution definition. It analyzes the
|
||||||
snapshot with the supplied caller variables and normalized direct-session
|
snapshot with the supplied caller variables and normalized direct-session
|
||||||
bypass; it does not resolve target readiness or enter the preparation
|
bypass; it does not resolve target readiness or enter the preparation
|
||||||
pipeline. Missing snapshot capability is a prompt-load failure rather than a
|
pipeline. An unavailable captured snapshot is a prompt-load failure. The root
|
||||||
source fallback. The root
|
|
||||||
[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public
|
[`Engine.InspectRequirements`](../../requirements.go) GoDoc owns the public
|
||||||
operation's exact contract.
|
operation's exact contract.
|
||||||
|
|
||||||
## Shared Profile Selection
|
## Shared Profile Selection
|
||||||
|
|
||||||
The runner uses one profile-selection and target-resolution boundary for
|
The runner selects a request profile or a prompt default and uses its captured,
|
||||||
ordinary preparation and exact profile inspection. Preparation first selects a
|
presence-aware target constructed from final profile and backend configuration.
|
||||||
request profile or a prompt default; inspection begins with its required
|
Request overrides apply afterward; preparation checks endpoint and model
|
||||||
explicit profile ID. Both use the captured, presence-aware target constructed
|
completeness only after those overrides. Resource profile inspection bypasses
|
||||||
from the final effective profile and backend configuration. Request overrides
|
this path and reports the profile before backend completion. Credential
|
||||||
apply afterward; preparation checks endpoint and model completeness only after
|
availability is checked when execution preparation requires it.
|
||||||
those overrides. Inspection can report a partial profile without requiring a
|
|
||||||
runnable target. Neither path checks credential availability or performs prompt,
|
|
||||||
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
|
## Smoke-Test Diagnostics
|
||||||
|
|
||||||
@@ -143,11 +135,10 @@ one execution-precedence and error-ordering implementation while allowing a
|
|||||||
full backend pool to reject work before expensive schema, artifact, and
|
full backend pool to reject work before expensive schema, artifact, and
|
||||||
rendering operations. `Prepare` discards the plan after returning its public
|
rendering operations. `Prepare` discards the plan after returning its public
|
||||||
metadata. `Run` retains the plan through initial and repaired-output validation
|
metadata. `Run` retains the plan through initial and repaired-output validation
|
||||||
and discards it when the operation ends. Prepared execution transfers the
|
and discards it when the operation ends. Prepared execution retains one
|
||||||
completed preparation's owned message, input-hash, and copied-schema values
|
immutable, redacted metadata snapshot shared by execution and internal details.
|
||||||
into its private payload, while deep-copying mutable provider extras and
|
The facade detaches mutable public details. Claim completion or discard clears
|
||||||
creating an independent details snapshot. Claim completion or discard clears
|
execution-only payload references without modifying the retained metadata.
|
||||||
the execution payload without removing the details snapshot.
|
|
||||||
|
|
||||||
The root facade's `Engine.ValidateRequest` uses the same complete preparation
|
The root facade's `Engine.ValidateRequest` uses the same complete preparation
|
||||||
path as `Prepare` and discards the resulting internal prepared value without
|
path as `Prepare` and discards the resulting internal prepared value without
|
||||||
@@ -177,7 +168,7 @@ encoding of that session, every role and content value, and cache-control
|
|||||||
presence and values; its hexadecimal value is opaque.
|
presence and values; its hexadecimal value is opaque.
|
||||||
|
|
||||||
The configuration snapshot is read-only after engine construction. Concurrent
|
The configuration snapshot is read-only after engine construction. Concurrent
|
||||||
`Prepare` and `Run` calls copy their resolved targets and keep all invocation
|
`Prepare` and `Run` calls use detached captured targets and keep invocation
|
||||||
state local.
|
state local.
|
||||||
|
|
||||||
## Run Flow
|
## Run Flow
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ source semantics. Internal packages are not consumer APIs.
|
|||||||
## Typed Configuration Loading
|
## Typed Configuration Loading
|
||||||
|
|
||||||
`internal/configuration` is the single adapter from ConfigKit's final
|
`internal/configuration` is the single adapter from ConfigKit's final
|
||||||
effective configuration to execution-owned definitions. It performs one
|
effective configuration to execution-owned definitions and resource views. It performs one
|
||||||
layered load, captures effective presence (including explicit null), records
|
layered load, captures the needed effective presence, records
|
||||||
normalization and defaults through resolution, and validates all effective
|
field-level normalization and derived defaults during resolution, and validates all effective
|
||||||
root and collection backend, profile, and prompt values. Replaced lower-layer
|
root and collection backend, profile, and prompt values. Replaced lower-layer
|
||||||
domain values are not separately validated. It indexes root resources under
|
domain values are not separately validated. It indexes root resources under
|
||||||
their unqualified IDs and collection resources under their full
|
their unqualified IDs and collection resources under their full
|
||||||
`namespace/local-key` IDs while retaining ConfigKit's effective tree in its
|
`namespace/local-key` IDs while retaining ConfigKit's resolved tree. It builds an immutable backend index and presence-aware profile
|
||||||
authored shape. It builds an immutable backend index and presence-aware profile
|
|
||||||
targets, applying final backend defaults only after profile inheritance. Runtime
|
targets, applying final backend defaults only after profile inheritance. Runtime
|
||||||
lookups use those final indexes and exact stored references; they do not rewrite
|
lookups use those final indexes and exact stored references; they do not rewrite
|
||||||
or resolve resources from ConfigKit source history. The runtime/domain packages
|
or resolve resources from ConfigKit source history. The runtime/domain packages
|
||||||
@@ -27,22 +26,22 @@ do not depend on ConfigKit.
|
|||||||
ConfigKit owns source headers, imports, composition, and source evidence.
|
ConfigKit owns source headers, imports, composition, and source evidence.
|
||||||
Promptkit does not preparse contributions: it validates the final effective
|
Promptkit does not preparse contributions: it validates the final effective
|
||||||
values once, captures the indexed identities and their original resource
|
values once, captures the indexed identities and their original resource
|
||||||
contexts, and retains ConfigKit's result for explicit operator inspection.
|
contexts, and retains ConfigKit's resolved result for explicit operator inspection.
|
||||||
Accepted non-main source headers are consumed during composition; the main
|
Accepted non-main source headers are consumed during composition; the main
|
||||||
source declaration remains available in that inspection view. Prompt and
|
source declaration remains available in that inspection view. Collection
|
||||||
collection release labels are descriptive metadata, not selection inputs. The
|
release labels are descriptive metadata, not selection inputs. The
|
||||||
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
[format reference](../formats.md#source-headers-collections-and-resource-ids)
|
||||||
owns the exact source-header and collection contracts.
|
owns the exact source-header and collection contracts.
|
||||||
|
|
||||||
The `ConfigurationInspection.Diff` facade method compares two retained results
|
The `ConfigurationInspection.Diff` facade method compares two retained results
|
||||||
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
through a private wrapper and a narrow `internal/configuration.Diff` bridge.
|
||||||
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
The wrapper keeps `Result[document]` inside the adapter; the bridge delegates
|
||||||
once to ConfigKit v0.8.0 and returns its public difference records. Comparison
|
once to ConfigKit v1.1.0 and returns its public difference records. Comparison
|
||||||
uses only frozen effective values and does not reopen sources, resolve runtime
|
uses only frozen effective values and does not reopen sources, resolve runtime
|
||||||
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
credentials, or enter the execution path. ConfigKit applies sensitivity from
|
||||||
both results; reports may still contain ordinary application data. See the
|
both results; reports may still contain ordinary application data. See the
|
||||||
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
[consumer comparison guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration)
|
||||||
and ConfigKit's [v0.8.0 comparison contract](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/config.md#effective-configuration-diffs).
|
and ConfigKit's [v1.1.0 comparison contract](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/config.md#effective-configuration-diffs).
|
||||||
|
|
||||||
After resource materialization and effective-profile snapshot construction, the
|
After resource materialization and effective-profile snapshot construction, the
|
||||||
adapter captures lexically ordered identity inventories for backends, profiles,
|
adapter captures lexically ordered identity inventories for backends, profiles,
|
||||||
@@ -53,7 +52,9 @@ captured values without consulting ConfigKit or copying resource definitions.
|
|||||||
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
|
The root `DirectoryInputs` and `FilesystemInputs` helpers discover sorted YAML
|
||||||
files under an explicit root, provide resource context, and leave imports
|
files under an explicit root, provide resource context, and leave imports
|
||||||
disabled. Each returned slice belongs to one strict-composition group; file
|
disabled. Each returned slice belongs to one strict-composition group; file
|
||||||
order does not confer priority. Caller underlays are composed in their given
|
order does not confer priority. Engine assembly prepends a dedicated embedded
|
||||||
|
OpenRouter underlay unless disabled, without modifying caller-owned slices.
|
||||||
|
Caller underlays are composed in their given
|
||||||
order, followed by the optional main input. The
|
order, followed by the optional main input. The
|
||||||
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
[configuration tests](../../internal/configuration/load_test.go) exercise the
|
||||||
real loader and final-value handoff.
|
real loader and final-value handoff.
|
||||||
@@ -84,19 +85,22 @@ returning and retains no source capability for public inspection.
|
|||||||
|
|
||||||
Prompt materialization shares a lazy metadata snapshot across root and collection
|
Prompt materialization shares a lazy metadata snapshot across root and collection
|
||||||
resources during construction, avoiding a full metadata copy per collection.
|
resources during construction, avoiding a full metadata copy per collection.
|
||||||
|
Input metadata, message content and templates, and output contracts are captured
|
||||||
|
separately before the definition, inspection, and hash are assembled.
|
||||||
|
|
||||||
Role and cache-control normalization is applied to execution-owned message
|
Normalization and defaults are recorded at individual resolved field paths,
|
||||||
copies. ConfigKit v0.8.0 retains effective input message metadata and its
|
including message roles and cache controls. List indexes identify fields for
|
||||||
resource origins; recording a sequence-wide normalization in the retained
|
presence, validation, sensitivity, and resource context; full IDs select
|
||||||
configuration would replace origins of unchanged content-file fields.
|
captured runtime indexes. Unchanged content-file fields retain their origins.
|
||||||
|
Backend-to-profile and request-time completion remain separate runtime work.
|
||||||
|
|
||||||
The runner selects full definitions from this index for preparation and uses
|
The runner selects full definitions from this index for preparation and retains
|
||||||
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
|
lightweight prompt metadata for `InspectRequirements`; the requirement snapshot
|
||||||
internal requirement snapshot lookup uses the selected prompt ID alone.
|
lookup uses the selected prompt ID alone. Separate configuration-owned resource
|
||||||
These paths do not rescan configuration or reopen message files. `InspectPrompt`
|
inspection projects the resolved record and captured messages without rendering
|
||||||
returns declared metadata without rendering or resolving profile, input, or
|
or resolving profile, input, or schema resources. These paths do not rescan
|
||||||
schema resources, and its public shape and prompt hashes do not include the
|
configuration or reopen message files. Presence metadata does not change the
|
||||||
captured presence state. Runtime rendering still checks request variables and
|
opaque definition hash. Runtime rendering still checks request variables and
|
||||||
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
|
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
|
||||||
cover resource origins, context containment, capture, and selection.
|
cover resource origins, context containment, capture, and selection.
|
||||||
|
|
||||||
@@ -142,13 +146,15 @@ change rendering behavior.
|
|||||||
## Profiles
|
## Profiles
|
||||||
|
|
||||||
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
ConfigKit resolves profile inheritance before the handoff. Within a collection,
|
||||||
`extends` names a sibling local key; it is not a cross-collection reference.
|
`extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference.
|
||||||
The configuration adapter validates final effective resource references and
|
The configuration adapter validates final effective resource references and
|
||||||
field values, then builds target
|
field values, then captures backend definitions and profile target snapshots
|
||||||
snapshots with backend defaults and presence/suppression state. A profile can
|
with backend defaults and outbound-control presence. Null profile endpoint,
|
||||||
remain partial until runtime overrides complete it. `InspectProfile` selects
|
credential source, and whole provider map permit backend fallback after
|
||||||
that captured target without prompt, artifact, schema, credential lookup, or
|
composition, subject to the required-credential rule. A profile can remain
|
||||||
provider work. Preparation applies runtime overrides and then checks target
|
partial until runtime overrides complete it. Resource inspection selects indexed
|
||||||
|
resolved backend and profile records without execution completion or prompt,
|
||||||
|
artifact, schema, credential lookup, or provider work. Preparation applies runtime overrides and then checks target
|
||||||
completeness. Identity-only profile selection can identify a partial captured
|
completeness. Identity-only profile selection can identify a partial captured
|
||||||
profile without copying its target or requiring readiness. The
|
profile without copying its target or requiring readiness. The
|
||||||
[target tests](../../internal/configuration/targets_test.go) cover effective
|
[target tests](../../internal/configuration/targets_test.go) cover effective
|
||||||
@@ -214,12 +220,14 @@ instead of first making a response-sized string copy.
|
|||||||
|
|
||||||
Every preparation operation creates one operation-local validation plan. None,
|
Every preparation operation creates one operation-local validation plan. None,
|
||||||
basic, and JSON modes retain the effective output contract without source
|
basic, and JSON modes retain the effective output contract without source
|
||||||
access. JSON Schema mode loads the root document once, resolves and compiles
|
access. JSON Schema mode loads the root document once, compiles its declared
|
||||||
each transitive reference, and retains the compiled validator. Schema compiler
|
schema locations, and retains the compiled root validator. External resources
|
||||||
resources use canonical escaped file or private-scheme URLs; loaders decode
|
are rejected without file or network loading; in-document references remain
|
||||||
their paths once and enforce the configured source boundary. The
|
supported. Root resources use canonical escaped file or private-scheme URLs. The
|
||||||
provider-facing structured-output metadata uses the root document captured by
|
provider-facing structured-output metadata uses the root document captured by
|
||||||
the same plan.
|
the same plan. Its detached schema document is not revalidated against the
|
||||||
|
runtime provider-value copier's traversal limits. Smoke inference uses a
|
||||||
|
separate JSON-only prepared plan before checking the prepared schema plan.
|
||||||
|
|
||||||
Schema preparation and execution remain synchronous. Promptkit checks
|
Schema preparation and execution remain synchronous. Promptkit checks
|
||||||
cancellation before and after source resolution, JSON decoding, compilation,
|
cancellation before and after source resolution, JSON decoding, compilation,
|
||||||
|
|||||||
@@ -26,17 +26,31 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
|||||||
|
|
||||||
## ConfigKit
|
## ConfigKit
|
||||||
|
|
||||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
Selected module: `go.maximumdirect.net/configkit` at `v1.1.0`.
|
||||||
|
|
||||||
Copyright (C) 2026 Eric Rakestraw
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
License and notices: [LGPL 3.0](../../LICENSE) |
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/COPYRIGHT) |
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
[LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
[Source](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9).
|
||||||
|
|
||||||
|
## ReleaseKit
|
||||||
|
|
||||||
|
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (maintainer tool).
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
License and notices: [LGPL 3.0](../../LICENSE) |
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
|
||||||
|
|
||||||
## jsonschema
|
## jsonschema
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**Base Policy Version:** 1.8
|
||||||
|
|
||||||
**Last Updated**: 2026-09-27
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||||
|
|
||||||
@@ -221,7 +221,8 @@ Promptkit retains its LGPL-3.0-or-later library grant in
|
|||||||
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
||||||
scope identified there and in [attribution](../oss/attribution.md).
|
scope identified there and in [attribution](../oss/attribution.md).
|
||||||
|
|
||||||
Promptkit publishes source-only Go module tags on Gitea, with no binaries or
|
Promptkit publishes source-only Go module tags and Gitea release pages, with no
|
||||||
separate hosted download page. The [release procedure](../release.md) owns
|
binaries. Each new hosted page reuses the complete checked-in release note,
|
||||||
publication and concrete footer URLs. The hosted-page guidance applies if such
|
including its licensing footer. The [release procedure](../release.md) owns
|
||||||
a channel is deliberately introduced; it does not require creating one.
|
source publication, hosted delivery, and concrete footer URLs. Previously
|
||||||
|
published notes and tags retain their original records.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.6
|
||||||
|
|
||||||
**Last Updated**: 2026-10-08
|
**Last Updated**: 2026-11-02
|
||||||
|
|
||||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||||
|
|
||||||
@@ -241,9 +241,13 @@ navigation on 2026-09-26. Apply them prospectively; previously published notes
|
|||||||
and tags retain their original records.
|
and tags retain their original records.
|
||||||
|
|
||||||
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
||||||
annotated stable SemVer tags. It has no binaries, hosted download pages, or
|
annotated stable SemVer tags. The source commit is pushed through main before
|
||||||
asynchronous deliverables. The source commit is pushed through main before the
|
the tag. After source publication, maintainers use ReleaseKit locally to deliver
|
||||||
tag. The [release procedure](../release.md) owns commands and recovery.
|
a Gitea release page containing the complete checked-in note, including its
|
||||||
|
licensing footer. There are no binaries or other deliverables. Source publication
|
||||||
|
and hosted delivery have separate outcomes; delivery credentials and service
|
||||||
|
availability do not gate source-release completion. The
|
||||||
|
[release procedure](../release.md) owns commands and recovery.
|
||||||
|
|
||||||
Release preparation uses the latest Go installation available in the development
|
Release preparation uses the latest Go installation available in the development
|
||||||
environment to build the maintainer release executable and validate the
|
environment to build the maintainer release executable and validate the
|
||||||
@@ -267,9 +271,11 @@ The [publication command](../../scripts/release.sh) delegates to the released
|
|||||||
ReleaseKit tool selected in `go.mod`. The committed
|
ReleaseKit tool selected in `go.mod`. The committed
|
||||||
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
[profile](../../releasekit.json) selects the race-enabled Go suite, full vet,
|
||||||
committed Go formatting, local Markdown links, workspace and filesystem
|
committed Go formatting, local Markdown links, workspace and filesystem
|
||||||
replacement inspection, and the offline semantic checks for the three
|
replacement inspection. The Go suite includes the offline semantic checks for
|
||||||
maintained consumer examples. These are the selected checks for the final
|
the three maintained consumer examples. It also rejects the configured local
|
||||||
candidate; a separate ordinary test run or standalone build is not an
|
delivery token path if committed; this narrowly scoped check is not a general credential
|
||||||
|
scan. These are the selected checks for the final candidate; a separate
|
||||||
|
ordinary test run or standalone build is not an
|
||||||
additional release gate. The upstream tool owns candidate, remote, and tag
|
additional release gate. The upstream tool owns candidate, remote, and tag
|
||||||
publication checks. It returns native status 0 for confirmed publication, 1
|
publication checks. It returns native status 0 for confirmed publication, 1
|
||||||
for a known validation or publication failure, 2 for invalid input or profile,
|
for a known validation or publication failure, 2 for invalid input or profile,
|
||||||
@@ -278,3 +284,9 @@ captured publication endpoint before retrying; the repository-specific
|
|||||||
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
[procedure](../release.md#failure-recovery) links to the upstream recovery
|
||||||
instructions. Do not wait for CI or Go module availability. Licensing footer
|
instructions. Do not wait for CI or Go module availability. Licensing footer
|
||||||
URLs are preparation work, not a network gate.
|
URLs are preparation work, not a network gate.
|
||||||
|
|
||||||
|
Hosted delivery is a separate post-publication step for new releases. It reuses
|
||||||
|
the immutable tag and note without rerunning candidate validation or waiting
|
||||||
|
for CI. Retry failed delivery against that same version after resolving any
|
||||||
|
uncertain hosted write; preserve published tags and hosted content. Report
|
||||||
|
delivery status separately from the completed source release.
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ documented validation model.
|
|||||||
|
|
||||||
The selected ReleaseKit profile runs the race-enabled package suite and full
|
The selected ReleaseKit profile runs the race-enabled package suite and full
|
||||||
vet, checks committed Go formatting, local Markdown links, and module
|
vet, checks committed Go formatting, local Markdown links, and module
|
||||||
distribution hygiene, and executes semantic assertions for the three
|
distribution hygiene. The Go suite includes semantic assertions for the three
|
||||||
maintained offline consumer examples. ReleaseKit owns this shared validation
|
maintained offline consumer examples. ReleaseKit owns this shared validation
|
||||||
machinery and publication workflow. The test run builds the packages; the
|
machinery and publication workflow. The test run builds the packages and
|
||||||
examples also exercise ordinary compilation. Separate ordinary tests and a
|
exercises the examples. Separate ordinary tests and a
|
||||||
standalone build are not additional release gates. Ordinary tests remain useful
|
standalone build are not additional release gates. Ordinary tests remain useful
|
||||||
during development. The preparation example protects assembled preparation and
|
during development. The preparation example protects assembled preparation and
|
||||||
inspection behavior. The execution example separately protects assembled
|
inspection behavior. The execution example separately protects assembled
|
||||||
|
|||||||
192
docs/release.md
192
docs/release.md
@@ -1,26 +1,26 @@
|
|||||||
# Release Procedure
|
# Release Procedure
|
||||||
|
|
||||||
This runbook implements the [release policy](policy/release.md). Promptkit
|
This runbook implements the [release policy](policy/release.md). Promptkit
|
||||||
publishes source-only Go module tags from `main` to `origin`. A successful push
|
publishes source-only Go module tags from `main` to `origin`, followed by a
|
||||||
of the selected annotated tag, after its source commit is available on `main`,
|
page-only Gitea release. Successful publication of the intended tag, with its
|
||||||
completes the release. Do not wait for CI or Go module availability.
|
source commit available on `main`, completes the source release. Report hosted
|
||||||
|
delivery separately. Do not wait for CI or Go module availability.
|
||||||
|
|
||||||
## Prepare The Candidate
|
## Prepare The Candidate
|
||||||
|
|
||||||
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
Required tools are Git, Go with race-detector support, and POSIX shell. Work
|
||||||
3. Work from the repository root with push access to `origin`.
|
from the repository root with push access to `origin`.
|
||||||
|
|
||||||
Use the latest Go installation available locally for the release tool, the
|
Use the latest Go installation available locally for the release tool and its
|
||||||
candidate validation, and the maintained examples. Make it the active `go`
|
child commands. Put it first on `PATH` and keep it selected throughout:
|
||||||
command and record its version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
go version
|
go version
|
||||||
```
|
```
|
||||||
|
|
||||||
For a minor release, advance Promptkit's consumer minimum to the initial stable
|
For a minor release, normally advance the consumer minimum to the initial
|
||||||
release of that Go series before committing the candidate:
|
stable patch of that Go series:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_go_version=$(go env GOVERSION)
|
release_go_version=$(go env GOVERSION)
|
||||||
@@ -29,105 +29,131 @@ release_go_series=${release_go_series%.*}
|
|||||||
go mod edit "-go=${release_go_series}.0"
|
go mod edit "-go=${release_go_series}.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, Go 1.26.8 normally results in `go 1.26.0`. Keep a higher patch
|
Retain a higher patch minimum when required by a dependency or identified fix.
|
||||||
minimum if a dependency or identified correctness fix requires it. For a patch
|
For a patch release, preserve the existing minimum. Explain minimum increases
|
||||||
release, leave the consumer minimum unchanged; using a newer validation
|
under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
||||||
toolchain does not itself raise that minimum. If the candidate needs a higher
|
Prepare dependency caches before committing the candidate:
|
||||||
minimum, prepare a minor release instead. See the
|
|
||||||
[consumer-minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
|
||||||
|
|
||||||
Prepare module caches before validation; this may update `go.sum` and belongs
|
|
||||||
before the candidate commit:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
GOTOOLCHAIN=local go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
Choose an unused stable version and create `docs/releases/<version>.md` with a
|
||||||
title containing `Promptkit` and that exact version, a short summary, and any
|
title containing `Promptkit` and that exact version, a short summary, and any
|
||||||
relevant compatibility impact or upgrade instructions. No fixed section
|
compatibility impact or upgrade instructions. End the note with:
|
||||||
headings are required. Describe a raised Go minimum and its compatibility
|
|
||||||
impact under the [versioning policy](policy/release.md#versioning-and-compatibility).
|
|
||||||
End each new note with the licensing footer below, using the selected version:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/vX.Y.Z/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Check the footer paths against the candidate; the future tag need not resolve
|
Substitute the selected version and check the paths against the candidate;
|
||||||
remotely during preparation. Follow the [OSS policy](policy/oss.md) for these
|
future tag URLs need not resolve during preparation. Follow the
|
||||||
links. Promptkit publishes no binaries, hosted download page, or other
|
[OSS policy](policy/oss.md). Historical notes and tags remain unchanged.
|
||||||
asynchronous deliverables.
|
|
||||||
|
|
||||||
Retire completed roadmaps while preserving deferred work and repairing incoming
|
Supply delivery credentials through `RELEASEKIT_GITEA_TOKEN` or the ignored
|
||||||
links. Review the intended changes through the ordinary development workflow,
|
local file `.releasekit/gitea-token`, with owner-only file permissions. A
|
||||||
commit the candidate on `main`, and push `main` to `origin` before publication.
|
nonblank environment value takes precedence. Source publication neither reads
|
||||||
Preparation may update module metadata; the release bootstrap and validation
|
nor requires this token. The [profile](../releasekit.json) selects the token
|
||||||
preserve the committed candidate.
|
path for committed-file protection; keep credentials out of commits and logs.
|
||||||
|
|
||||||
|
Retire completed roadmaps, preserve unfinished work, and repair affected links.
|
||||||
|
Review and commit the candidate on `main`, including any module updates, then
|
||||||
|
push `main` to `origin` before publishing its tag.
|
||||||
|
|
||||||
## Validate And Publish
|
## Validate And Publish
|
||||||
|
|
||||||
For an optional local diagnostic of a committed candidate, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/releasekit.sh check vMAJOR.MINOR.PATCH
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not a required step before publication. The upstream
|
|
||||||
[candidate-check contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/consumers/check.md)
|
|
||||||
describes its requirements.
|
|
||||||
|
|
||||||
Run the single publication command with the chosen version:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./scripts/release.sh vMAJOR.MINOR.PATCH
|
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
The familiar wrapper delegates to the native ReleaseKit executable selected in
|
The launcher uses `go tool` to build or reuse the module-selected ReleaseKit
|
||||||
`go.mod`. The committed [profile](../releasekit.json) selects the race-enabled
|
executable in Go's cache, using the selected local toolchain. ReleaseKit runs
|
||||||
Go suite, full vet, committed Go formatting, local Markdown links, workspace
|
the [profile's checks](../releasekit.json) once, verifies publication state, and
|
||||||
and filesystem replacement inspection, and semantic checks for the three
|
publishes only the intended tag. Preserve its native exit status and diagnostic.
|
||||||
maintained offline consumer examples. ReleaseKit also verifies the committed
|
Successful publication needs no separate remote read-back or CI wait.
|
||||||
candidate and publication state, then publishes only the selected tag. Its
|
|
||||||
checks run once for this candidate; do not repeat them as a separate sign-off.
|
|
||||||
|
|
||||||
The native command returns status 0 for confirmed publication, 1 for a known
|
For optional local diagnosis, use `./scripts/releasekit.sh check VERSION`.
|
||||||
validation or publication failure, 2 for invalid input or profile, and 3 when
|
It is not a required step before publication. The upstream
|
||||||
the push outcome remains unknown. Successful publication needs no remote
|
[candidate-check guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/check.md)
|
||||||
read-back, CI wait, or installation check. Provider probes and inference calls
|
and [CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/cli.md)
|
||||||
are not part of validation.
|
own shared checks and exit-status definitions.
|
||||||
|
|
||||||
|
## Deliver The Hosted Release
|
||||||
|
|
||||||
|
After source publication, keep a clean checkout at the published tag's commit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/releasekit.sh deliver vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile selects page-only Gitea delivery for this repository. The hosted
|
||||||
|
body is the complete checked-in note, including its licensing footer. There are
|
||||||
|
no binary assets, checksum manifests, candidate revalidation, or CI waits.
|
||||||
|
ReleaseKit checks delivery credentials during delivery, not as a publication
|
||||||
|
gate. Report the hosted URL on success; delivery failure leaves the source
|
||||||
|
release complete.
|
||||||
|
|
||||||
|
For a later retry, use a clean checkout of the original tag with its recorded
|
||||||
|
module files and supply the token there. In a separate checkout, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
(
|
||||||
|
set -e
|
||||||
|
release_version=vMAJOR.MINOR.PATCH
|
||||||
|
release_tag_ref="refs/tags/$release_version"
|
||||||
|
git fetch origin "$release_tag_ref:$release_tag_ref"
|
||||||
|
release_tag_commit=$(git rev-parse --verify "$release_tag_ref^{commit}")
|
||||||
|
git switch --detach "$release_tag_commit"
|
||||||
|
release_worktree_status=$(git status --porcelain --untracked-files=all)
|
||||||
|
if [ -n "$release_worktree_status" ]; then
|
||||||
|
printf '%s\n' 'Delivery retry requires a clean tagged checkout.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
./scripts/releasekit.sh deliver "$release_version"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve uncertain hosted writes before retrying the same immutable version.
|
||||||
|
Matching content is accepted; conflicts are not overwritten. Do not rerun
|
||||||
|
`publish` or move tags to repair delivery. Follow the upstream
|
||||||
|
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/delivery.md)
|
||||||
|
for result interpretation and recovery. These commands apply to tags containing
|
||||||
|
this delivery configuration; older releases retain their original workflows.
|
||||||
|
|
||||||
## Failure Recovery
|
## Failure Recovery
|
||||||
|
|
||||||
For status 1, use the diagnostic to distinguish a validation failure from a
|
For source-publication failures, use ReleaseKit's status and diagnostic to
|
||||||
known publication conflict. Correct and commit candidate defects, push `main`,
|
distinguish a candidate defect from a publication conflict. Correct and commit
|
||||||
and rerun publication with an unused version when appropriate. Status 2 means
|
candidate defects, push `main`, and rerun publication as appropriate. A failure
|
||||||
the invocation or profile must be corrected.
|
after local tag creation may leave a ref even when its object ID is unavailable.
|
||||||
|
|
||||||
For status 3, do not retry or remove the local tag until the remote outcome is
|
Status 3 means the push outcome remains unknown. Do not retry or remove the local
|
||||||
resolved. ReleaseKit reports the attempted version, tag object, and recovery
|
tag until the authoritative outcome is resolved. ReleaseKit attempts to save
|
||||||
state, including the captured publication endpoint when available. Use that
|
private publication evidence and reports a record path or a separate recording
|
||||||
endpoint—not merely the current `origin` URL—to query the exact tag; follow the
|
warning. A recording failure does not change the publication outcome.
|
||||||
upstream
|
|
||||||
[failure-recovery procedure](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/release.md#resolve-failures):
|
Use the recovery command reported for that attempt, or discover local records
|
||||||
|
and explicitly select the matching attempt:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
./scripts/releasekit.sh publication-attempts
|
||||||
releasekit_version='vX.Y.Z'
|
./scripts/releasekit.sh reconcile --record /absolute/path/to/selected-record.json
|
||||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
|
||||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the returned object ID with the reported tag object. An exact match
|
Discovery is local; reconciliation observes the original destination without
|
||||||
confirms publication. A missing ref confirms absence only when the endpoint
|
rerunning validation, changing tags, or delivering hosted content. Neither is
|
||||||
identity check and query both succeed. If either check fails, the outcome
|
a routine gate after successful publication. Follow the upstream
|
||||||
remains unknown; correct the transport configuration and resolve it before
|
[recovery procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/release.md#resolve-publication-failures)
|
||||||
retrying. Only after authoritative absence is confirmed may an unpublished
|
for outcomes, incomplete evidence, and older attempts without records.
|
||||||
local tag be removed before correcting the candidate or retrying. Never move,
|
|
||||||
overwrite, or delete a published tag.
|
|
||||||
|
|
||||||
## Policy Changes
|
Records are stored under Git's common directory by default and can contain
|
||||||
|
credential-bearing endpoints. Keep them private, out of source control and
|
||||||
|
shared logs; retain them while needed for recovery and remove them when that
|
||||||
|
value expires. See the [evidence guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.9.0/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
for storage and retention details.
|
||||||
|
|
||||||
Update the policy and this procedure together when changing release governance.
|
Current absence does not prove a version was never published. Remove a retained
|
||||||
Consumers own their dependency-availability and release checks; their readiness
|
local tag only after establishing absence and confirming it was not already
|
||||||
does not delay completion of a Promptkit release.
|
published or used by another release channel. Never reuse a known published
|
||||||
|
version or replace a published tag or hosted page. Corrections require a new
|
||||||
|
appropriate version.
|
||||||
|
|||||||
68
docs/releases/v0.17.0.md
Normal file
68
docs/releases/v0.17.0.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Promptkit v0.17.0
|
||||||
|
|
||||||
|
This release adopts the vanity module path `go.maximumdirect.net/promptkit`
|
||||||
|
and ConfigKit v1.0.0, standardizes configuration resources as identified lists,
|
||||||
|
and removes descriptive prompt-version metadata. These are intentional breaking
|
||||||
|
changes before v1. The minimum supported Go version increases from 1.26.0 to
|
||||||
|
1.27.0 for the ConfigKit v1 migration.
|
||||||
|
|
||||||
|
## Breaking Changes And Upgrade
|
||||||
|
|
||||||
|
Consumers upgrading from v0.16.0 should follow the
|
||||||
|
[configuration and API migration guide](../consumers/configuration-migration.md):
|
||||||
|
|
||||||
|
- Change Promptkit imports from `gitea.maximumdirect.net/eric/promptkit` to
|
||||||
|
`go.maximumdirect.net/promptkit`. Update packages that exchange Promptkit
|
||||||
|
types together; old-path and vanity-path types are distinct.
|
||||||
|
- Change ConfigKit imports to `go.maximumdirect.net/configkit` at v1.0.0 and
|
||||||
|
adopt its renamed public inspection types and fields where used.
|
||||||
|
- Convert root and collection `prompts`, `profiles`, and `backends` from keyed
|
||||||
|
mappings to lists whose records contain an explicit `id`. Collections remain
|
||||||
|
keyed by namespace. Sibling inheritance now uses `extends: {id: parent}`;
|
||||||
|
later same-ID records replace lower records in full.
|
||||||
|
- Change `ProfileSource` and `BackendSource` calls to pass slices of
|
||||||
|
`ProfileConfig` and `BackendConfig`, setting each record's `ID`.
|
||||||
|
- Change source-root `configkit:` controls to `config:`. Imports are optional
|
||||||
|
unless marked `required: true`. Optional Promptkit specification headers now
|
||||||
|
use `version: 2` or `version: "2"`; headerless sources remain valid.
|
||||||
|
- Remove descriptive prompt `version` values and code consuming `PromptVersion`,
|
||||||
|
`PromptIdentity.Version`, or corresponding `prompt_version` output members.
|
||||||
|
Collection `release_version` labels remain available. Prompt hashes may change
|
||||||
|
across this migration.
|
||||||
|
|
||||||
|
After adapting Go imports, configuration, and API calls, use Go 1.27.0 or newer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.17.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Earlier tags retain their original module declarations. No permanent local
|
||||||
|
replacement is needed for this cutover. See the
|
||||||
|
[framework format reference](../formats.md) for current configuration semantics
|
||||||
|
and the [maintained preparation example](../../examples/go-library/prepare/main.go)
|
||||||
|
for explicit composition, source-relative imports, and scoped discovery.
|
||||||
|
|
||||||
|
## Configuration Inspection And Execution
|
||||||
|
|
||||||
|
Configuration inspection and diffs expose ConfigKit's composed values and
|
||||||
|
provenance, including list-index paths. Promptkit applies domain normalization
|
||||||
|
and execution defaults to owned interpretations without replacing the source
|
||||||
|
origins needed for relative resources. Profile inspection and preparation expose
|
||||||
|
their interpreted or effective settings. See the
|
||||||
|
[migration guide](../consumers/configuration-migration.md#select-and-compose-the-configuration-you-need)
|
||||||
|
for the distinction and inspection-path updates.
|
||||||
|
|
||||||
|
## Maintainer Tooling
|
||||||
|
|
||||||
|
Release tooling now uses the module-selected ReleaseKit v0.8.0 through `go tool`,
|
||||||
|
with page-only Gitea delivery and saved-publication-attempt recovery. The
|
||||||
|
[release procedure](../release.md) describes publication and recovery.
|
||||||
|
|
||||||
|
The three maintained offline examples now have ordinary Go workflow tests,
|
||||||
|
included in the standard suite. Their separate Python runner and custom release
|
||||||
|
hook have been removed; Python is no longer a normal release prerequisite.
|
||||||
|
The optional release-wiring diagnostic remains Python. See the
|
||||||
|
[development guide](../development.md#maintainer-validation).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.17.0/docs/oss/attribution.md)
|
||||||
65
docs/releases/v0.18.0.md
Normal file
65
docs/releases/v0.18.0.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Promptkit v0.18.0
|
||||||
|
|
||||||
|
Configuration inspection now exposes Promptkit's recorded field normalization
|
||||||
|
and derived queue-capacity defaults through ConfigKit v1.1.0. Engines also
|
||||||
|
include a default-on OpenRouter configuration. The minimum supported Go
|
||||||
|
version remains 1.27.0.
|
||||||
|
|
||||||
|
## Bundled OpenRouter Backend And Profiles
|
||||||
|
|
||||||
|
New engines include an embedded OpenRouter backend and seventeen root profiles:
|
||||||
|
ten latest-family aliases, the auto and free routers, and five auto-router
|
||||||
|
cost-tier variants. Select a profile explicitly in a prompt or request; the
|
||||||
|
bundle does not supply an implicit execution profile or contact providers
|
||||||
|
during construction. The backend uses `OPENROUTER_API_KEY` when available under
|
||||||
|
the existing optional-credential rules.
|
||||||
|
|
||||||
|
The bundle is the lowest-priority underlay. Applications can replace its records
|
||||||
|
or derive named variants through ordinary configuration, and external collections
|
||||||
|
remain supported. Cost-tier variants inherit the effective `auto` profile, so
|
||||||
|
replacing that parent changes their inherited target while retaining plugins.
|
||||||
|
The [bundle reference](../formats.md#bundled-openrouter-configuration) owns the
|
||||||
|
inventory and composition details.
|
||||||
|
|
||||||
|
Explicit smoke diagnostics now have OpenRouter authentication metadata and an
|
||||||
|
inference fallback profile. They remain opt-in and may incur provider costs.
|
||||||
|
Latest aliases and routers can change upstream; configured slugs do not pin
|
||||||
|
the concrete model, pricing, or availability. See the
|
||||||
|
[consumer guide](../consumers/pkg-promptkit.md#construct-an-engine) for selection
|
||||||
|
and opt-out examples.
|
||||||
|
|
||||||
|
## Recorded Configuration Normalization
|
||||||
|
|
||||||
|
Inspection shows normalized backend and profile settings, prompt metadata, and
|
||||||
|
configured message roles and cache controls, with field-level resolution
|
||||||
|
history. Existing source evidence remains available, and unchanged content-file
|
||||||
|
origins continue to select their resource context.
|
||||||
|
|
||||||
|
Configuration diffs compare normalized effective values, so spelling-only
|
||||||
|
changes that normalize identically may no longer appear. Lists remain atomic in
|
||||||
|
diffs. Inspection does not include backend-to-profile completion or request
|
||||||
|
overrides; see the [configuration inspection guide](../consumers/pkg-promptkit.md#inspect-the-captured-configuration).
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
New engines install a bundled OpenRouter backend and root profiles by default.
|
||||||
|
Inventory listings and inspection sources therefore change, and references to
|
||||||
|
bundled IDs that previously failed may now resolve. Pass
|
||||||
|
`promptkit.WithBundledOpenRouter(false)` to retain application-only behavior.
|
||||||
|
Caller records can replace bundled definitions through ordinary composition;
|
||||||
|
same-ID replacements replace complete records rather than merging selected fields.
|
||||||
|
No configuration-format migration is required from v0.17.0.
|
||||||
|
Consumers that display or compare configuration inspection results should account
|
||||||
|
for normalized values and derived defaults replacing their earlier authored
|
||||||
|
representations. Request-time overrides and caller-appended messages retain
|
||||||
|
their existing processing.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.18.0
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [framework format reference](../formats.md) for current configuration
|
||||||
|
semantics. Consumers upgrading from versions before v0.17.0 should also follow
|
||||||
|
the [configuration and API migration guide](../consumers/configuration-migration.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.18.0/docs/oss/attribution.md)
|
||||||
80
docs/releases/v0.19.0.md
Normal file
80
docs/releases/v0.19.0.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Promptkit v0.19.0
|
||||||
|
|
||||||
|
This release completes two rounds of audit remediation, correcting error and
|
||||||
|
diagnostic reporting and simplifying configuration, validation, and value
|
||||||
|
ownership. It includes intentional compatibility changes for custom artifact
|
||||||
|
readers, programmatic configuration sources, and schemas. The minimum supported
|
||||||
|
Go version remains 1.27.0; ConfigKit remains at v1.1.0.
|
||||||
|
|
||||||
|
## Compatibility And Upgrade
|
||||||
|
|
||||||
|
Consumers upgrading from v0.18.0 should review these changes:
|
||||||
|
|
||||||
|
- A successful `ArtifactReader.Read` transfers ownership of the returned
|
||||||
|
artifact and its mutable body storage to Promptkit. Custom readers must stop
|
||||||
|
mutating returned values through retained aliases. Readers that reuse scratch
|
||||||
|
buffers must return independent storage. Promptkit retains its defensive
|
||||||
|
body copy, but that copy does not make concurrent buffer reuse safe. See the
|
||||||
|
[extension guidance](../consumers/pkg-promptkit.md#extension-interfaces).
|
||||||
|
- Provider maps passed through `ProfileSource` and `BackendSource` now use
|
||||||
|
ConfigKit's native Mapping input forms directly: built-in scalars, `[]any`,
|
||||||
|
and `map[string]any` recursively. Convert `json.Number` and typed containers
|
||||||
|
explicitly to supported values, choosing numeric precision appropriate to
|
||||||
|
the application. The former recursive compatibility conversion is removed.
|
||||||
|
Runtime execution overrides retain their separate accepted-value behavior.
|
||||||
|
- Schemas used for local validation and native structured output must be
|
||||||
|
self-contained. Move externally referenced definitions into the root document
|
||||||
|
and use internal references or explicit schema IDs. References requiring
|
||||||
|
another file or network resource, or relying on the source filename as a base,
|
||||||
|
fail during preparation. Promptkit does not bundle external schemas. See the
|
||||||
|
[schema reference](../formats.md#schemas).
|
||||||
|
|
||||||
|
Prepared schemas no longer inherit the depth and node limits used when copying
|
||||||
|
runtime provider values. Schemas previously rejected solely by those incidental
|
||||||
|
limits may now be accepted; decoder/compiler checks and the self-contained
|
||||||
|
schema requirement still apply.
|
||||||
|
|
||||||
|
No configuration-format migration is required from v0.18.0. After addressing
|
||||||
|
any affected integrations:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.19.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors And Diagnostics
|
||||||
|
|
||||||
|
Public errors and terminal observations now preserve the category of the
|
||||||
|
operation that failed even when an injected collaborator returns an error from
|
||||||
|
another Promptkit operation. Underlying causes remain available through
|
||||||
|
`errors.Is` and `errors.As`, without repeatedly duplicating nested cause trees.
|
||||||
|
|
||||||
|
Built-in generation preserves response-body read failures separately from
|
||||||
|
malformed response content, and failures during request preparation are
|
||||||
|
distinguished from dispatched attempts.
|
||||||
|
|
||||||
|
OpenRouter authentication diagnostics bound response recognition to 1 MiB.
|
||||||
|
An oversized HTTP 200 response reports
|
||||||
|
`SmokeTestReasonAuthenticationResponseLimitExceeded`, leaving credential
|
||||||
|
acceptance unknown. Cancellation during the read remains an interruption.
|
||||||
|
Consumers that enumerate diagnostic reasons should handle the new reason;
|
||||||
|
see the [diagnostic integration contract](../integrations/openai-compatible-chat.md#explicit-smoke-diagnostics).
|
||||||
|
|
||||||
|
## Internal Simplification And Ownership
|
||||||
|
|
||||||
|
Prompt lookup and rendering now consistently use captured configuration, and
|
||||||
|
smoke syntax and schema checks share the prepared output-validation lifecycle.
|
||||||
|
Unused routing defaults, alternate private workflows, and duplicate wire
|
||||||
|
representations have been removed.
|
||||||
|
|
||||||
|
Provider-setting resolution, inspection, prepared details, and smoke reports
|
||||||
|
avoid redundant validation and copying of already accepted values. Prepared
|
||||||
|
execution shares one private redacted metadata snapshot while preserving
|
||||||
|
independent caller-visible values. Provider values are detached before custom
|
||||||
|
JSON encoders run, protecting retained metadata from encoder-side mutation.
|
||||||
|
|
||||||
|
Requirement analysis, namespace-filtered listings, and repair-feedback
|
||||||
|
construction also avoid unnecessary traversal and temporary allocation. These
|
||||||
|
changes preserve existing request isolation and execution behavior without
|
||||||
|
introducing new performance guarantees.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.19.0/docs/oss/attribution.md)
|
||||||
55
docs/releases/v0.20.0.md
Normal file
55
docs/releases/v0.20.0.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Promptkit v0.20.0
|
||||||
|
|
||||||
|
This minor release adds generically renderable resource inspection and typed
|
||||||
|
prompt source construction. The Go consumer minimum remains 1.27.0 and
|
||||||
|
ConfigKit remains at v1.1.0.
|
||||||
|
|
||||||
|
## Compatibility And Migration
|
||||||
|
|
||||||
|
`InspectProfile` now returns the resolved profile resource instead of the
|
||||||
|
backend-completed `EffectiveModelParams`. Replace accesses to that field with
|
||||||
|
the optional profile fields; use `Prepare` or `PreparedExecution.Details` for
|
||||||
|
completed request settings. `InspectPrompt` now includes captured messages and
|
||||||
|
uses optional pointers for presence-sensitive fields, including default profile,
|
||||||
|
inputs, input metadata, and output settings. Check pointers before dereferencing.
|
||||||
|
All three resource reports support ordinary JSON and YAML rendering without
|
||||||
|
per-field adapters. See the [consumer guidance](../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work).
|
||||||
|
|
||||||
|
After configuration composition and inheritance, profile null endpoint,
|
||||||
|
API-key source, and whole provider map now permit backend defaults during
|
||||||
|
execution as well as appearing absent in resource inspection. Use explicit
|
||||||
|
empty strings to clear endpoint or API-key source. There is no replacement for
|
||||||
|
profile-level suppression of an entire backend provider map; edit backend
|
||||||
|
defaults through composition instead. The required-key rule still applies.
|
||||||
|
See [defaults and overrides](../formats.md#defaults-and-overrides) for the
|
||||||
|
migration example and retained ConfigKit null provenance.
|
||||||
|
|
||||||
|
## Resource Inspection And Sources
|
||||||
|
|
||||||
|
`InspectBackend` reports the resolved backend and configured diagnostics without
|
||||||
|
contacting a provider or deriving diagnostic destinations. Prompt and profile
|
||||||
|
inspection likewise report selected resources independently of request readiness;
|
||||||
|
configuration provenance, requirement analysis, and preparation remain separate.
|
||||||
|
`PromptSource` adds typed programmatic prompt definitions, and `BackendConfig`
|
||||||
|
supports sibling inheritance. ConfigKit continues to own source composition,
|
||||||
|
snapshotting, and relative message-file contexts.
|
||||||
|
|
||||||
|
The source helpers' [GoDoc](../../prompts.go) and
|
||||||
|
[backend configuration API](../../backends.go) describe programmatic construction.
|
||||||
|
|
||||||
|
## Refinements
|
||||||
|
|
||||||
|
Package GoDoc and migration guidance now consistently describe the resource
|
||||||
|
inspection contract, and the full package overview is visible in generated
|
||||||
|
documentation. Prompt inspection avoids repeating the same metadata lookup for
|
||||||
|
each declared input.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
After adapting affected inspection consumers and reviewing profile null usage:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get go.maximumdirect.net/promptkit@v0.20.0
|
||||||
|
```
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.20.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.20.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/promptkit/src/tag/v0.20.0/docs/oss/attribution.md)
|
||||||
@@ -126,7 +126,7 @@ cycle-safe, and resolved afresh for ordinary operations. Prepared execution
|
|||||||
freezes the fully resolved target. The selected leaf ID remains public while
|
freezes the fully resolved target. The selected leaf ID remains public while
|
||||||
effective execution settings reflect the resolved chain.
|
effective execution settings reflect the resolved chain.
|
||||||
|
|
||||||
See the [profile inheritance format reference](../formats.md#profile-inheritance),
|
See the [profile inheritance format reference](../formats.md#resource-inheritance),
|
||||||
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
the [current identity migration mapping](../consumers/configuration-migration.md#identity-mapping),
|
||||||
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
||||||
behavior.
|
behavior.
|
||||||
|
|||||||
@@ -38,8 +38,17 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
No ideas are currently awaiting selection. Active feature work belongs in its
|
### Schema Bundling
|
||||||
focused roadmap rather than this catalog.
|
|
||||||
|
Allow consumers to author reusable schemas across multiple files and package
|
||||||
|
them into a single self-contained schema for local validation and provider
|
||||||
|
submission. This could restore multi-file authoring convenience while retaining
|
||||||
|
the [self-contained schema requirement](../formats.md#schemas).
|
||||||
|
|
||||||
|
Bundling would need to preserve reference semantics, identifiers, anchors, and
|
||||||
|
recursion. A complete bundle would not guarantee that a provider supports every
|
||||||
|
schema feature. This is a possible future enhancement; implementation is not
|
||||||
|
currently approved.
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
88
engine.go
88
engine.go
@@ -8,11 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
internalengine "go.maximumdirect.net/promptkit/internal/engine"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/observation"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||||
@@ -30,6 +31,8 @@ var (
|
|||||||
// ErrProfileNotFound identifies a selected profile ID that is absent from
|
// ErrProfileNotFound identifies a selected profile ID that is absent from
|
||||||
// every configured profile source. It does not also match ErrProfileLoad.
|
// every configured profile source. It does not also match ErrProfileLoad.
|
||||||
ErrProfileNotFound = errors.New("profile not found")
|
ErrProfileNotFound = errors.New("profile not found")
|
||||||
|
// ErrBackendNotFound identifies a backend absent from resource inspection.
|
||||||
|
ErrBackendNotFound = errors.New("backend not found")
|
||||||
// ErrProfileRequired identifies a request for which neither RunRequest.ProfileID
|
// ErrProfileRequired identifies a request for which neither RunRequest.ProfileID
|
||||||
// nor the selected prompt's default profile is present. Such an error also
|
// nor the selected prompt's default profile is present. Such an error also
|
||||||
// matches ErrInvalidRequest.
|
// matches ErrInvalidRequest.
|
||||||
@@ -81,12 +84,12 @@ var (
|
|||||||
ErrValidation = errors.New("failed to validate output")
|
ErrValidation = errors.New("failed to validate output")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine inspects prompts and profiles, enumerates prompt requirements, and
|
// Engine inspects resources, enumerates prompt requirements, and
|
||||||
// prepares and runs Promptkit prompt requests.
|
// prepares and runs Promptkit prompt requests.
|
||||||
//
|
//
|
||||||
// An Engine is safe for concurrent calls to [Engine.WithNamespaces],
|
// An Engine is safe for concurrent calls to [Engine.WithNamespaces],
|
||||||
// [Engine.InspectConfiguration],
|
// [Engine.InspectConfiguration],
|
||||||
// [Engine.InspectPrompt],
|
// [Engine.InspectPrompt], [Engine.InspectBackend],
|
||||||
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||||
// [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles],
|
// [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles],
|
||||||
// [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and
|
// [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and
|
||||||
@@ -101,8 +104,9 @@ type Engine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
// ConfigurationSources selects ordered ConfigKit underlays and an optional
|
||||||
// highest-priority main input. Each underlay's inputs strictly compose within
|
// highest-priority main input. By default a bundled OpenRouter underlay precedes
|
||||||
// their group; later underlays overlay earlier ones. A nil Main uses
|
// the caller underlays; [WithBundledOpenRouter] can disable it. Inputs within
|
||||||
|
// each underlay strictly compose; later underlays overlay earlier ones. A nil Main uses
|
||||||
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
|
// ConfigKit's empty source. A non-nil Main is passed through unchanged and
|
||||||
// may fail construction if invalid.
|
// may fail construction if invalid.
|
||||||
type ConfigurationSources struct {
|
type ConfigurationSources struct {
|
||||||
@@ -154,6 +158,15 @@ type engineOptions struct {
|
|||||||
internal internalengine.Options
|
internal internalengine.Options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithBundledOpenRouter enables or disables the built-in OpenRouter underlay.
|
||||||
|
// It is enabled by default; when repeated, the last option wins.
|
||||||
|
func WithBundledOpenRouter(enabled bool) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
options.internal.SetBundledOpenRouter(enabled)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
// [Engine.RunPrepared].
|
// [Engine.RunPrepared].
|
||||||
//
|
//
|
||||||
@@ -203,7 +216,8 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
|
|||||||
//
|
//
|
||||||
// Prompt schema_path values refer to the file's base name. path must name an
|
// Prompt schema_path values refer to the file's base name. path must name an
|
||||||
// existing non-directory file when NewEngine applies the option. This option
|
// existing non-directory file when NewEngine applies the option. This option
|
||||||
// replaces Config.SchemaDir and earlier schema-source options.
|
// replaces Config.SchemaDir and earlier schema-source options. The selected
|
||||||
|
// schema must be self-contained; external resources are not loaded.
|
||||||
func WithSchemaFile(path string) Option {
|
func WithSchemaFile(path string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
return options.internal.SetSchemaFile(path)
|
return options.internal.SetSchemaFile(path)
|
||||||
@@ -213,7 +227,9 @@ func WithSchemaFile(path string) Option {
|
|||||||
// NewEngine constructs an Engine from configuration and options.
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
//
|
//
|
||||||
// ConfigKit composes caller-selected underlays in their given order, followed
|
// ConfigKit composes caller-selected underlays in their given order, followed
|
||||||
// by the optional main input. Construction validates the final effective
|
// by the optional main input, after the default-on bundled OpenRouter underlay.
|
||||||
|
// The bundle adds a root backend and profiles to inventories, but no prompts
|
||||||
|
// or implicit profile selection. Construction validates the final effective
|
||||||
// backend, profile, and prompt definitions and captures referenced prompt
|
// backend, profile, and prompt definitions and captures referenced prompt
|
||||||
// content. A missing prompt collection is allowed. Schema documents and
|
// content. A missing prompt collection is allowed. Schema documents and
|
||||||
// request artifacts remain runtime resources.
|
// request artifacts remain runtime resources.
|
||||||
@@ -251,18 +267,19 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
// InspectPrompt requires a nonblank promptID and performs an exact,
|
// InspectPrompt requires a nonblank promptID and performs an exact,
|
||||||
// case-sensitive lookup in the captured configuration snapshot. A
|
// case-sensitive lookup in the captured configuration snapshot. A
|
||||||
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
// namespace-scoped view qualifies slashless caller IDs before lookup; already
|
||||||
// qualified IDs pass through. Prompt version labels do not select definitions.
|
// qualified IDs pass through.
|
||||||
//
|
//
|
||||||
// A successful result proves that the selected definition and any referenced
|
// A successful result proves that the selected definition and any referenced
|
||||||
// message content files were structurally loaded. Inputs are returned in
|
// message content files were structurally loaded. Inputs and captured,
|
||||||
// definition order. DefaultProfileID is declared metadata only and is not
|
// unrendered messages are returned in definition order. DefaultProfileID is
|
||||||
|
// declared metadata only and is not
|
||||||
// resolved. OutputContract is the normalized declared contract, with a JSON
|
// resolved. OutputContract is the normalized declared contract, with a JSON
|
||||||
// Schema path when declared but without loading or compiling that schema.
|
// Schema path when declared but without loading or compiling that schema.
|
||||||
// PromptHash is the same opaque equality value as PreparedRun.PromptHash for
|
// PromptHash is the same opaque equality value as PreparedRun.PromptHash for
|
||||||
// the selected captured definition; its spelling, length,
|
// the selected captured definition; its spelling, length,
|
||||||
// encoding, algorithm, and security properties are not contracts.
|
// encoding, algorithm, and security properties are not contracts.
|
||||||
//
|
//
|
||||||
// This method does not return prompt bodies, templates, source paths, schemas,
|
// This method does not return parsed templates, source paths, schemas,
|
||||||
// rendered messages, or execution settings. It does not resolve a profile or
|
// rendered messages, or execution settings. It does not resolve a profile or
|
||||||
// credential, read artifacts or schemas, render, validate, admit capacity,
|
// credential, read artifacts or schemas, render, validate, admit capacity,
|
||||||
// contact a provider, or generate model output. The returned PromptInspection
|
// contact a provider, or generate model output. The returned PromptInspection
|
||||||
@@ -286,21 +303,19 @@ func (e *Engine) InspectPrompt(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
return fromDomainPromptInspection(inspection), nil
|
return fromPromptResource(inspection), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectProfile resolves one explicit profile without selecting a prompt or
|
// InspectProfile resolves one explicit profile without selecting a prompt or
|
||||||
// starting execution work.
|
// starting execution work.
|
||||||
//
|
//
|
||||||
// InspectProfile looks up a nonblank profileID exactly and case-sensitively
|
// InspectProfile looks up a nonblank profileID exactly and case-sensitively
|
||||||
// in the captured effective configuration. It applies the framework timeout
|
// in the captured effective configuration. It reports the resolved profile
|
||||||
// baseline, selected
|
// resource without backend completion, execution defaults, or request overrides.
|
||||||
// backend, and then selected profile to EffectiveModelParams without a request
|
|
||||||
// override. BackendID is empty for an endpoint-only profile.
|
|
||||||
//
|
//
|
||||||
// APIKeyEnv in the returned target is an environment-variable name, never its
|
// APIKeyEnv in the returned resource is an environment-variable name, never its
|
||||||
// value. APIKeyRequired instead reports a direct credential requirement and is
|
// value. APIKeyRequired, when present and true, reports that a credential is
|
||||||
// mutually exclusive with a nonblank APIKeyEnv. InspectProfile neither derives
|
// required; an explicit profile environment source can also be present. InspectProfile neither derives
|
||||||
// an ID from a prompt default_profile nor checks credential availability, so an
|
// an ID from a prompt default_profile nor checks credential availability, so an
|
||||||
// absent or blank named environment variable is not an error.
|
// absent or blank named environment variable is not an error.
|
||||||
//
|
//
|
||||||
@@ -324,7 +339,32 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
return fromDomainProfileInspection(inspection), nil
|
return fromProfileResource(inspection), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectBackend selects a resolved backend by exact ID from the captured
|
||||||
|
// configuration, qualifying a slashless ID with the backend namespace. It
|
||||||
|
// reports configured resource fields and diagnostics, not credential values,
|
||||||
|
// derived diagnostic destinations, or runtime capacity occupancy. References
|
||||||
|
// are not resolved and no network request is made. The result and its mutable
|
||||||
|
// values are caller-owned. Source changes require a new engine.
|
||||||
|
//
|
||||||
|
// An invalid engine matches ErrInvalidConfig, an invalid ID matches
|
||||||
|
// ErrInvalidRequest, and an absent backend matches ErrBackendNotFound (not
|
||||||
|
// ErrInvalidRequest). Cancellation remains detectable via errors.Is. No
|
||||||
|
// partial result is returned on error.
|
||||||
|
func (e *Engine) InspectBackend(ctx context.Context, backendID string) (*BackendInspection, error) {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
inspection, err := e.internal.InspectBackend(ctx, backendID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, backend.ErrBackendNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrBackendNotFound, err)
|
||||||
|
}
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromBackendResource(inspection), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare resolves and renders a prompt request without calling an LLM.
|
// Prepare resolves and renders a prompt request without calling an LLM.
|
||||||
|
|||||||
46
errors.go
46
errors.go
@@ -5,18 +5,52 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"go.maximumdirect.net/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
"go.maximumdirect.net/promptkit/internal/generation"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"go.maximumdirect.net/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// publicOperationError keeps classification separate from inspectable causes.
|
||||||
|
// Cache wording so formatting nested operations does not repeatedly rebuild it.
|
||||||
|
type publicOperationError struct {
|
||||||
|
owner error
|
||||||
|
cause error
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *publicOperationError) Error() string { return e.message }
|
||||||
|
func (e *publicOperationError) Unwrap() error { return e.cause }
|
||||||
|
func (e *publicOperationError) Is(target error) bool { return errors.Is(e.owner, target) }
|
||||||
|
|
||||||
func mapPublicError(err error) error {
|
func mapPublicError(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var operation *usecase.OperationError
|
||||||
|
if errors.As(err, &operation) && operation != nil {
|
||||||
|
owner := publicErrorFor(operation.Category)
|
||||||
|
if owner != nil {
|
||||||
|
// Project the underlying specialized error separately, retaining both
|
||||||
|
// the outer operation and the original cause for Is/As callers.
|
||||||
|
mapped := mapPublicError(operation.Cause)
|
||||||
|
if errors.Is(operation.Cause, llm.ErrTransport) {
|
||||||
|
mapped = errors.Join(ErrTransport, mapped)
|
||||||
|
}
|
||||||
|
if errors.Is(operation.Cause, llm.ErrMalformedResponse) {
|
||||||
|
mapped = errors.Join(ErrMalformedResponse, mapped)
|
||||||
|
}
|
||||||
|
// The projection already retains ordinary causes; adding the original
|
||||||
|
// again would duplicate its entire subtree at each enclosing operation.
|
||||||
|
return &publicOperationError{owner: owner, cause: mapped, message: fmt.Sprintf("%v: %v", owner, mapped)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapUnownedPublicError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapUnownedPublicError(err error) error {
|
||||||
var internalCapacityError *usecase.CapacityError
|
var internalCapacityError *usecase.CapacityError
|
||||||
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
||||||
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
||||||
@@ -80,3 +80,12 @@ func TestMapPublicErrorDoesNotTreatRequestEncodingAsTransport(t *testing.T) {
|
|||||||
t.Fatalf("request encoding category = %v", err)
|
t.Fatalf("request encoding category = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorRetainsOwnerWithSpecializedCapacity(t *testing.T) {
|
||||||
|
internal := &usecase.CapacityError{BackendID: "limited"}
|
||||||
|
err := mapPublicError(&usecase.OperationError{Category: usecase.ErrLLMGenerate, Cause: internal})
|
||||||
|
var capacity *CapacityError
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) || !errors.As(err, &capacity) || capacity.BackendID != "limited" || !errors.Is(err, ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("mapped owned capacity = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed prompts/*.yaml schemas/*.json
|
//go:embed prompts/*.yaml schemas/*.json
|
||||||
@@ -84,19 +84,21 @@ func run() (summary, error) {
|
|||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
backendID, proposalModel, assessmentModel := "offline-backend", "proposal-model", "assessment-model"
|
||||||
|
backend := promptkit.LocalBackend("https://example.invalid/v1", 1)
|
||||||
|
backend.ID = backendID
|
||||||
inputs = append(inputs,
|
inputs = append(inputs,
|
||||||
configkit.Input{Source: promptkit.BackendSource("example backend", map[string]promptkit.BackendConfig{
|
configkit.Input{Source: promptkit.BackendSource("example backend", []promptkit.BackendConfig{
|
||||||
backendID: promptkit.LocalBackend("https://example.invalid/v1", 1),
|
backend,
|
||||||
})},
|
})},
|
||||||
configkit.Input{Source: promptkit.ProfileSource("example profiles", map[string]promptkit.ProfileConfig{
|
configkit.Input{Source: promptkit.ProfileSource("example profiles", []promptkit.ProfileConfig{
|
||||||
"proposal-profile": {Backend: &backendID, Model: &proposalModel},
|
{ID: "proposal-profile", Backend: &backendID, Model: &proposalModel},
|
||||||
"assessment-profile": {Backend: &backendID, Model: &assessmentModel},
|
{ID: "assessment-profile", Backend: &backendID, Model: &assessmentModel},
|
||||||
})},
|
})},
|
||||||
)
|
)
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}},
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
}, promptkit.WithSchemaFS(assets, "schemas"))
|
}, promptkit.WithBundledOpenRouter(false), promptkit.WithSchemaFS(assets, "schemas")) // Keep the example's configuration self-contained.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary{}, err
|
return summary{}, err
|
||||||
}
|
}
|
||||||
@@ -156,13 +158,16 @@ func run() (summary, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
value, err := run()
|
if err := execute(os.Stdout); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execute(out io.Writer) error {
|
||||||
|
value, err := run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewEncoder(out).Encode(value)
|
||||||
|
}
|
||||||
|
|||||||
30
examples/go-library/observe/main_test.go
Normal file
30
examples/go-library/observe/main_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestObserveExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := execute(&output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
ProposalAttempts *int `json:"proposal_attempts"`
|
||||||
|
ProposalRetries *int `json:"proposal_transient_retries"`
|
||||||
|
UsageComplete *bool `json:"proposal_usage_complete"`
|
||||||
|
AssessmentAttempts *int `json:"assessment_attempts"`
|
||||||
|
AssessmentRetries *int `json:"assessment_transient_retries"`
|
||||||
|
Outcome string `json:"assessment_outcome"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ProposalAttempts == nil || *got.ProposalAttempts != 2 || got.ProposalRetries == nil || *got.ProposalRetries != 1 ||
|
||||||
|
got.UsageComplete == nil || *got.UsageComplete || got.AssessmentAttempts == nil || *got.AssessmentAttempts != 1 ||
|
||||||
|
got.AssessmentRetries == nil || *got.AssessmentRetries != 0 || got.Outcome != "accepted" {
|
||||||
|
t.Fatalf("unexpected observation summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.assessment:
|
- id: example.assessment
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: assessment-profile
|
default_profile: assessment-profile
|
||||||
description: Assess a structured proposal independently of schema validity.
|
description: Assess a structured proposal independently of schema validity.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.proposal:
|
- id: example.proposal
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: proposal-profile
|
default_profile: proposal-profile
|
||||||
description: Propose a bounded improvement from a synthetic idea.
|
description: Propose a bounded improvement from a synthetic idea.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://review.example.invalid/v1
|
endpoint: https://review.example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
audit:
|
- id: audit
|
||||||
backend: review/offline
|
backend: review/offline
|
||||||
model: review-model
|
model: review-model
|
||||||
prompts:
|
prompts:
|
||||||
check:
|
- id: check
|
||||||
default_profile: review/audit
|
default_profile: review/audit
|
||||||
description: Inspect a review item without contacting a model provider.
|
description: Inspect a review item without contacting a model provider.
|
||||||
messages:
|
messages:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version: "1"
|
version: "2"
|
||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: fragments/resources.yaml
|
- path: fragments/resources.yaml
|
||||||
|
required: true
|
||||||
|
- path: fragments/prepare.yaml
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
required: true
|
||||||
release_version: "1.4.0"
|
release_version: "1.4.0"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
id: prepare
|
||||||
|
default_profile: support/fast
|
||||||
|
description: Prepare a prompt without contacting a model provider.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content_file: ../messages/note.md
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
@@ -1,28 +1,11 @@
|
|||||||
backends:
|
backends:
|
||||||
offline:
|
- id: offline
|
||||||
endpoint: https://example.invalid/v1
|
endpoint: https://example.invalid/v1
|
||||||
profiles:
|
profiles:
|
||||||
base:
|
- id: base
|
||||||
backend: support/offline
|
backend: support/offline
|
||||||
model: offline-model
|
model: offline-model
|
||||||
timeout_seconds: 90
|
timeout_seconds: 90
|
||||||
fast:
|
- id: fast
|
||||||
extends: base
|
extends: {id: base}
|
||||||
timeout_seconds: 60
|
timeout_seconds: 60
|
||||||
prompts:
|
|
||||||
prepare:
|
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: support/fast
|
|
||||||
description: Prepare a prompt without contacting a model provider.
|
|
||||||
inputs:
|
|
||||||
- name: note
|
|
||||||
required: true
|
|
||||||
content_type: text/plain
|
|
||||||
messages:
|
|
||||||
- role: system
|
|
||||||
content: Summarize the note in one sentence.
|
|
||||||
- role: user
|
|
||||||
content_file: ../messages/note.md
|
|
||||||
output:
|
|
||||||
format: text
|
|
||||||
validation_mode: basic
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# A later input intentionally replaces only the inherited profile timeout.
|
# A later input replaces the profile with an inherited variant.
|
||||||
profiles:
|
profiles:
|
||||||
fast:
|
- id: fast
|
||||||
|
extends: {id: base}
|
||||||
timeout_seconds: 30
|
timeout_seconds: 30
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type summary struct {
|
type summary struct {
|
||||||
PromptID string `json:"prompt_id"`
|
PromptID string `json:"prompt_id"`
|
||||||
PromptVersion *string `json:"prompt_version,omitempty"`
|
|
||||||
SelectedProfile string `json:"selected_profile"`
|
SelectedProfile string `json:"selected_profile"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
@@ -29,62 +30,66 @@ type collectionSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/prepare", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
support, err := promptkit.CollectionInput(
|
support, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
review, err := promptkit.CollectionInput(
|
review, err := promptkit.CollectionInput(
|
||||||
"review",
|
"review",
|
||||||
configkit.File("examples/go-library/prepare/collections/review/collection.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "review", "collection.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
override, err := promptkit.CollectionInput(
|
override, err := promptkit.CollectionInput(
|
||||||
"support",
|
"support",
|
||||||
configkit.File("examples/go-library/prepare/collections/support/operator-overrides.yaml"),
|
configkit.File(filepath.Join(exampleDir, "collections", "support", "operator-overrides.yaml")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{support, review}},
|
{Inputs: []configkit.Input{support, review}},
|
||||||
{Inputs: []configkit.Input{override}},
|
{Inputs: []configkit.Input{override}},
|
||||||
},
|
},
|
||||||
}})
|
}}, promptkit.WithBundledOpenRouter(false)) // Keep this example's inventory caller-owned.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
|
||||||
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
scoped, err := engine.WithNamespaces(promptkit.Namespaces{
|
||||||
Prompt: "support", Profile: "support", Backend: "support",
|
Prompt: "support", Profile: "support", Backend: "support",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
view, err := engine.InspectConfiguration()
|
view, err := engine.InspectConfiguration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if _, found := view.Explain(configkit.Keys(
|
if _, found := view.Explain(configkit.Keys("collections", "support", "profiles").Index(1).Key("timeout_seconds")); !found {
|
||||||
"collections", "support", "profiles", "fast", "timeout_seconds",
|
return fmt.Errorf("collection profile override was not found")
|
||||||
)); !found {
|
|
||||||
exit(fmt.Errorf("collection profile override was not found"))
|
|
||||||
}
|
}
|
||||||
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
requirements, err := scoped.InspectRequirements(ctx, promptkit.RequirementInspectionRequest{
|
||||||
PromptID: "prepare",
|
PromptID: "prepare",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
if !requirements.Complete || len(requirements.Inputs) != 1 ||
|
||||||
requirements.Inputs[0].Name != "note" ||
|
requirements.Inputs[0].Name != "note" ||
|
||||||
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
requirements.Inputs[0].Kind != promptkit.RequirementRequired {
|
||||||
exit(fmt.Errorf("required note input was not discovered before preparation"))
|
return fmt.Errorf("required note input was not discovered before preparation")
|
||||||
}
|
}
|
||||||
|
|
||||||
request := promptkit.RunRequest{
|
request := promptkit.RunRequest{
|
||||||
@@ -95,39 +100,39 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
if err := scoped.ValidateRequest(ctx, request); err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
prepared, err := scoped.Prepare(ctx, request)
|
prepared, err := scoped.Prepare(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
profile, err := scoped.InspectProfile(ctx, "fast")
|
profile, err := scoped.InspectProfile(ctx, "fast")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
|
if profile.TimeoutSeconds == nil || *profile.TimeoutSeconds != 30 {
|
||||||
exit(fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"later profile override was not applied: timeout is %d seconds",
|
"later profile override was not applied: timeout is %v",
|
||||||
profile.EffectiveModelParams.TimeoutSeconds,
|
profile.TimeoutSeconds,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
namespaces, err := scoped.ListNamespaces(ctx)
|
namespaces, err := scoped.ListNamespaces(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
collections, err := scoped.ListCollections(ctx)
|
collections, err := scoped.ListCollections(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPrompts, err := scoped.ListPrompts(ctx)
|
scopedPrompts, err := scoped.ListPrompts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
reviewPrompts, err := scoped.ListPrompts(ctx, promptkit.NamespaceFilter{
|
||||||
Mode: promptkit.NamespaceNamed, Name: "review",
|
Mode: promptkit.NamespaceNamed, Name: "review",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
scopedPromptIDs := make([]string, len(scopedPrompts))
|
scopedPromptIDs := make([]string, len(scopedPrompts))
|
||||||
for index := range scopedPrompts {
|
for index := range scopedPrompts {
|
||||||
@@ -144,25 +149,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
PromptID: prepared.PromptID,
|
PromptID: prepared.PromptID,
|
||||||
PromptVersion: prepared.PromptVersion,
|
|
||||||
SelectedProfile: prepared.SelectedProfileID,
|
SelectedProfile: prepared.SelectedProfileID,
|
||||||
Model: prepared.EffectiveModelParams.Model,
|
Model: prepared.EffectiveModelParams.Model,
|
||||||
MessageCount: len(prepared.Messages),
|
MessageCount: len(prepared.Messages),
|
||||||
ProfileTimeout: profile.EffectiveModelParams.TimeoutSeconds,
|
ProfileTimeout: *profile.TimeoutSeconds,
|
||||||
Namespaces: namespaces,
|
Namespaces: namespaces,
|
||||||
Collections: collectionSummaries,
|
Collections: collectionSummaries,
|
||||||
ScopedPrompts: scopedPromptIDs,
|
ScopedPrompts: scopedPromptIDs,
|
||||||
ReviewPrompts: reviewPromptIDs,
|
ReviewPrompts: reviewPromptIDs,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
45
examples/go-library/prepare/main_test.go
Normal file
45
examples/go-library/prepare/main_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
Profile string `json:"selected_profile"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
Timeout int `json:"profile_timeout_seconds"`
|
||||||
|
Namespaces []string `json:"namespaces"`
|
||||||
|
Collections []map[string]json.RawMessage `json:"collections"`
|
||||||
|
ScopedPrompts []string `json:"scoped_prompts"`
|
||||||
|
ReviewPrompts []string `json:"review_prompts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PromptID != "support/prepare" || got.Profile != "support/fast" || got.Model != "offline-model" ||
|
||||||
|
got.MessageCount <= 0 || got.Timeout != 30 || !reflect.DeepEqual(got.Namespaces, []string{"review", "support"}) ||
|
||||||
|
!reflect.DeepEqual(got.ScopedPrompts, []string{"support/prepare"}) || !reflect.DeepEqual(got.ReviewPrompts, []string{"review/check"}) || len(got.Collections) != 2 {
|
||||||
|
t.Fatalf("unexpected preparation summary: %+v", got)
|
||||||
|
}
|
||||||
|
for index, want := range []struct{ namespace, release string }{{"review", ""}, {"support", "1.4.0"}} {
|
||||||
|
var namespace string
|
||||||
|
if err := json.Unmarshal(got.Collections[index]["namespace"], &namespace); err != nil || namespace != want.namespace {
|
||||||
|
t.Fatalf("collection namespace = %q, %v", namespace, err)
|
||||||
|
}
|
||||||
|
value, present := got.Collections[index]["release_version"]
|
||||||
|
var release *string
|
||||||
|
if !present || json.Unmarshal(value, &release) != nil || (index == 0 && release != nil) || (index == 1 && (release == nil || *release != want.release)) {
|
||||||
|
t.Fatalf("collection release = %s, present %t; want %q", value, present, want.release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type deterministicClient struct{}
|
type deterministicClient struct{}
|
||||||
@@ -39,41 +41,41 @@ type summary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(context.Background(), "examples/go-library/run", os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, exampleDir string, out io.Writer) error {
|
||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("example profile", []promptkit.ProfileConfig{
|
||||||
"offline-example": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline-example", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.File("examples/go-library/run/prompt.yaml")},
|
Main: &configkit.Input{Source: configkit.File(filepath.Join(exampleDir, "prompt.yaml"))},
|
||||||
}}, promptkit.WithLLMClient(deterministicClient{}))
|
}}, promptkit.WithBundledOpenRouter(false), promptkit.WithLLMClient(deterministicClient{})) // Use only this example's offline configuration.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
PromptID: "example.run",
|
PromptID: "example.run",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"note": promptkit.Inline("Ada finished the migration review."),
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exit(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := json.NewEncoder(os.Stdout)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
if err := encoder.Encode(summary{
|
return encoder.Encode(summary{
|
||||||
Output: result.RawOutput,
|
Output: result.RawOutput,
|
||||||
ValidationStatus: result.Validation.Status,
|
ValidationStatus: result.Validation.Status,
|
||||||
IsValid: result.Validation.IsValid,
|
IsValid: result.Validation.IsValid,
|
||||||
Model: result.ModelName,
|
Model: result.ModelName,
|
||||||
TotalTokens: result.Usage.TotalTokens,
|
TotalTokens: result.Usage.TotalTokens,
|
||||||
}); err != nil {
|
})
|
||||||
exit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exit(err error) {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
examples/go-library/run/main_test.go
Normal file
28
examples/go-library/run/main_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunExample(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := run(t.Context(), ".", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Valid *bool `json:"is_valid"`
|
||||||
|
Status string `json:"validation_status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Tokens *int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Output != "Ada finished the migration review." || got.Valid == nil || !*got.Valid ||
|
||||||
|
got.Status != "passed" || got.Model != "offline-model" || got.Tokens == nil || *got.Tokens != 18 {
|
||||||
|
t.Fatalf("unexpected execution summary: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
prompts:
|
prompts:
|
||||||
example.run:
|
- id: example.run
|
||||||
version: "1.0.0"
|
|
||||||
default_profile: offline-example
|
default_profile: offline-example
|
||||||
description: Run a prompt with a deterministic injected model client.
|
description: Run a prompt with a deterministic injected model client.
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const capturedPromptDocument = `
|
const capturedPromptDocument = `
|
||||||
prompts:
|
prompts:
|
||||||
captured:
|
- id: captured
|
||||||
version: "1"
|
|
||||||
default_profile: offline
|
default_profile: offline
|
||||||
session_id: '{{.session}}'
|
session_id: '{{.session}}'
|
||||||
messages:
|
messages:
|
||||||
@@ -40,8 +39,8 @@ func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
|||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{
|
Underlays: []configkit.Underlay{
|
||||||
{Inputs: inputs},
|
{Inputs: inputs},
|
||||||
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}},
|
})}}},
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -145,12 +144,11 @@ func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *tes
|
|||||||
endpoint, model := "https://example.invalid/v1", "offline-model"
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
schemas := fstest.MapFS{}
|
schemas := fstest.MapFS{}
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{
|
||||||
"offline": {Endpoint: &endpoint, Model: &model},
|
{ID: "offline", Endpoint: &endpoint, Model: &model},
|
||||||
})}}}},
|
})}}}},
|
||||||
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||||
"prompts": map[string]any{"structured": map[string]any{
|
"prompts": []any{map[string]any{"id": "structured",
|
||||||
"version": "1",
|
|
||||||
"default_profile": "offline",
|
"default_profile": "offline",
|
||||||
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"go.maximumdirect.net/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuiltInGenerationError(t *testing.T) {
|
func TestBuiltInGenerationError(t *testing.T) {
|
||||||
|
|||||||
12
go.mod
12
go.mod
@@ -1,18 +1,18 @@
|
|||||||
module gitea.maximumdirect.net/eric/promptkit
|
module go.maximumdirect.net/promptkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
go.maximumdirect.net/configkit v1.1.0
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
go.maximumdirect.net/releasekit v0.9.0 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
tool go.maximumdirect.net/releasekit/cmd/releasekit
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,13 +1,13 @@
|
|||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0 h1:UzXN2ZMnEkcL0O4YSLqD9qd9M1gb/NWCHQR/H8AaQzA=
|
||||||
|
go.maximumdirect.net/configkit v1.1.0/go.mod h1:s1zLJ9GGB/1Q4a+aOHMLISonJxWFgNWpA6SpPlOafeA=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
|
||||||
|
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ package artifact
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var artifactFileReadBenchmarkSink *domain.Artifact
|
var artifactFileReadBenchmarkSink *domain.Artifact
|
||||||
@@ -153,9 +151,12 @@ func checkArtifactFileRead(b *testing.B, reader Reader, ref domain.ArtifactRef,
|
|||||||
if !bytes.Equal(artifact.Body, want) || artifact.Size != int64(len(want)) {
|
if !bytes.Equal(artifact.Body, want) || artifact.Size != int64(len(want)) {
|
||||||
b.Fatalf("artifact size=%d body length=%d, want %d", artifact.Size, len(artifact.Body), len(want))
|
b.Fatalf("artifact size=%d body length=%d, want %d", artifact.Size, len(artifact.Body), len(want))
|
||||||
}
|
}
|
||||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(want))
|
inline, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: string(want)})
|
||||||
if artifact.Hash != wantHash {
|
if err != nil {
|
||||||
b.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if inline.Hash == "" || artifact.Hash != inline.Hash {
|
||||||
|
b.Fatalf("file and inline hashes differ: %q/%q", artifact.Hash, inline.Hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ package artifact
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
||||||
@@ -284,9 +282,12 @@ func TestFileReaderUsesOpenedSizeOnlyAsACapacityHint(t *testing.T) {
|
|||||||
if artifact.Name != filepath.Base(filePath) || artifact.URI != filePath || artifact.ContentType == "" {
|
if artifact.Name != filepath.Base(filePath) || artifact.URI != filePath || artifact.ContentType == "" {
|
||||||
t.Fatalf("unexpected artifact metadata: name=%q URI=%q content type=%q", artifact.Name, artifact.URI, artifact.ContentType)
|
t.Fatalf("unexpected artifact metadata: name=%q URI=%q content type=%q", artifact.Name, artifact.URI, artifact.ContentType)
|
||||||
}
|
}
|
||||||
wantHash := fmt.Sprintf("%x", sha256.Sum256(tc.body))
|
inline, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: string(tc.body)})
|
||||||
if artifact.Hash != wantHash {
|
if err != nil {
|
||||||
t.Fatalf("artifact hash=%q, want %q", artifact.Hash, wantHash)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inline.Hash == "" || artifact.Hash != inline.Hash {
|
||||||
|
t.Fatalf("file and inline hashes differ: %q/%q", artifact.Hash, inline.Hash)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,17 +27,13 @@ type Registry struct {
|
|||||||
// NewEffectiveRegistry stores already-validated effective backend definitions
|
// NewEffectiveRegistry stores already-validated effective backend definitions
|
||||||
// under their exact configuration keys. Configuration validation and
|
// under their exact configuration keys. Configuration validation and
|
||||||
// normalization precede this ownership boundary.
|
// normalization precede this ownership boundary.
|
||||||
func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) {
|
func NewEffectiveRegistry(definitions map[string]domain.Backend) *Registry {
|
||||||
registry := &Registry{backends: make(map[string]domain.Backend, len(definitions))}
|
registry := &Registry{backends: make(map[string]domain.Backend, len(definitions))}
|
||||||
for id, definition := range definitions {
|
for id, definition := range definitions {
|
||||||
definition.ID = id
|
definition.ID = id
|
||||||
cloned, err := domain.CloneBackend(definition)
|
registry.backends[id] = domain.CloneBackend(definition)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("backend %q: %w", id, err)
|
|
||||||
}
|
|
||||||
registry.backends[id] = cloned
|
|
||||||
}
|
}
|
||||||
return registry, nil
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBackend returns a defensive copy of the backend registered with id.
|
// GetBackend returns a defensive copy of the backend registered with id.
|
||||||
@@ -49,11 +45,7 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||||
}
|
}
|
||||||
cloned, err := domain.CloneBackend(definition)
|
return domain.CloneBackend(definition), nil
|
||||||
if err != nil {
|
|
||||||
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
|
||||||
}
|
|
||||||
return cloned, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CapacityPolicies returns a copy of the normalized policies for limited
|
// CapacityPolicies returns a copy of the normalized policies for limited
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
||||||
@@ -22,16 +22,13 @@ func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
|||||||
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
|
Kind: &authenticationKind, Endpoint: &authenticationEndpoint,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
registry, err := backend.NewEffectiveRegistry(map[string]domain.Backend{
|
registry := backend.NewEffectiveRegistry(map[string]domain.Backend{
|
||||||
" local.v1 ": {
|
" local.v1 ": {
|
||||||
Endpoint: "https://backend.example/v1", ExtraParams: params,
|
Endpoint: "https://backend.example/v1", ExtraParams: params,
|
||||||
ConcurrencyLimit: 2, QueueCapacity: 3, QueueCapacitySet: true,
|
ConcurrencyLimit: 2, QueueCapacity: 3,
|
||||||
SmokeTests: smokeTests,
|
SmokeTests: smokeTests,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
params["nested"].(map[string]any)["enabled"] = false
|
params["nested"].(map[string]any)["enabled"] = false
|
||||||
defaultProfile = "mutated-profile"
|
defaultProfile = "mutated-profile"
|
||||||
reachabilityEndpoint = "https://mutated.example/ready"
|
reachabilityEndpoint = "https://mutated.example/ready"
|
||||||
@@ -79,10 +76,7 @@ func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectiveRegistryMissingBackend(t *testing.T) {
|
func TestEffectiveRegistryMissingBackend(t *testing.T) {
|
||||||
registry, err := backend.NewEffectiveRegistry(nil)
|
registry := backend.NewEffectiveRegistry(nil)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := registry.GetBackend("missing"); !errors.Is(err, backend.ErrBackendNotFound) {
|
if _, err := registry.GetBackend("missing"); !errors.Is(err, backend.ErrBackendNotFound) {
|
||||||
t.Fatalf("missing backend error = %v", err)
|
t.Fatalf("missing backend error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package capacity
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
manager *Manager
|
|
||||||
next llm.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient wraps next with configured active-generation limits. A nil manager
|
|
||||||
// leaves next unchanged.
|
|
||||||
func NewClient(manager *Manager, next llm.Client) llm.Client {
|
|
||||||
if manager == nil {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return &client{
|
|
||||||
manager: manager,
|
|
||||||
next: next,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) Generate(
|
|
||||||
ctx context.Context,
|
|
||||||
req domain.GenerateRequest,
|
|
||||||
) (*domain.GenerateResponse, error) {
|
|
||||||
if c.manager.getPool(req.Target.BackendID) == nil {
|
|
||||||
return c.next.Generate(ctx, req)
|
|
||||||
}
|
|
||||||
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
return c.next.Generate(ctx, req)
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,14 @@ package capacity
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type generateResult struct {
|
type generateResult struct {
|
||||||
@@ -24,6 +23,18 @@ type clientFunc func(
|
|||||||
domain.GenerateRequest,
|
domain.GenerateRequest,
|
||||||
) (*domain.GenerateResponse, error)
|
) (*domain.GenerateResponse, error)
|
||||||
|
|
||||||
|
// newTestClient exercises one acquisition around a controlled call.
|
||||||
|
func newTestClient(manager *Manager, next llm.Client) llm.Client {
|
||||||
|
return clientFunc(func(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
release, err := manager.Acquire(ctx, req.Target.BackendID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
return next.Generate(ctx, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (f clientFunc) Generate(
|
func (f clientFunc) Generate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req domain.GenerateRequest,
|
req domain.GenerateRequest,
|
||||||
@@ -167,7 +178,7 @@ func TestClientLimitsPeakConcurrencyAndServesWaitersFIFO(t *testing.T) {
|
|||||||
"second": secondRelease,
|
"second": secondRelease,
|
||||||
"third": thirdRelease,
|
"third": thirdRelease,
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
first := generateAsync(client, context.Background(), "limited", "first")
|
first := generateAsync(client, context.Background(), "limited", "first")
|
||||||
if got := receiveStarted(t, next.started); got != "first" {
|
if got := receiveStarted(t, next.started); got != "first" {
|
||||||
@@ -212,7 +223,7 @@ func TestClientPeakConcurrencyDoesNotExceedConfiguredLimit(t *testing.T) {
|
|||||||
releases[string(rune('a'+i))] = gate
|
releases[string(rune('a'+i))] = gate
|
||||||
}
|
}
|
||||||
next := newBlockingClient(releases)
|
next := newBlockingClient(releases)
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
results := make([]<-chan generateResult, 0, len(releases))
|
results := make([]<-chan generateResult, 0, len(releases))
|
||||||
for id := range releases {
|
for id := range releases {
|
||||||
@@ -257,7 +268,7 @@ func TestClientRemovesCanceledWaiters(t *testing.T) {
|
|||||||
"three": make(chan struct{}),
|
"three": make(chan struct{}),
|
||||||
}
|
}
|
||||||
next := newBlockingClient(releases)
|
next := newBlockingClient(releases)
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
holder := generateAsync(client, context.Background(), "limited", "holder")
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
if got := receiveStarted(t, next.started); got != "holder" {
|
if got := receiveStarted(t, next.started); got != "holder" {
|
||||||
@@ -321,7 +332,7 @@ func TestClientGrantCancellationRaceDoesNotLeakPermit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return &domain.GenerateResponse{Content: req.Prompt.SessionID}, nil
|
return &domain.GenerateResponse{Content: req.Prompt.SessionID}, nil
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
holder := generateAsync(client, context.Background(), "limited", "holder")
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
waitForActiveCount(t, manager, "limited", 1)
|
waitForActiveCount(t, manager, "limited", 1)
|
||||||
@@ -394,7 +405,7 @@ func TestClientUsesIndependentPoolsAndUnlimitedFastPaths(t *testing.T) {
|
|||||||
"alpha": alphaRelease,
|
"alpha": alphaRelease,
|
||||||
"beta": betaRelease,
|
"beta": betaRelease,
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
|
|
||||||
alpha := generateAsync(client, context.Background(), "alpha", "alpha")
|
alpha := generateAsync(client, context.Background(), "alpha", "alpha")
|
||||||
beta := generateAsync(client, context.Background(), "beta", "beta")
|
beta := generateAsync(client, context.Background(), "beta", "beta")
|
||||||
@@ -423,63 +434,6 @@ func TestClientUsesIndependentPoolsAndUnlimitedFastPaths(t *testing.T) {
|
|||||||
t.Fatalf("unlimited backend %q response=(%#v, %v)", backendID, response, err)
|
t.Fatalf("unlimited backend %q response=(%#v, %v)", backendID, response, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := NewClient(nil, next); got != next {
|
|
||||||
t.Fatal("nil manager did not return the wrapped client unchanged")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientPreservesRequestsResponsesAndErrors(t *testing.T) {
|
|
||||||
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
|
||||||
"limited": {ConcurrencyLimit: 1},
|
|
||||||
})
|
|
||||||
request := domain.GenerateRequest{
|
|
||||||
Prompt: domain.RenderedPrompt{
|
|
||||||
SessionID: "session",
|
|
||||||
Messages: []domain.RenderedMessage{
|
|
||||||
{Role: "user", Content: "content"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Target: domain.ExecutionTarget{
|
|
||||||
BackendID: "limited",
|
|
||||||
Model: "model",
|
|
||||||
ExtraParams: map[string]any{"key": "value"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response := &domain.GenerateResponse{
|
|
||||||
Content: "output",
|
|
||||||
Usage: domain.TokenUsage{TotalTokens: 7},
|
|
||||||
}
|
|
||||||
collaboratorErr := errors.New("collaborator failure")
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response *domain.GenerateResponse
|
|
||||||
err error
|
|
||||||
}{
|
|
||||||
{name: "successful response", response: response},
|
|
||||||
{name: "nil response"},
|
|
||||||
{name: "collaborator error", response: response, err: collaboratorErr},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var captured domain.GenerateRequest
|
|
||||||
next := clientFunc(func(
|
|
||||||
_ context.Context,
|
|
||||||
req domain.GenerateRequest,
|
|
||||||
) (*domain.GenerateResponse, error) {
|
|
||||||
captured = req
|
|
||||||
return tc.response, tc.err
|
|
||||||
})
|
|
||||||
gotResponse, gotErr := NewClient(manager, next).Generate(context.Background(), request)
|
|
||||||
if !reflect.DeepEqual(captured, request) {
|
|
||||||
t.Fatalf("request changed: %#v", captured)
|
|
||||||
}
|
|
||||||
if gotResponse != tc.response || gotErr != tc.err {
|
|
||||||
t.Fatalf("response=(%p, %v), want (%p, %v)",
|
|
||||||
gotResponse, gotErr, tc.response, tc.err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
||||||
@@ -496,7 +450,7 @@ func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return &domain.GenerateResponse{Content: "recovered"}, nil
|
return &domain.GenerateResponse{Content: "recovered"}, nil
|
||||||
})
|
})
|
||||||
client := NewClient(manager, next)
|
client := newTestClient(manager, next)
|
||||||
request := domain.GenerateRequest{
|
request := domain.GenerateRequest{
|
||||||
Target: domain.ExecutionTarget{BackendID: "limited"},
|
Target: domain.ExecutionTarget{BackendID: "limited"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const benchmarkDefinitionCount = 128
|
const benchmarkDefinitionCount = 128
|
||||||
@@ -31,13 +31,26 @@ type configurationShape struct {
|
|||||||
|
|
||||||
func benchmarkPrompt(profileID string) map[string]any {
|
func benchmarkPrompt(profileID string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "1",
|
|
||||||
"default_profile": profileID,
|
"default_profile": profileID,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func benchmarkRecords(values map[string]any) []any {
|
||||||
|
records := make([]any, 0, len(values))
|
||||||
|
for _, id := range sortedKeys(values) {
|
||||||
|
value := values[id].(map[string]any)
|
||||||
|
record := make(map[string]any, len(value)+1)
|
||||||
|
for key, field := range value {
|
||||||
|
record[key] = field
|
||||||
|
}
|
||||||
|
record["id"] = id
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
func independentDefinitions(count int) configurationShape {
|
func independentDefinitions(count int) configurationShape {
|
||||||
profiles := make(map[string]any, count)
|
profiles := make(map[string]any, count)
|
||||||
prompts := make(map[string]any, count)
|
prompts := make(map[string]any, count)
|
||||||
@@ -48,7 +61,7 @@ func independentDefinitions(count int) configurationShape {
|
|||||||
prompts[promptID] = benchmarkPrompt(profileID)
|
prompts[promptID] = benchmarkPrompt(profileID)
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": profiles, "prompts": prompts})},
|
main: configkit.Input{Source: configkit.Mapping("independent definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)})},
|
||||||
inputEntries: count * 2,
|
inputEntries: count * 2,
|
||||||
effectiveEntries: count * 2,
|
effectiveEntries: count * 2,
|
||||||
tiers: 1,
|
tiers: 1,
|
||||||
@@ -71,7 +84,7 @@ func fileBackedDefinitions(count int) configurationShape {
|
|||||||
})
|
})
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{
|
main: configkit.Input{
|
||||||
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": profiles, "prompts": prompts}),
|
Source: configkit.Mapping("file-backed definitions", map[string]any{"profiles": benchmarkRecords(profiles), "prompts": benchmarkRecords(prompts)}),
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
|
||||||
},
|
},
|
||||||
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
inputEntries: count * 2, effectiveEntries: count * 2, tiers: 1,
|
||||||
@@ -85,12 +98,12 @@ func sharedBaseDefinitions(count int) configurationShape {
|
|||||||
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
"extra_params": map[string]any{"provider": map[string]any{"mode": "shared", "limit": 4}},
|
||||||
}
|
}
|
||||||
for index := range count {
|
for index := range count {
|
||||||
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": "base", "temperature": float64(index%10) / 10}
|
profiles[fmt.Sprintf("child-%03d", index)] = map[string]any{"extends": map[string]any{"id": "base"}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inherited definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"shared": benchmarkPrompt(fmt.Sprintf("child-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count + 2,
|
inputEntries: count + 2,
|
||||||
effectiveEntries: count + 2,
|
effectiveEntries: count + 2,
|
||||||
@@ -106,19 +119,19 @@ func layeredDefinitions(count int) configurationShape {
|
|||||||
for index := range count {
|
for index := range count {
|
||||||
id := fmt.Sprintf("profile-%03d", index)
|
id := fmt.Sprintf("profile-%03d", index)
|
||||||
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
base[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}
|
||||||
operator[id] = map[string]any{"model": "operator-model"}
|
operator[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model"}
|
||||||
programmatic[id] = map[string]any{"extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
programmatic[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}}
|
||||||
final[id] = map[string]any{"temperature": float64(index%10) / 10}
|
final[id] = map[string]any{"endpoint": "https://example.test/v1", "model": "operator-model", "extra_params": map[string]any{"provider": map[string]any{"mode": "programmatic"}}, "temperature": float64(index%10) / 10}
|
||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
underlays: []configkit.Underlay{
|
underlays: []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": base})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("fallback", map[string]any{"profiles": benchmarkRecords(base)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": operator})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{"profiles": benchmarkRecords(operator)})}}},
|
||||||
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": programmatic})}}},
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic", map[string]any{"profiles": benchmarkRecords(programmatic)})}}},
|
||||||
},
|
},
|
||||||
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||||
"profiles": final,
|
"profiles": benchmarkRecords(final),
|
||||||
"prompts": map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))},
|
"prompts": benchmarkRecords(map[string]any{"layered": benchmarkPrompt(fmt.Sprintf("profile-%03d", count-1))}),
|
||||||
})},
|
})},
|
||||||
inputEntries: count*4 + 1,
|
inputEntries: count*4 + 1,
|
||||||
effectiveEntries: count + 1,
|
effectiveEntries: count + 1,
|
||||||
@@ -132,8 +145,8 @@ func BenchmarkCollectionFileBackedConstruction(b *testing.B) {
|
|||||||
collections := make(map[string]any, count)
|
collections := make(map[string]any, count)
|
||||||
for index := range count {
|
for index := range count {
|
||||||
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
collections[fmt.Sprintf("collection-%03d", index)] = map[string]any{
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"example": map[string]any{
|
map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
"messages": []any{map[string]any{"role": "user", "content_file": "body.md"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||||
},
|
},
|
||||||
@@ -240,8 +253,8 @@ func inspectionDefinitions(extraParams map[string]any) configurationShape {
|
|||||||
}
|
}
|
||||||
return configurationShape{
|
return configurationShape{
|
||||||
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
main: configkit.Input{Source: configkit.Mapping("inspection definitions", map[string]any{
|
||||||
"profiles": profiles,
|
"profiles": benchmarkRecords(profiles),
|
||||||
"prompts": prompts,
|
"prompts": benchmarkRecords(prompts),
|
||||||
})},
|
})},
|
||||||
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
inputEntries: 2, effectiveEntries: 2, tiers: 1,
|
||||||
}
|
}
|
||||||
@@ -262,7 +275,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
definition, hash, err := result.GetPromptSnapshot(ctx, "inspection")
|
||||||
if err != nil || definition.ID != "inspection" || definition.Version == nil || *definition.Version != "1" || hash == "" ||
|
if err != nil || definition.ID != "inspection" || hash == "" ||
|
||||||
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
len(definition.Inputs) != 1 || definition.Inputs[0].Name != "transcript" || !definition.Inputs[0].Required {
|
||||||
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
b.Fatalf("prompt lookup fixture is invalid: definition=%+v hash=%q err=%v", definition, hash, err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +285,7 @@ func BenchmarkInspectionSelection(b *testing.B) {
|
|||||||
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
b.Fatalf("profile lookup fixture is invalid: profile=%+v err=%v", profile, err)
|
||||||
}
|
}
|
||||||
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
inspection, err := result.GetPromptInspection(ctx, "inspection")
|
||||||
if err != nil || inspection.PromptID != "inspection" || inspection.PromptVersion == nil || *inspection.PromptVersion != "1" ||
|
if err != nil || inspection.PromptID != "inspection" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "transcript" {
|
||||||
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
b.Fatalf("prompt inspection fixture is invalid: inspection=%+v err=%v", inspection, err)
|
||||||
}
|
}
|
||||||
|
|||||||
16
internal/configuration/bundled.go
Normal file
16
internal/configuration/bundled.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bundled/openrouter.yaml
|
||||||
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
|
// BundledOpenRouterUnderlay returns the built-in OpenRouter configuration source.
|
||||||
|
func BundledOpenRouterUnderlay() configkit.Underlay {
|
||||||
|
filesystem := configkit.NewFilesystem("Promptkit bundled OpenRouter", bundledFiles)
|
||||||
|
return configkit.Underlay{Inputs: []configkit.Input{{Source: filesystem.File("bundled/openrouter.yaml")}}}
|
||||||
|
}
|
||||||
60
internal/configuration/bundled/openrouter.yaml
Normal file
60
internal/configuration/bundled/openrouter.yaml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
backends:
|
||||||
|
- id: openrouter
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
smoke_tests:
|
||||||
|
default_profile: gpt-luna-latest
|
||||||
|
authentication:
|
||||||
|
kind: openrouter_key
|
||||||
|
profiles:
|
||||||
|
- id: gpt-astra-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-astra-latest
|
||||||
|
- id: gpt-sol-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-sol-latest
|
||||||
|
- id: gpt-terra-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-terra-latest
|
||||||
|
- id: gpt-luna-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~openai/gpt-luna-latest
|
||||||
|
- id: claude-fable-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-fable-latest
|
||||||
|
- id: claude-opus-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-opus-latest
|
||||||
|
- id: claude-sonnet-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-sonnet-latest
|
||||||
|
- id: claude-haiku-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~anthropic/claude-haiku-latest
|
||||||
|
- id: gemini-pro-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~google/gemini-pro-latest
|
||||||
|
- id: gemini-flash-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: ~google/gemini-flash-latest
|
||||||
|
- id: auto
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/auto
|
||||||
|
- id: free
|
||||||
|
backend: openrouter
|
||||||
|
model: openrouter/free
|
||||||
|
- id: auto-low
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: low}]}
|
||||||
|
- id: auto-medium
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: medium}]}
|
||||||
|
- id: auto-high
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: high}]}
|
||||||
|
- id: auto-xhigh
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: xhigh}]}
|
||||||
|
- id: auto-max
|
||||||
|
extends: {id: auto}
|
||||||
|
extra_params: {plugins: [{id: auto-router, cost_tier: max}]}
|
||||||
54
internal/configuration/bundled_test.go
Normal file
54
internal/configuration/bundled_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBundledOpenRouterUnderlay(t *testing.T) {
|
||||||
|
state, err := Load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{BundledOpenRouterUnderlay()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := state.GetBackend("openrouter")
|
||||||
|
if err != nil || backend.Endpoint != "https://openrouter.ai/api/v1" || backend.APIKeyEnv != "OPENROUTER_API_KEY" || backend.SmokeTests == nil || backend.SmokeTests.DefaultProfile == nil || *backend.SmokeTests.DefaultProfile != "gpt-luna-latest" || backend.SmokeTests.Authentication == nil || backend.SmokeTests.Authentication.Kind == nil || *backend.SmokeTests.Authentication.Kind != "openrouter_key" {
|
||||||
|
t.Fatalf("backend = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(state.backendIDs, []string{"openrouter"}) || len(state.promptIDs) != 0 || len(state.collections) != 0 {
|
||||||
|
t.Fatalf("bundle inventories: backends=%v prompts=%v collections=%v", state.backendIDs, state.promptIDs, state.collections)
|
||||||
|
}
|
||||||
|
models := map[string]string{
|
||||||
|
"gpt-astra-latest": "~openai/gpt-astra-latest", "gpt-sol-latest": "~openai/gpt-sol-latest", "gpt-terra-latest": "~openai/gpt-terra-latest", "gpt-luna-latest": "~openai/gpt-luna-latest",
|
||||||
|
"claude-fable-latest": "~anthropic/claude-fable-latest", "claude-opus-latest": "~anthropic/claude-opus-latest", "claude-sonnet-latest": "~anthropic/claude-sonnet-latest", "claude-haiku-latest": "~anthropic/claude-haiku-latest",
|
||||||
|
"gemini-pro-latest": "~google/gemini-pro-latest", "gemini-flash-latest": "~google/gemini-flash-latest", "auto": "openrouter/auto", "free": "openrouter/free",
|
||||||
|
}
|
||||||
|
for _, tier := range []string{"low", "medium", "high", "xhigh", "max"} {
|
||||||
|
models["auto-"+tier] = "openrouter/auto"
|
||||||
|
}
|
||||||
|
if len(state.profileIDs) != len(models) {
|
||||||
|
t.Fatalf("profiles = %v", state.profileIDs)
|
||||||
|
}
|
||||||
|
for id, model := range models {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("profile %s = %+v, %v", id, profile, err)
|
||||||
|
}
|
||||||
|
want := defaults.ExecutionTargetDefault()
|
||||||
|
want.Model, want.BackendID, want.Endpoint, want.APIKeyEnv = model, "openrouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"
|
||||||
|
if tier, ok := strings.CutPrefix(id, "auto-"); ok {
|
||||||
|
want.ExtraParams = map[string]any{"plugins": []any{map[string]any{"id": "auto-router", "cost_tier": tier}}}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(*profile.ResolvedTarget, want) {
|
||||||
|
t.Fatalf("profile %s target = %+v, want %+v", id, *profile.ResolvedTarget, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(state.result.Sources()), "Promptkit bundled OpenRouter") {
|
||||||
|
t.Fatalf("source evidence = %+v", state.result.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inspection is the read-only subset of the retained ConfigKit result used by
|
// Inspection is the read-only subset of the retained ConfigKit result used by
|
||||||
@@ -66,11 +67,16 @@ func secretProviderPath(path configkit.Path) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func providerExtraParamsPath(parts []string) bool {
|
func providerExtraParamsPath(parts []string) bool {
|
||||||
if len(parts) >= 4 && parts[1] != "" &&
|
if len(parts) >= 4 && sequenceIndex(parts[1]) &&
|
||||||
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
(parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" &&
|
||||||
(parts[2] == "backends" || parts[2] == "profiles") &&
|
(parts[2] == "backends" || parts[2] == "profiles") &&
|
||||||
parts[3] != "" && parts[4] == "extra_params"
|
sequenceIndex(parts[3]) && parts[4] == "extra_params"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceIndex(segment string) bool {
|
||||||
|
index, err := strconv.Atoi(segment)
|
||||||
|
return err == nil && index >= 0 && strconv.Itoa(index) == segment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package configuration
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
||||||
@@ -12,15 +12,15 @@ func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) {
|
|||||||
path configkit.Path
|
path configkit.Path
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true},
|
{name: "backend nested secret", path: configkit.Keys("backends").Index(0).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true},
|
{name: "profile nested secret", path: configkit.Keys("profiles").Index(1).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true},
|
{name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends").Index(2).Key("extra_params").Key("nested").Key("Token"), want: true},
|
||||||
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), want: true},
|
{name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles").Index(0).Key("extra_params").Key("authorization"), want: true},
|
||||||
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
{name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false},
|
||||||
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
{name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false},
|
||||||
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
{name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false},
|
||||||
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
{name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false},
|
||||||
{name: "ordinary provider value", path: configkit.Keys("profiles", "assistant", "extra_params", "provider", "mode"), want: false},
|
{name: "ordinary provider value", path: configkit.Keys("profiles").Index(0).Key("extra_params").Key("provider").Key("mode"), want: false},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
152
internal/configuration/interpretation_test.go
Normal file
152
internal/configuration/interpretation_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTargetResolutionNormalizesNestedValues(t *testing.T) {
|
||||||
|
state, err := load(mapping("padded target", map[string]any{
|
||||||
|
"backends": []any{map[string]any{
|
||||||
|
"id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 1,
|
||||||
|
"smoke_tests": map[string]any{
|
||||||
|
"reachability": map[string]any{"endpoint": " https://example.invalid/health "},
|
||||||
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": " https://example.invalid/key "},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local", "service_tier": " standard ", "api_key_env": " TOKEN "}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := state.GetBackend("local")
|
||||||
|
if err != nil || backend.Endpoint != "https://example.invalid/v1" || backend.QueueCapacity != 1024 ||
|
||||||
|
backend.SmokeTests == nil || *backend.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
|
||||||
|
*backend.SmokeTests.Authentication.Kind != "none" || *backend.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" {
|
||||||
|
t.Fatalf("interpreted backend = %+v, %v", backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "partial")
|
||||||
|
if err != nil || profile.ResolvedTarget.ServiceTier != "standard" || profile.ResolvedTarget.APIKeyEnv != "TOKEN" {
|
||||||
|
t.Fatalf("interpreted profile = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Backends[0]
|
||||||
|
if *composed.Endpoint != "https://example.invalid/v1" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 ||
|
||||||
|
*composed.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" ||
|
||||||
|
*composed.SmokeTests.Authentication.Kind != "none" || *composed.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" ||
|
||||||
|
*state.result.Config.Profiles[0].ServiceTier != "standard" || *state.result.Config.Profiles[0].APIKeyEnv != "TOKEN" {
|
||||||
|
t.Fatalf("resolved values were not normalized: %+v", state.result.Config)
|
||||||
|
}
|
||||||
|
path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint")
|
||||||
|
field, found := state.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Fatalf("nested field origin changed: %+v", field)
|
||||||
|
}
|
||||||
|
for _, path := range []configkit.Path{
|
||||||
|
configkit.Keys("backends").Index(0).Key("endpoint"),
|
||||||
|
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("reachability").Key("endpoint"),
|
||||||
|
configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("kind"),
|
||||||
|
configkit.Keys("profiles").Index(0).Key("service_tier"),
|
||||||
|
configkit.Keys("profiles").Index(0).Key("api_key_env"),
|
||||||
|
} {
|
||||||
|
field, found := state.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Errorf("normalization at %v: %+v", path, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptResolutionNormalizesOutputAndRejectsDuplicateNames(t *testing.T) {
|
||||||
|
definition := map[string]any{
|
||||||
|
"id": "example", "inputs": []any{map[string]any{"name": " document "}},
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": " schema.json "},
|
||||||
|
}
|
||||||
|
state, err := load(mapping("prompt metadata", map[string]any{"prompts": []any{definition}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, inspection, err := state.prompt("example")
|
||||||
|
if err != nil || selected.Validation.SchemaPath != "schema.json" || inspection.OutputContract.SchemaPath != "schema.json" || selected.Inputs[0].Name != "document" {
|
||||||
|
t.Fatalf("interpreted prompt = %+v, %+v, %v", selected, inspection, err)
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Prompts[0]
|
||||||
|
if *composed.Output.SchemaPath != "schema.json" || composed.Inputs[0].Name != "document" {
|
||||||
|
t.Fatalf("prompt was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
definition["inputs"] = []any{map[string]any{"name": " document "}, map[string]any{"name": "document"}}
|
||||||
|
_, err = load(mapping("duplicate inputs", map[string]any{"prompts": []any{definition}}), nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/inputs/1") || !strings.Contains(err.Error(), "duplicated") {
|
||||||
|
t.Fatalf("duplicate normalized names were not rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptResolutionRecordsMessageFields(t *testing.T) {
|
||||||
|
loadPrompt := func(role, cacheType, ttl string) *loaded {
|
||||||
|
t.Helper()
|
||||||
|
state, err := load(mapping("prompt", map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "example", "inputs": []any{map[string]any{"name": " input ", "content_type": nil, "description": " text "}},
|
||||||
|
"messages": []any{map[string]any{"role": role, "content": "", "cache_control": map[string]any{"type": cacheType, "ttl": ttl}}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
|
}}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
padded := loadPrompt(" USER ", " ephemeral ", " 1h ")
|
||||||
|
canonical := loadPrompt("user", "ephemeral", "1h")
|
||||||
|
definition, inspection, err := padded.prompt("example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other, otherInspection, err := canonical.prompt("example")
|
||||||
|
if err != nil || inspection.PromptHash != otherInspection.PromptHash || definition.Templates[0].Role != other.Templates[0].Role || definition.Templates[0].Content != "" {
|
||||||
|
t.Fatalf("equivalent prompt mismatch: %+v %+v %v", definition, other, err)
|
||||||
|
}
|
||||||
|
base := configkit.Keys("prompts").Index(0)
|
||||||
|
for _, path := range []configkit.Path{base.Key("inputs").Index(0).Key("name"), base.Key("inputs").Index(0).Key("description"), base.Key("messages").Index(0).Key("role"), base.Key("messages").Index(0).Key("cache_control").Key("type"), base.Key("messages").Index(0).Key("cache_control").Key("ttl")} {
|
||||||
|
field, found := padded.result.Metadata().Lookup(path)
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Errorf("normalization at %v: %+v", path, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if padded.promptMetadata["example"][0].contentType != fieldNull {
|
||||||
|
t.Fatal("null input metadata was not retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolutionRecordsTargetDependencies(t *testing.T) {
|
||||||
|
backend := func(id string) map[string]any {
|
||||||
|
return map[string]any{"id": id, "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 2, "queue_capacity": nil}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("targets", map[string]any{
|
||||||
|
"backends": []any{backend("root")},
|
||||||
|
"collections": map[string]any{"scoped": map[string]any{"backends": []any{backend("local")}}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, base := range []configkit.Path{configkit.Keys("backends").Index(0), configkit.Keys("collections", "scoped", "backends").Index(0)} {
|
||||||
|
endpoint, ok := state.result.Explain(base.Key("endpoint"))
|
||||||
|
if !ok || !strings.Contains(endpoint.Effective.YAML, "https://example.invalid/v1") {
|
||||||
|
t.Fatalf("endpoint explanation: %+v", endpoint)
|
||||||
|
}
|
||||||
|
history := state.result.Metadata().History(base.Key("queue_capacity"))
|
||||||
|
found := false
|
||||||
|
for _, change := range history {
|
||||||
|
if change.Origin.Operation == configkit.ChangeDerivedDefault {
|
||||||
|
for _, dependency := range change.Dependencies {
|
||||||
|
if dependency.String() == base.Key("concurrency_limit").String() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("missing queue dependency: %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,36 +2,66 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IdentityFilter selects all identities, one namespace, or unqualified IDs.
|
||||||
|
type IdentityFilter struct {
|
||||||
|
All bool
|
||||||
|
Namespace string
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectIdentities[T any](ctx context.Context, entries []T, filter IdentityFilter, id func(T) string) ([]T, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if filter.All {
|
||||||
|
result := append([]T(nil), entries...)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if filter.Namespace != "" {
|
||||||
|
prefix := filter.Namespace + "/"
|
||||||
|
start := sort.Search(len(entries), func(i int) bool { return id(entries[i]) >= prefix })
|
||||||
|
end := start + sort.Search(len(entries)-start, func(i int) bool { return !strings.HasPrefix(id(entries[start+i]), prefix) })
|
||||||
|
result := append([]T(nil), entries[start:end]...)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
var result []T
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !strings.Contains(id(entry), "/") {
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CollectionIdentity is the captured metadata for one final collection entry.
|
// CollectionIdentity is the captured metadata for one final collection entry.
|
||||||
type CollectionIdentity struct {
|
type CollectionIdentity struct {
|
||||||
Namespace string
|
Namespace string
|
||||||
ReleaseVersion *string
|
ReleaseVersion *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListProfiles(ctx context.Context) ([]string, error) {
|
func (state *loaded) ListProfiles(ctx context.Context, filter IdentityFilter) ([]string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.profileIDs, filter, func(id string) string { return id })
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
profiles := append([]string(nil), state.profileIDs...)
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return profiles, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListBackends(ctx context.Context) ([]string, error) {
|
func (state *loaded) ListBackends(ctx context.Context, filter IdentityFilter) ([]string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.backendIDs, filter, func(id string) string { return id })
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
backends := append([]string(nil), state.backendIDs...)
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return backends, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
func (state *loaded) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) {
|
||||||
@@ -72,16 +102,6 @@ func (state *loaded) ListCollections(ctx context.Context) ([]CollectionIdentity,
|
|||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) {
|
func (state *loaded) ListPrompts(ctx context.Context, filter IdentityFilter) ([]domain.PromptIdentity, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
return selectIdentities(ctx, state.promptIDs, filter, func(entry domain.PromptIdentity) string { return entry.ID })
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
prompts := append([]domain.PromptIdentity(nil), state.promptIDs...)
|
|
||||||
for index := range prompts {
|
|
||||||
prompts[index].Version = stringPointerValue(prompts[index].Version)
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return prompts, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,24 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}},
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1"},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{
|
"collections": map[string]any{
|
||||||
"z": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "z-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"z": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "z-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
"partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local"}}},
|
"partial": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local"}}},
|
||||||
"a": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "a-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}},
|
"a": map[string]any{"profiles": []any{map[string]any{"id": "profile", "backend": "local", "model": "a-model"}}, "prompts": []any{listingPromptDefinition("prompt")}},
|
||||||
" z": map[string]any{"profiles": map[string]any{"profile ": map[string]any{"backend": "local", "model": "spaced-model"}}, "prompts": map[string]any{"prompt ": listingPromptDefinition()}},
|
" z": map[string]any{"profiles": []any{map[string]any{"id": "profile ", "backend": "local", "model": "spaced-model"}}, "prompts": []any{listingPromptDefinition("prompt ")}},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -34,7 +32,7 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
t.Fatalf("backend IDs = %#v, want %#v", backends, wantBackends)
|
t.Fatalf("backend IDs = %#v, want %#v", backends, wantBackends)
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -43,14 +41,14 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles)
|
t.Fatalf("profile IDs = %#v, want %#v", profiles, wantProfiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{
|
wantPrompts := []domain.PromptIdentity{
|
||||||
{ID: " z/prompt ", Version: stringPointer("v1")},
|
{ID: " z/prompt "},
|
||||||
{ID: "a/prompt", Version: stringPointer("v1")},
|
{ID: "a/prompt"},
|
||||||
{ID: "z/prompt", Version: stringPointer("v1")},
|
{ID: "z/prompt"},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
t.Fatalf("prompt identities = %#v, want %#v", prompts, wantPrompts)
|
||||||
@@ -59,68 +57,113 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsAreIndependent(t *testing.T) {
|
func TestLoadedListsAreIndependent(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"backends": map[string]any{"backend": map[string]any{"endpoint": "https://example.test/v1"}},
|
"backends": []any{map[string]any{"id": "backend", "endpoint": "https://example.test/v1"}},
|
||||||
"profiles": map[string]any{"profile": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "profile"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profiles = append(profiles, "caller profile")
|
profiles = append(profiles, "caller profile")
|
||||||
profiles[0] = "changed profile"
|
profiles[0] = "changed profile"
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backends[0] = "changed backend"
|
backends[0] = "changed backend"
|
||||||
|
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prompts[0].ID = "changed prompt"
|
prompts[0].ID = "changed prompt"
|
||||||
*prompts[0].Version = "changed version"
|
|
||||||
|
|
||||||
profiles, err = state.ListProfiles(context.Background())
|
profiles, err = state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(profiles, []string{"profile"}) {
|
if !reflect.DeepEqual(profiles, []string{"profile"}) {
|
||||||
t.Fatalf("caller mutation changed profile inventory: %#v", profiles)
|
t.Fatalf("caller mutation changed profile inventory: %#v", profiles)
|
||||||
}
|
}
|
||||||
backends, err = state.ListBackends(context.Background())
|
backends, err = state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || !reflect.DeepEqual(backends, []string{"backend"}) {
|
if err != nil || !reflect.DeepEqual(backends, []string{"backend"}) {
|
||||||
t.Fatalf("caller mutation changed backend inventory: %#v, %v", backends, err)
|
t.Fatalf("caller mutation changed backend inventory: %#v, %v", backends, err)
|
||||||
}
|
}
|
||||||
prompts, err = state.ListPrompts(context.Background())
|
prompts, err = state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantPrompts := []domain.PromptIdentity{{ID: "example", Version: stringPointer("v1")}}
|
wantPrompts := []domain.PromptIdentity{{ID: "example"}}
|
||||||
if !reflect.DeepEqual(prompts, wantPrompts) {
|
if !reflect.DeepEqual(prompts, wantPrompts) {
|
||||||
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
t.Fatalf("caller mutation changed prompt inventory: %#v", prompts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadedListsSelectNamespaces(t *testing.T) {
|
||||||
|
ids := []string{"a", "a-b/one", "a/one", "a/two", "b", "z/one"}
|
||||||
|
state := &loaded{profileIDs: append([]string(nil), ids...), backendIDs: append([]string(nil), ids...)}
|
||||||
|
for _, id := range ids {
|
||||||
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filter IdentityFilter
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"all", IdentityFilter{All: true}, ids},
|
||||||
|
{"unqualified", IdentityFilter{}, []string{"a", "b"}},
|
||||||
|
{"namespace", IdentityFilter{Namespace: "a"}, []string{"a/one", "a/two"}},
|
||||||
|
{"prefix sharing", IdentityFilter{Namespace: "a-b"}, []string{"a-b/one"}},
|
||||||
|
{"missing", IdentityFilter{Namespace: "absent"}, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
profiles, err := state.ListProfiles(context.Background(), tc.filter)
|
||||||
|
if err != nil || !reflect.DeepEqual(profiles, tc.want) {
|
||||||
|
t.Fatalf("profiles = %v, %v; want %v", profiles, err, tc.want)
|
||||||
|
}
|
||||||
|
backends, err := state.ListBackends(context.Background(), tc.filter)
|
||||||
|
if err != nil || !reflect.DeepEqual(backends, tc.want) {
|
||||||
|
t.Fatalf("backends = %v, %v; want %v", backends, err, tc.want)
|
||||||
|
}
|
||||||
|
prompts, err := state.ListPrompts(context.Background(), tc.filter)
|
||||||
|
if err != nil || len(prompts) != len(tc.want) {
|
||||||
|
t.Fatalf("prompts = %v, %v; want %v", prompts, err, tc.want)
|
||||||
|
}
|
||||||
|
for i := range prompts {
|
||||||
|
if prompts[i].ID != tc.want[i] {
|
||||||
|
t.Fatalf("prompt ID = %q; want %q", prompts[i].ID, tc.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(profiles) > 0 {
|
||||||
|
profiles[0], backends[0], prompts[0].ID = "changed", "changed", "changed"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(state.profileIDs, ids) || !reflect.DeepEqual(state.backendIDs, ids) || state.promptIDs[0].ID != ids[0] {
|
||||||
|
t.Fatal("caller mutation changed captured identities")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadedListsEmptyInventory(t *testing.T) {
|
func TestLoadedListsEmptyInventory(t *testing.T) {
|
||||||
state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil)
|
state, err := Load(configkit.Input{Source: configkit.NoSource()}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profiles, err := state.ListProfiles(context.Background())
|
profiles, err := state.ListProfiles(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(profiles) != 0 {
|
if err != nil || len(profiles) != 0 {
|
||||||
t.Fatalf("empty profile listing = %#v, %v", profiles, err)
|
t.Fatalf("empty profile listing = %#v, %v", profiles, err)
|
||||||
}
|
}
|
||||||
backends, err := state.ListBackends(context.Background())
|
backends, err := state.ListBackends(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(backends) != 0 {
|
if err != nil || len(backends) != 0 {
|
||||||
t.Fatalf("empty backend listing = %#v, %v", backends, err)
|
t.Fatalf("empty backend listing = %#v, %v", backends, err)
|
||||||
}
|
}
|
||||||
prompts, err := state.ListPrompts(context.Background())
|
prompts, err := state.ListPrompts(context.Background(), IdentityFilter{All: true})
|
||||||
if err != nil || len(prompts) != 0 {
|
if err != nil || len(prompts) != 0 {
|
||||||
t.Fatalf("empty prompt listing = %#v, %v", prompts, err)
|
t.Fatalf("empty prompt listing = %#v, %v", prompts, err)
|
||||||
}
|
}
|
||||||
@@ -128,8 +171,8 @@ func TestLoadedListsEmptyInventory(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
||||||
state, err := Load(mapping("identities", map[string]any{
|
state, err := Load(mapping("identities", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{"example": listingPromptDefinition()},
|
"prompts": []any{listingPromptDefinition("example")},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -137,23 +180,25 @@ func TestLoadedListsHonorCanceledContext(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
profiles, err := state.ListProfiles(ctx)
|
for _, filter := range []IdentityFilter{{All: true}, {}, {Namespace: "example"}} {
|
||||||
if !errors.Is(err, context.Canceled) || len(profiles) != 0 {
|
profiles, err := state.ListProfiles(ctx, filter)
|
||||||
t.Fatalf("canceled profile listing = %#v, %v", profiles, err)
|
if !errors.Is(err, context.Canceled) || len(profiles) != 0 {
|
||||||
}
|
t.Fatalf("canceled profile listing = %#v, %v", profiles, err)
|
||||||
backends, err := state.ListBackends(ctx)
|
}
|
||||||
if !errors.Is(err, context.Canceled) || len(backends) != 0 {
|
backends, err := state.ListBackends(ctx, filter)
|
||||||
t.Fatalf("canceled backend listing = %#v, %v", backends, err)
|
if !errors.Is(err, context.Canceled) || len(backends) != 0 {
|
||||||
}
|
t.Fatalf("canceled backend listing = %#v, %v", backends, err)
|
||||||
prompts, err := state.ListPrompts(ctx)
|
}
|
||||||
if !errors.Is(err, context.Canceled) || len(prompts) != 0 {
|
prompts, err := state.ListPrompts(ctx, filter)
|
||||||
t.Fatalf("canceled prompt listing = %#v, %v", prompts, err)
|
if !errors.Is(err, context.Canceled) || len(prompts) != 0 {
|
||||||
|
t.Fatalf("canceled prompt listing = %#v, %v", prompts, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listingPromptDefinition() map[string]any {
|
func listingPromptDefinition(id string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"version": "v1",
|
"id": id,
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type document struct {
|
type document struct {
|
||||||
Version *string `yaml:"version"`
|
Version *string `yaml:"version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
Collections map[string]collectionConfig `yaml:"collections"`
|
Collections map[string]collectionConfig `yaml:"collections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type collectionConfig struct {
|
type collectionConfig struct {
|
||||||
ReleaseVersion *string `yaml:"release_version"`
|
ReleaseVersion *string `yaml:"release_version"`
|
||||||
Backends map[string]backendConfig `yaml:"backends"`
|
Backends []backendConfig `yaml:"backends"`
|
||||||
Profiles map[string]profileConfig `yaml:"profiles"`
|
Profiles []profileConfig `yaml:"profiles"`
|
||||||
Prompts map[string]promptConfig `yaml:"prompts"`
|
Prompts []promptConfig `yaml:"prompts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type backendConfig struct {
|
type backendConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
APIKeyEnv *string `yaml:"api_key_env"`
|
APIKeyEnv *string `yaml:"api_key_env"`
|
||||||
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
ConcurrencyLimit *int `yaml:"concurrency_limit"`
|
||||||
@@ -56,6 +56,7 @@ type authenticationProbeConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type profileConfig struct {
|
type profileConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
Backend *string `yaml:"backend"`
|
Backend *string `yaml:"backend"`
|
||||||
Endpoint *string `yaml:"endpoint"`
|
Endpoint *string `yaml:"endpoint"`
|
||||||
Model *string `yaml:"model"`
|
Model *string `yaml:"model"`
|
||||||
@@ -71,7 +72,7 @@ type profileConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type promptConfig struct {
|
type promptConfig struct {
|
||||||
Version *string `yaml:"version"`
|
ID string `yaml:"id"`
|
||||||
DefaultProfile *string `yaml:"default_profile"`
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
Description *string `yaml:"description"`
|
Description *string `yaml:"description"`
|
||||||
SessionID *string `yaml:"session_id"`
|
SessionID *string `yaml:"session_id"`
|
||||||
@@ -118,11 +119,12 @@ const (
|
|||||||
|
|
||||||
type loaded struct {
|
type loaded struct {
|
||||||
result *configkit.Result[document]
|
result *configkit.Result[document]
|
||||||
backends map[string]map[string]fieldState
|
|
||||||
profiles map[string]map[string]fieldState
|
|
||||||
promptMetadata map[string][]promptInputPresence
|
promptMetadata map[string][]promptInputPresence
|
||||||
|
promptRepair map[string]bool
|
||||||
prompts map[string]*promptEntry
|
prompts map[string]*promptEntry
|
||||||
registry *backend.Registry
|
registry *backend.Registry
|
||||||
|
backendRecords map[string]backendConfig
|
||||||
|
profileRecords map[string]profileConfig
|
||||||
indexedProfiles map[string]domain.ExecutionProfile
|
indexedProfiles map[string]domain.ExecutionProfile
|
||||||
backendIDs []string
|
backendIDs []string
|
||||||
profileIDs []string
|
profileIDs []string
|
||||||
@@ -141,9 +143,9 @@ type resourceSet struct {
|
|||||||
isCollection bool
|
isCollection bool
|
||||||
namespace string
|
namespace string
|
||||||
bases resourceBases
|
bases resourceBases
|
||||||
backends map[string]backendConfig
|
backends []backendConfig
|
||||||
profiles map[string]profileConfig
|
profiles []profileConfig
|
||||||
prompts map[string]promptConfig
|
prompts []promptConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootResourceBases() resourceBases {
|
func rootResourceBases() resourceBases {
|
||||||
@@ -192,8 +194,8 @@ func indexedResourceID(namespace, localKey string) string {
|
|||||||
func indexedBackends(config *document) map[string]backendConfig {
|
func indexedBackends(config *document) map[string]backendConfig {
|
||||||
backends := make(map[string]backendConfig)
|
backends := make(map[string]backendConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.backends {
|
for _, definition := range resources.backends {
|
||||||
backends[indexedResourceID(resources.namespace, localKey)] = definition
|
backends[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return backends
|
return backends
|
||||||
@@ -202,8 +204,8 @@ func indexedBackends(config *document) map[string]backendConfig {
|
|||||||
func indexedProfiles(config *document) map[string]profileConfig {
|
func indexedProfiles(config *document) map[string]profileConfig {
|
||||||
profiles := make(map[string]profileConfig)
|
profiles := make(map[string]profileConfig)
|
||||||
for _, resources := range resourceSets(config) {
|
for _, resources := range resourceSets(config) {
|
||||||
for localKey, definition := range resources.profiles {
|
for _, definition := range resources.profiles {
|
||||||
profiles[indexedResourceID(resources.namespace, localKey)] = definition
|
profiles[indexedResourceID(resources.namespace, definition.ID)] = definition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return profiles
|
return profiles
|
||||||
@@ -211,45 +213,53 @@ func indexedProfiles(config *document) map[string]profileConfig {
|
|||||||
|
|
||||||
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"}
|
|
||||||
var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"}
|
|
||||||
|
|
||||||
// Load captures one immutable effective configuration for internal consumers.
|
// Load captures one immutable effective configuration for internal consumers.
|
||||||
func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||||
return load(main, underlays)
|
return load(main, underlays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configurationDefinition(state *loaded) configkit.Definition[document] {
|
||||||
|
return configkit.Definition[document]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("backends"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("profiles"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("backends")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("profiles")}, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts")}, "id"),
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: checkConfigurationVersion,
|
||||||
|
},
|
||||||
|
Sensitive: secretProviderPath,
|
||||||
|
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
for _, resources := range resourceSets(config) {
|
||||||
|
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata, state.promptRepair)
|
||||||
|
}
|
||||||
|
for _, resources := range resourceSets(config) {
|
||||||
|
resolveBackends(resources.backends, resources.bases.backends, resolution)
|
||||||
|
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
|
||||||
|
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(config *document, validation *configkit.Validation) {
|
||||||
|
validateEffective(config, validation)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// load performs one ConfigKit lifecycle over the selected source tiers.
|
// load performs one ConfigKit lifecycle over the selected source tiers.
|
||||||
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
|
||||||
state := &loaded{
|
state := &loaded{
|
||||||
backends: make(map[string]map[string]fieldState),
|
|
||||||
profiles: make(map[string]map[string]fieldState),
|
|
||||||
promptMetadata: make(map[string][]promptInputPresence),
|
promptMetadata: make(map[string][]promptInputPresence),
|
||||||
|
promptRepair: make(map[string]bool),
|
||||||
}
|
}
|
||||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
|
||||||
Underlays: underlays,
|
Underlays: underlays,
|
||||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
Definition: configkit.Definition[document]{
|
Definition: configurationDefinition(state),
|
||||||
Version: &configkit.VersionRule{
|
|
||||||
Path: configkit.Keys("version"),
|
|
||||||
Check: checkConfigurationVersion,
|
|
||||||
},
|
|
||||||
Secret: secretProviderPath,
|
|
||||||
Resolve: func(config *document, resolution *configkit.Resolution) error {
|
|
||||||
for _, resources := range resourceSets(config) {
|
|
||||||
captureFields(resources.backends, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends)
|
|
||||||
captureFields(resources.profiles, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
|
|
||||||
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
|
|
||||||
resolveBackends(resources.backends, resources.bases.backends, resolution)
|
|
||||||
resolveProfiles(resources.profiles, resources.bases.profiles, resolution)
|
|
||||||
resolvePrompts(resources.prompts, resources.bases.prompts, resolution)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
Validate: func(config *document, validation *configkit.Validation) {
|
|
||||||
validateEffective(config, validation)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
@@ -261,10 +271,10 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
if err := state.buildExecutionProfiles(); err != nil {
|
if err := state.buildExecutionProfiles(); err != nil {
|
||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
state.backendIDs = sortedKeys(indexedBackends(&result.Config))
|
state.backendIDs = sortedKeys(indexedBackends(&state.result.Config))
|
||||||
state.profileIDs = sortedKeys(state.indexedProfiles)
|
state.profileIDs = sortedKeys(state.indexedProfiles)
|
||||||
for _, id := range sortedKeys(state.prompts) {
|
for _, id := range sortedKeys(state.prompts) {
|
||||||
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id, Version: stringPointerValue(state.prompts[id].definition.Version)})
|
state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id})
|
||||||
}
|
}
|
||||||
state.namespaces = map[string][]string{
|
state.namespaces = map[string][]string{
|
||||||
"backend": resourceNamespaces(state.backendIDs),
|
"backend": resourceNamespaces(state.backendIDs),
|
||||||
@@ -302,35 +312,38 @@ func checkConfigurationVersion(version configkit.Version, validation *configkit.
|
|||||||
if !version.Present {
|
if !version.Present {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if version.Null || version.Text != "1" {
|
if version.Null || version.Text != "2" {
|
||||||
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
validation.Error(configkit.Keys("version"), "unsupported configuration specification version")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type promptInputPresence struct {
|
type promptInputPresence struct {
|
||||||
|
required fieldState
|
||||||
contentType fieldState
|
contentType fieldState
|
||||||
description fieldState
|
description fieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence, repair map[string]bool) {
|
||||||
for _, id := range sortedKeys(prompts) {
|
for promptIndex, prompt := range prompts {
|
||||||
inputs := prompts[id].Inputs
|
inputs := prompt.Inputs
|
||||||
states := make([]promptInputPresence, len(inputs))
|
states := make([]promptInputPresence, len(inputs))
|
||||||
base := basePath.Key(id).Key("inputs")
|
base := basePath.Index(promptIndex).Key("inputs")
|
||||||
for index := range inputs {
|
for index := range inputs {
|
||||||
path := base.Index(index)
|
path := base.Index(index)
|
||||||
states[index] = promptInputPresence{
|
states[index] = promptInputPresence{
|
||||||
|
required: captureFieldState(path.Key("required"), resolution),
|
||||||
contentType: captureFieldState(path.Key("content_type"), resolution),
|
contentType: captureFieldState(path.Key("content_type"), resolution),
|
||||||
description: captureFieldState(path.Key("description"), resolution),
|
description: captureFieldState(path.Key("description"), resolution),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
destination[indexedResourceID(namespace, prompt.ID)] = states
|
||||||
|
repair[indexedResourceID(namespace, prompt.ID)] = captureFieldState(basePath.Index(promptIndex).Key("output").Key("repair_attempts"), resolution) == fieldValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fieldState {
|
||||||
info, exists := resolution.InputInfo(path)
|
info, exists := resolution.EntryInfo(path)
|
||||||
switch {
|
switch {
|
||||||
case !exists || !info.Explicit:
|
case !exists || !info.Explicit:
|
||||||
return fieldAbsent
|
return fieldAbsent
|
||||||
@@ -341,21 +354,26 @@ func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureFields[T any](entries map[string]T, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
|
// eligibleResourceList leaves invalid identities to the existing validation
|
||||||
for id := range entries {
|
// boundaries, without mutating a list that cannot be recorded by identity.
|
||||||
states := make(map[string]fieldState, len(fields))
|
func eligibleResourceList[T any](entries []T, identity func(T) string) bool {
|
||||||
for _, field := range fields {
|
seen := make(map[string]struct{}, len(entries))
|
||||||
info, exists := resolution.InputInfo(basePath.Key(id).Key(field))
|
for _, entry := range entries {
|
||||||
switch {
|
id := identity(entry)
|
||||||
case !exists || !info.Explicit:
|
if !domain.IsValidLocalResourceKey(id) {
|
||||||
states[field] = fieldAbsent
|
return false
|
||||||
case info.Null:
|
|
||||||
states[field] = fieldNull
|
|
||||||
default:
|
|
||||||
states[field] = fieldValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
destination[indexedResourceID(namespace, id)] = states
|
if _, exists := seen[id]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordStringNormalization(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) {
|
||||||
|
if *value != nil && **value != normalized {
|
||||||
|
configkit.Normalize(resolution, path, value, &normalized, "Normalize configured value")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,99 +386,6 @@ func sortedKeys[T any](entries map[string]T) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
|
|
||||||
if value != nil && *value != nil && **value != normalized {
|
|
||||||
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBackends(backends map[string]backendConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(backends) {
|
|
||||||
backend := backends[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if backend.Endpoint != nil {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.APIKeyEnv != nil {
|
|
||||||
normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv))
|
|
||||||
}
|
|
||||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
|
||||||
smokePath := base.Key("smoke_tests")
|
|
||||||
if smokeTests.Reachability != nil {
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
if smokeTests.Authentication != nil {
|
|
||||||
if smokeTests.Authentication.Kind != nil {
|
|
||||||
normalizeString(&smokeTests.Authentication.Kind,
|
|
||||||
smokePath.Key("authentication").Key("kind"), resolution,
|
|
||||||
strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind)))
|
|
||||||
}
|
|
||||||
normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint,
|
|
||||||
smokePath.Key("authentication").Key("endpoint"), resolution)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil {
|
|
||||||
capacity := 1024
|
|
||||||
if resolution.IsExplicit(base.Key("queue_capacity")) {
|
|
||||||
configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
} else {
|
|
||||||
configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
backends[id] = backend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
if value == nil || *value == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalized := strings.TrimSpace(**value)
|
|
||||||
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
|
||||||
if normalized == "" || err != nil || request.URL == nil ||
|
|
||||||
(strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") ||
|
|
||||||
request.URL.Hostname() == "" {
|
|
||||||
resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalizeString(value, path, resolution, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(profiles) {
|
|
||||||
profile := profiles[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
resolution.Error(base.Key("endpoint"), err.Error())
|
|
||||||
} else {
|
|
||||||
normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"service_tier", &profile.ServiceTier},
|
|
||||||
{"reasoning_effort", &profile.ReasoningEffort},
|
|
||||||
{"api_key_env", &profile.APIKeyEnv},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profiles[id] = profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateEffective(config *document, validation *configkit.Validation) {
|
func validateEffective(config *document, validation *configkit.Validation) {
|
||||||
sets := resourceSets(config)
|
sets := resourceSets(config)
|
||||||
backendIDs := make(map[string]struct{})
|
backendIDs := make(map[string]struct{})
|
||||||
@@ -468,107 +393,119 @@ func validateEffective(config *document, validation *configkit.Validation) {
|
|||||||
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) {
|
||||||
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash")
|
||||||
}
|
}
|
||||||
for localKey := range resources.backends {
|
for _, backend := range resources.backends {
|
||||||
backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{}
|
backendIDs[indexedResourceID(resources.namespace, backend.ID)] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, resources := range sets {
|
for _, resources := range sets {
|
||||||
for _, id := range sortedKeys(resources.backends) {
|
for index, backend := range resources.backends {
|
||||||
backend := resources.backends[id]
|
validateBackendRecord(backend, resources.bases.backends.Index(index), validation)
|
||||||
base := resources.bases.backends.Key(id)
|
}
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
for index, profile := range resources.profiles {
|
||||||
validation.Error(base, "backend key must be a nonblank local key without a slash")
|
validateProfileRecord(profile, resources.bases.profiles.Index(index), backendIDs, validation)
|
||||||
}
|
}
|
||||||
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
for index, prompt := range resources.prompts {
|
||||||
validation.Error(base.Key("endpoint"), "endpoint is required")
|
validatePromptRecord(prompt, resources.bases.prompts.Index(index), validation)
|
||||||
}
|
}
|
||||||
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
}
|
||||||
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
}
|
||||||
}
|
|
||||||
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
func validateBackendRecord(backend backendConfig, base configkit.Path, validation *configkit.Validation) {
|
||||||
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
validateBackendEndpoints(backend, base, validation)
|
||||||
}
|
if !domain.IsValidLocalResourceKey(backend.ID) {
|
||||||
if backend.QueueCapacity != nil {
|
validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash")
|
||||||
if *backend.QueueCapacity < 0 {
|
}
|
||||||
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
if backend.Endpoint == nil || *backend.Endpoint == "" {
|
||||||
}
|
validation.Error(base.Key("endpoint"), "endpoint is required")
|
||||||
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
}
|
||||||
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) {
|
||||||
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
validation.Error(base.Key("api_key_env"), "environment variable name is invalid")
|
||||||
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
}
|
||||||
}
|
if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 {
|
||||||
}
|
validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative")
|
||||||
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
}
|
||||||
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
if backend.QueueCapacity != nil {
|
||||||
smokePath := base.Key("smoke_tests")
|
if *backend.QueueCapacity < 0 {
|
||||||
if smokeTests.DefaultProfile != nil {
|
validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative")
|
||||||
if strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
}
|
||||||
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 {
|
||||||
} else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) {
|
validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit")
|
||||||
validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed")
|
} else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit {
|
||||||
}
|
validation.Error(base.Key("queue_capacity"), "total capacity overflows int")
|
||||||
}
|
}
|
||||||
if authentication := smokeTests.Authentication; authentication != nil {
|
}
|
||||||
kindPath := smokePath.Key("authentication").Key("kind")
|
validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation)
|
||||||
if authentication.Kind == nil {
|
if smokeTests := backend.SmokeTests; smokeTests != nil {
|
||||||
validation.Error(kindPath, "authentication kind is required")
|
smokePath := base.Key("smoke_tests")
|
||||||
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
if smokeTests.DefaultProfile != nil {
|
||||||
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
if strings.TrimSpace(*smokeTests.DefaultProfile) == "" {
|
||||||
}
|
validation.Error(smokePath.Key("default_profile"), "default profile must not be blank")
|
||||||
}
|
} else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) {
|
||||||
|
validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, id := range sortedKeys(resources.profiles) {
|
if authentication := smokeTests.Authentication; authentication != nil {
|
||||||
profile := resources.profiles[id]
|
kindPath := smokePath.Key("authentication").Key("kind")
|
||||||
base := resources.bases.profiles.Key(id)
|
if authentication.Kind == nil {
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
validation.Error(kindPath, "authentication kind is required")
|
||||||
validation.Error(base, "profile key must be a nonblank local key without a slash")
|
} else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" {
|
||||||
}
|
validation.Error(kindPath, "authentication kind must be none or openrouter_key")
|
||||||
if profile.Backend != nil && *profile.Backend != "" {
|
|
||||||
if !domain.IsValidResourceID(*profile.Backend) {
|
|
||||||
validation.Error(base.Key("backend"), "backend reference is malformed")
|
|
||||||
} else if _, exists := backendIDs[*profile.Backend]; !exists {
|
|
||||||
validation.Error(base.Key("backend"), "backend is not defined")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
|
||||||
validation.Error(base.Key("model"), "model must not be blank")
|
|
||||||
}
|
|
||||||
if profile.Temperature != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
|
||||||
validation.Error(base.Key("temperature"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.TopP != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
|
||||||
validation.Error(base.Key("top_p"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.MaxTokens != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
|
||||||
validation.Error(base.Key("max_tokens"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.TimeoutSeconds != nil {
|
|
||||||
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
|
||||||
validation.Error(base.Key("timeout_seconds"), err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
|
||||||
}
|
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
|
||||||
prompt := resources.prompts[id]
|
|
||||||
base := resources.bases.prompts.Key(id)
|
|
||||||
if !domain.IsValidLocalResourceKey(id) {
|
|
||||||
validation.Error(base, "prompt key must be a nonblank local key without a slash")
|
|
||||||
}
|
|
||||||
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
|
||||||
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProfileRecord(profile profileConfig, base configkit.Path, backendIDs map[string]struct{}, validation *configkit.Validation) {
|
||||||
|
if profile.Endpoint != nil && *profile.Endpoint != "" {
|
||||||
|
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint); err != nil {
|
||||||
|
validation.Error(base.Key("endpoint"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !domain.IsValidLocalResourceKey(profile.ID) {
|
||||||
|
validation.Error(base.Key("id"), "profile ID must be a nonblank local key without a slash")
|
||||||
|
}
|
||||||
|
if profile.Backend != nil && *profile.Backend != "" {
|
||||||
|
if !domain.IsValidResourceID(*profile.Backend) {
|
||||||
|
validation.Error(base.Key("backend"), "backend reference is malformed")
|
||||||
|
} else if _, exists := backendIDs[*profile.Backend]; !exists {
|
||||||
|
validation.Error(base.Key("backend"), "backend is not defined")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" {
|
||||||
|
validation.Error(base.Key("model"), "model must not be blank")
|
||||||
|
}
|
||||||
|
if profile.Temperature != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil {
|
||||||
|
validation.Error(base.Key("temperature"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.TopP != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil {
|
||||||
|
validation.Error(base.Key("top_p"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.MaxTokens != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil {
|
||||||
|
validation.Error(base.Key("max_tokens"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.TimeoutSeconds != nil {
|
||||||
|
if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil {
|
||||||
|
validation.Error(base.Key("timeout_seconds"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePromptRecord(prompt promptConfig, base configkit.Path, validation *configkit.Validation) {
|
||||||
|
if !domain.IsValidLocalResourceKey(prompt.ID) {
|
||||||
|
validation.Error(base.Key("id"), "prompt ID must be a nonblank local key without a slash")
|
||||||
|
}
|
||||||
|
if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) {
|
||||||
|
validation.Error(base.Key("default_profile"), "default profile reference is malformed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) {
|
func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,17 +1,342 @@
|
|||||||
package configuration
|
package configuration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapping(label string, values map[string]any) configkit.Input {
|
func mapping(label string, values map[string]any) configkit.Input {
|
||||||
return configkit.Input{Source: configkit.Mapping(label, values)}
|
return configkit.Input{Source: configkit.Mapping(label, values)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadIdentifiedResourceLists(t *testing.T) {
|
||||||
|
for _, header := range []bool{false, true} {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
"collections": map[string]any{"team": map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.invalid/v1"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if header {
|
||||||
|
values["version"] = "2"
|
||||||
|
}
|
||||||
|
loaded, err := load(mapping("identified resources", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load identified resources with header %t: %v", header, err)
|
||||||
|
}
|
||||||
|
if loaded.result.Config.Backends[0].ID != "local" || loaded.result.Config.Collections["team"].Backends[0].ID != "local" {
|
||||||
|
t.Fatal("identified root and collection resources were not retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesComposeAcrossNamespacesAndKinds(t *testing.T) {
|
||||||
|
resources := func() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"backends": []any{
|
||||||
|
map[string]any{"id": "shared", "endpoint": "https://example.invalid/v1"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "model"},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
"prompts": []any{
|
||||||
|
map[string]any{"id": "shared", "messages": []any{map[string]any{"role": "user", "content": "inherited"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "shared"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := resources()
|
||||||
|
values["collections"] = map[string]any{"*": resources(), "123": resources()}
|
||||||
|
state, err := load(mapping("identified namespaces", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"", "*/", "123/"} {
|
||||||
|
backend, err := state.registry.GetBackend(prefix + "child")
|
||||||
|
if err != nil || backend.ID != prefix+"child" || backend.Endpoint != "https://example.invalid/v1" {
|
||||||
|
t.Fatalf("inherited backend %q = %+v, %v", prefix, backend, err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfileIdentity(context.Background(), prefix+"child")
|
||||||
|
if err != nil || profile != prefix+"child" {
|
||||||
|
t.Fatalf("inherited profile %q = %q, %v", prefix, profile, err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt(prefix + "child")
|
||||||
|
if err != nil || definition.ID != prefix+"child" || len(definition.Templates) != 1 || definition.Templates[0].Content != "inherited" {
|
||||||
|
t.Fatalf("inherited prompt %q = %+v, %v", prefix, definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidInheritance(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for name, records := range map[string][]any{
|
||||||
|
"missing": {map[string]any{"id": "child", "extends": map[string]any{"id": "missing"}}},
|
||||||
|
"self": {map[string]any{"id": "child", "extends": map[string]any{"id": "child"}}},
|
||||||
|
"cycle": {
|
||||||
|
map[string]any{"id": "first", "extends": map[string]any{"id": "second"}},
|
||||||
|
map[string]any{"id": "second", "extends": map[string]any{"id": "first"}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid inheritance", map[string]any{kind: records}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream inheritance diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeInheritanceMissing || diagnostic.Code == configkit.CodeInheritanceCycle {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing inheritance diagnostic: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectInvalidLocalIDs(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
record map[string]any
|
||||||
|
}{
|
||||||
|
{name: "missing", record: map[string]any{}},
|
||||||
|
{name: "null", record: map[string]any{"id": nil}},
|
||||||
|
{name: "non-string", record: map[string]any{"id": 42}},
|
||||||
|
{name: "blank", record: map[string]any{"id": " "}},
|
||||||
|
{name: "slash", record: map[string]any{"id": "not/local"}},
|
||||||
|
} {
|
||||||
|
t.Run(kind+"/"+test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("invalid resource ID", map[string]any{kind: []any{test.record}}), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want a path-aware error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourcesRejectDuplicateStrictPeers(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, namespace := range []string{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
values := func() map[string]any {
|
||||||
|
records := []any{map[string]any{"id": "duplicate"}}
|
||||||
|
if namespace == "" {
|
||||||
|
return map[string]any{kind: records}
|
||||||
|
}
|
||||||
|
return map[string]any{"collections": map[string]any{namespace: map[string]any{kind: records}}}
|
||||||
|
}
|
||||||
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
mapping("first", values()), mapping("second", values()),
|
||||||
|
}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want an upstream duplicate diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing duplicate conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, id := range []string{" ", "bad/id"} {
|
||||||
|
t.Run(kind+"/"+id, func(t *testing.T) {
|
||||||
|
record := map[string]any{"id": id}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
record["endpoint"] = " HTTPS://example.invalid/v1 "
|
||||||
|
record["concurrency_limit"] = 1
|
||||||
|
case "profiles":
|
||||||
|
record["service_tier"] = " standard "
|
||||||
|
case "prompts":
|
||||||
|
record["description"] = " padded "
|
||||||
|
}
|
||||||
|
_, err := load(mapping("invalid", map[string]any{kind: []any{record}}), nil)
|
||||||
|
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
|
||||||
|
t.Fatalf("invalid identity error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateIdentitiesWithNormalizableFieldsAreRejected(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
record := map[string]any{"id": "same"}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
record["endpoint"] = " HTTPS://example.invalid/v1 "
|
||||||
|
record["concurrency_limit"] = 1
|
||||||
|
case "profiles":
|
||||||
|
record["service_tier"] = " standard "
|
||||||
|
case "prompts":
|
||||||
|
record["description"] = " padded "
|
||||||
|
}
|
||||||
|
_, err := load(mapping("duplicates", map[string]any{kind: []any{record, record}}), nil)
|
||||||
|
if err == nil || strings.Contains(strings.ToLower(err.Error()), "unrecorded") {
|
||||||
|
t.Fatalf("duplicate identity error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalIdentitiesAreCheckedAfterApplicationResolution(t *testing.T) {
|
||||||
|
for _, kind := range []string{"backends", "profiles", "prompts"} {
|
||||||
|
for _, namespace := range []string{"", "*"} {
|
||||||
|
t.Run(kind+"/"+namespace, func(t *testing.T) {
|
||||||
|
definition := configurationDefinition(&loaded{})
|
||||||
|
definition.Resolve = func(config *document, resolution *configkit.Resolution) error {
|
||||||
|
path := configkit.Keys(kind)
|
||||||
|
if namespace == "" {
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
config.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
config.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
config.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collection := collectionConfig{}
|
||||||
|
switch kind {
|
||||||
|
case "backends":
|
||||||
|
collection.Backends = []backendConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "profiles":
|
||||||
|
collection.Profiles = []profileConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
case "prompts":
|
||||||
|
collection.Prompts = []promptConfig{{ID: "same"}, {ID: "same"}}
|
||||||
|
}
|
||||||
|
config.Collections = map[string]collectionConfig{namespace: collection}
|
||||||
|
path = configkit.Keys("collections", namespace, kind)
|
||||||
|
}
|
||||||
|
resolution.RecordNormalization(path, "supply resolved records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.NoSource()}
|
||||||
|
if namespace != "" {
|
||||||
|
input = mapping("existing collection", map[string]any{"collections": map[string]any{
|
||||||
|
namespace: map[string]any{kind: []any{}},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(input, configkit.LayeredOptions[document]{Definition: definition})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want a final identity diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeSequenceConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing final identity conflict: %+v", loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayerOperations(t *testing.T) {
|
||||||
|
base := mapping("base profiles", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "first", "model": "original", "service_tier": "standard"},
|
||||||
|
map[string]any{"id": "second", "model": "retained"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
records []any
|
||||||
|
models map[string]string
|
||||||
|
}{
|
||||||
|
{name: "whole record upsert", records: []any{map[string]any{"id": "first", "model": "updated"}}, models: map[string]string{"first": "updated", "second": "retained"}},
|
||||||
|
{name: "empty upsert", records: []any{}, models: map[string]string{"first": "original", "second": "retained"}},
|
||||||
|
{name: "append", mode: "append", records: []any{map[string]any{"id": "third", "model": "added"}}, models: map[string]string{"first": "original", "second": "retained", "third": "added"}},
|
||||||
|
{name: "replace", mode: "replace", records: []any{map[string]any{"id": "third", "model": "replacement"}}, models: map[string]string{"third": "replacement"}},
|
||||||
|
{name: "clear", mode: "replace", records: []any{}, models: map[string]string{}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
values := map[string]any{"profiles": test.records}
|
||||||
|
if test.mode != "" {
|
||||||
|
values["config"] = map[string]any{"sequences": []any{map[string]any{
|
||||||
|
"destination": []any{"profiles"}, "mode": test.mode,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("overlay profiles", values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profiles := indexedProfiles(&state.result.Config)
|
||||||
|
if len(profiles) != len(test.models) {
|
||||||
|
t.Fatalf("profiles = %+v, want IDs and models %+v", profiles, test.models)
|
||||||
|
}
|
||||||
|
for id, model := range test.models {
|
||||||
|
profile, ok := profiles[id]
|
||||||
|
if !ok || profile.Model == nil || *profile.Model != model {
|
||||||
|
t.Fatalf("profile %q = %+v, want model %q", id, profile, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "whole record upsert" && profiles["first"].ServiceTier != nil {
|
||||||
|
t.Fatalf("upsert retained a displaced field: %+v", profiles["first"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedResourceLayersRejectDisplacedParentAndDuplicateAppend(t *testing.T) {
|
||||||
|
base := mapping("base", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "model": "original"},
|
||||||
|
}})
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
values map[string]any
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "self extension cannot recover displaced record",
|
||||||
|
values: map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "shared", "extends": map[string]any{"id": "shared"}},
|
||||||
|
}},
|
||||||
|
code: configkit.CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "append cannot duplicate an identity",
|
||||||
|
values: map[string]any{
|
||||||
|
"config": map[string]any{"sequences": []any{map[string]any{"destination": []any{"profiles"}, "mode": "append"}}},
|
||||||
|
"profiles": []any{map[string]any{"id": "shared", "model": "other"}},
|
||||||
|
},
|
||||||
|
code: configkit.CodeSequenceConflict,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping("overlay", test.values), []configkit.Underlay{{Inputs: []configkit.Input{base}}})
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load error = %v, want structured diagnostic", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code == test.code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing %s diagnostic: %+v", test.code, loadErr.Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -19,11 +344,11 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
include bool
|
include bool
|
||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{name: "string one", version: "1", include: true, valid: true},
|
{name: "string two", version: "2", include: true, valid: true},
|
||||||
{name: "integer one", version: int64(1), include: true, valid: true},
|
{name: "integer two", version: int64(2), include: true, valid: true},
|
||||||
{name: "omitted", valid: true},
|
{name: "omitted", valid: true},
|
||||||
{name: "explicit null", include: true},
|
{name: "explicit null", include: true},
|
||||||
{name: "unsupported string", version: "2", include: true},
|
{name: "unsupported string", version: "1", include: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
@@ -35,8 +360,8 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load accepted version: %v", err)
|
t.Fatalf("load accepted version: %v", err)
|
||||||
}
|
}
|
||||||
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") {
|
if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2") {
|
||||||
t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version)
|
t.Fatalf("typed version = %v, want 2", loaded.result.Config.Version)
|
||||||
}
|
}
|
||||||
if !test.include && loaded.result.Config.Version != nil {
|
if !test.include && loaded.result.Config.Version != nil {
|
||||||
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version)
|
||||||
@@ -47,9 +372,6 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
if err == nil || !errors.As(err, &loadErr) {
|
if err == nil || !errors.As(err, &loadErr) {
|
||||||
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
t.Fatalf("load error = %v, want ConfigKit load error", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "2") {
|
|
||||||
t.Fatalf("load error disclosed the unsupported value: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
found := false
|
||||||
for _, diagnostic := range loadErr.Diagnostics() {
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" {
|
||||||
@@ -63,9 +385,44 @@ func TestLoadChecksOptionalConfigurationVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMalformedHeadersAndLegacyResourceMappings(t *testing.T) {
|
||||||
|
for name, values := range map[string]map[string]any{
|
||||||
|
"boolean header": {"version": true},
|
||||||
|
"fractional header": {"version": 2.5},
|
||||||
|
"sequence header": {"version": []any{2}},
|
||||||
|
"backend mapping": {"backends": map[string]any{"local": map[string]any{"endpoint": "https://example.invalid/v1"}}},
|
||||||
|
"profile mapping": {"profiles": map[string]any{"local": map[string]any{}}},
|
||||||
|
"prompt mapping": {"prompts": map[string]any{"local": map[string]any{}}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := load(mapping(name, values), nil)
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 {
|
||||||
|
t.Fatalf("load error = %v, want structured rejection", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedConfigRemainsApplicationData(t *testing.T) {
|
||||||
|
state, err := load(mapping("nested config", map[string]any{
|
||||||
|
"config": map[string]any{},
|
||||||
|
"profiles": []any{map[string]any{"id": "example", "extra_params": map[string]any{
|
||||||
|
"config": map[string]any{"imports": "provider data"},
|
||||||
|
}}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile, err := state.GetProfile(context.Background(), "example")
|
||||||
|
if err != nil || profile.ResolvedTarget.ExtraParams["config"].(map[string]any)["imports"] != "provider data" {
|
||||||
|
t.Fatalf("nested application data = %+v, %v", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) {
|
||||||
underlay := mapping("declared underlay", map[string]any{
|
underlay := mapping("declared underlay", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +433,7 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
invalidFragment := mapping("headerless prompt fragment", map[string]any{
|
||||||
"prompts": map[string]any{"incomplete": map[string]any{"description": "still validated"}},
|
"prompts": []any{map[string]any{"id": "incomplete", "description": "still validated"}},
|
||||||
})
|
})
|
||||||
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") {
|
||||||
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err)
|
||||||
@@ -85,30 +442,25 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes
|
|||||||
|
|
||||||
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"version": int64(1),
|
"version": int64(2),
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2},
|
|
||||||
},
|
|
||||||
"collections": map[string]any{"base": map[string]any{
|
"collections": map[string]any{"base": map[string]any{
|
||||||
"release_version": "",
|
"release_version": "",
|
||||||
"backends": map[string]any{
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 3}},
|
||||||
"local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3},
|
"profiles": []any{
|
||||||
},
|
map[string]any{"id": "one", "backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
||||||
"profiles": map[string]any{
|
|
||||||
"one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true,
|
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
"extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}},
|
||||||
"child.v1": map[string]any{"extends": "one"},
|
map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"}},
|
||||||
"namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"},
|
map[string]any{"id": "namespaced", "backend": "base/local.v1", "model": "collection-model"},
|
||||||
},
|
},
|
||||||
}, "invoice": map[string]any{
|
}, "invoice": map[string]any{
|
||||||
"release_version": "r7",
|
"release_version": "r7",
|
||||||
"prompts": map[string]any{
|
"prompts": []any{
|
||||||
"summary": map[string]any{
|
map[string]any{"id": "summary",
|
||||||
"version": "v1.0",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Summarize."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
"version": map[string]any{
|
map[string]any{"id": "version",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Nested ID."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
},
|
},
|
||||||
@@ -116,31 +468,32 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}},
|
"backends": []any{map[string]any{"id": "local.v1", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 2, "queue_capacity": 0}},
|
||||||
"collections": map[string]any{"base": map[string]any{"profiles": map[string]any{"child.v1": map[string]any{
|
"collections": map[string]any{"base": map[string]any{"profiles": []any{map[string]any{"id": "child.v1", "extends": map[string]any{"id": "one"},
|
||||||
"temperature": 0, "top_p": nil, "api_key_required": false,
|
"temperature": 0, "top_p": nil, "api_key_required": false,
|
||||||
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
"extra_params": map[string]any{"provider": map[string]any{"second": 3}},
|
||||||
}}}},
|
}}}},
|
||||||
})
|
})
|
||||||
loaded, err := load(mapping("main", map[string]any{"version": "1"}), []configkit.Underlay{
|
loaded, err := load(mapping("main", map[string]any{"version": "2"}), []configkit.Underlay{
|
||||||
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
{Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("load: %v; diagnostics: %+v", err, loadErr.Diagnostics())
|
||||||
|
}
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
profile := loaded.result.Config.Collections["base"].Profiles["child.v1"]
|
profile := loaded.result.Config.Collections["base"].Profiles[1]
|
||||||
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("effective values were not retained: %+v", profile)
|
t.Fatalf("effective values were not retained: %+v", profile)
|
||||||
}
|
}
|
||||||
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)
|
provider := profile.ExtraParams["provider"].(map[string]any)
|
||||||
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
if provider["first"] != int64(1) || provider["second"] != int64(3) {
|
||||||
t.Fatalf("recursive overlay failed: %+v", provider)
|
t.Fatalf("recursive overlay failed: %+v", provider)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local.v1"]
|
backend := loaded.result.Config.Backends[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue {
|
if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 {
|
||||||
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
t.Fatalf("explicit zero queue capacity was lost: %+v", backend)
|
||||||
}
|
}
|
||||||
if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil {
|
if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil {
|
||||||
@@ -150,10 +503,10 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
|
|||||||
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" {
|
||||||
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Collections["invoice"].Prompts["summary"].Messages[0].Content == nil || loaded.result.Config.Collections["invoice"].Prompts["summary"].Version == nil || *loaded.result.Config.Collections["invoice"].Prompts["summary"].Version != "v1.0" {
|
if loaded.result.Config.Collections["invoice"].Prompts[0].Messages[0].Content == nil {
|
||||||
t.Fatal("literal prompt ID and version were not decoded")
|
t.Fatal("literal prompt content was not decoded")
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1" ||
|
if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "2" ||
|
||||||
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" ||
|
||||||
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" {
|
||||||
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config)
|
||||||
@@ -176,20 +529,20 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "root resource with separator",
|
name: "root resource with separator",
|
||||||
values: map[string]any{"backends": map[string]any{
|
values: map[string]any{"backends": []any{
|
||||||
"bad/name": map[string]any{"endpoint": "https://example.test/v1"},
|
map[string]any{"id": "bad/name", "endpoint": "https://example.test/v1"},
|
||||||
}},
|
}},
|
||||||
message: "backend key must be a nonblank local key without a slash",
|
message: "backend ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collection resource with separator",
|
name: "collection resource with separator",
|
||||||
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
values: map[string]any{"collections": map[string]any{"good": map[string]any{
|
||||||
"prompts": map[string]any{"bad/name": map[string]any{
|
"prompts": []any{map[string]any{"id": "bad/name",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
"messages": []any{map[string]any{"role": "user", "content": "Hello."}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}},
|
}},
|
||||||
}}},
|
}}},
|
||||||
message: "prompt key must be a nonblank local key without a slash",
|
message: "prompt ID must be a nonblank local key without a slash",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -208,22 +561,35 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) {
|
func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
map[string]any{"id": "local", "endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ",
|
||||||
"concurrency_limit": 2, "queue_capacity": nil},
|
"concurrency_limit": 2, "queue_capacity": nil},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
backend, err := loaded.GetBackend("local")
|
||||||
if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" ||
|
if err != nil || backend.Endpoint != "http://localhost:8080/v1" ||
|
||||||
backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" ||
|
backend.APIKeyEnv != "TOKEN_NAME" || backend.QueueCapacity != 1024 {
|
||||||
backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 {
|
|
||||||
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
t.Fatalf("backend normalization/default was not applied: %+v", backend)
|
||||||
}
|
}
|
||||||
if loaded.backends["local"]["queue_capacity"] != fieldNull {
|
composed := loaded.result.Config.Backends[0]
|
||||||
t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"])
|
if composed.Endpoint == nil || *composed.Endpoint != "http://localhost:8080/v1" ||
|
||||||
|
composed.APIKeyEnv == nil || *composed.APIKeyEnv != "TOKEN_NAME" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 {
|
||||||
|
t.Fatalf("resolved backend was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint"))
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeNormalization {
|
||||||
|
t.Fatalf("endpoint origin was not preserved: %+v", field)
|
||||||
|
}
|
||||||
|
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("queue_capacity"))
|
||||||
|
if !found || field.Origin.Operation != configkit.ChangeDerivedDefault {
|
||||||
|
t.Fatalf("queue capacity default was not recorded: %+v", field)
|
||||||
|
}
|
||||||
|
field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("concurrency_limit"))
|
||||||
|
if !found || field.Origin.Operation == configkit.ChangeNormalization || field.Origin.Operation == configkit.ChangeDerivedDefault {
|
||||||
|
t.Fatalf("unchanged concurrency acquired a resolution record: %+v", field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,17 +604,17 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
{name: "null", smokeTests: nil, include: true, inherited: true},
|
{name: "null", smokeTests: nil, include: true, inherited: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
backend := map[string]any{"endpoint": "https://backend.example/v1"}
|
backend := map[string]any{"id": "local", "endpoint": "https://backend.example/v1"}
|
||||||
if test.include {
|
if test.include {
|
||||||
backend["smoke_tests"] = test.smokeTests
|
backend["smoke_tests"] = test.smokeTests
|
||||||
}
|
}
|
||||||
main := mapping("backend", map[string]any{
|
main := mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": backend},
|
"backends": []any{backend},
|
||||||
})
|
})
|
||||||
var underlays []configkit.Underlay
|
var underlays []configkit.Underlay
|
||||||
if test.inherited {
|
if test.inherited {
|
||||||
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
"smoke_tests": map[string]any{"default_profile": "inherited-profile"},
|
||||||
}},
|
}},
|
||||||
@@ -258,7 +624,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := loaded.result.Config.Backends["local"].SmokeTests; got != nil {
|
if got := loaded.result.Config.Backends[0].SmokeTests; got != nil {
|
||||||
t.Fatalf("smoke_tests = %+v, want nil", got)
|
t.Fatalf("smoke_tests = %+v, want nil", got)
|
||||||
}
|
}
|
||||||
selected, err := loaded.registry.GetBackend("local")
|
selected, err := loaded.registry.GetBackend("local")
|
||||||
@@ -274,7 +640,7 @@ func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
||||||
base := mapping("defaults", map[string]any{
|
base := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "catalog",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "catalog-profile",
|
"default_profile": "catalog-profile",
|
||||||
@@ -287,7 +653,7 @@ func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
overlay := mapping("operator", map[string]any{
|
overlay := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "extends": map[string]any{"id": "catalog"},
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "not-yet-listed",
|
"default_profile": "not-yet-listed",
|
||||||
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
"authentication": map[string]any{"kind": " NONE ", "endpoint": nil},
|
||||||
@@ -331,37 +697,37 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "blank default profile",
|
name: "blank default profile",
|
||||||
data: map[string]any{"default_profile": " \t "},
|
data: map[string]any{"default_profile": " \t "},
|
||||||
path: "/backends/local/smoke_tests/default_profile",
|
path: "/backends/0/smoke_tests/default_profile",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "authentication kind required",
|
name: "authentication kind required",
|
||||||
data: map[string]any{"authentication": map[string]any{}},
|
data: map[string]any{"authentication": map[string]any{}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown authentication kind",
|
name: "unknown authentication kind",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "bearer"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/kind",
|
path: "/backends/0/smoke_tests/authentication/kind",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-http reachability URL",
|
name: "non-http reachability URL",
|
||||||
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}},
|
||||||
path: "/backends/local/smoke_tests/reachability/endpoint",
|
path: "/backends/0/smoke_tests/reachability/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank authentication URL",
|
name: "blank authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "relative authentication URL",
|
name: "relative authentication URL",
|
||||||
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}},
|
||||||
path: "/backends/local/smoke_tests/authentication/endpoint",
|
path: "/backends/0/smoke_tests/authentication/endpoint",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, err := load(mapping("backend", map[string]any{
|
_, err := load(mapping("backend", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": test.data,
|
"smoke_tests": test.data,
|
||||||
}},
|
}},
|
||||||
@@ -381,22 +747,41 @@ func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) {
|
||||||
loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{
|
underlay := mapping("defaults", map[string]any{"backends": []any{
|
||||||
"local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "api_key_env": "OLD_KEY"},
|
||||||
|
}})
|
||||||
|
loaded, err := load(mapping("backend", map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1", "concurrency_limit": 1},
|
||||||
|
}}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend, err := loaded.GetBackend("local")
|
||||||
|
if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity == nil || *loaded.result.Config.Backends[0].QueueCapacity != 1024 {
|
||||||
|
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCapturesProfilePresence(t *testing.T) {
|
||||||
|
loaded, err := load(mapping("profile", map[string]any{"profiles": []any{
|
||||||
|
map[string]any{"id": "local", "temperature": 0, "top_p": nil, "api_key_required": false},
|
||||||
}}), nil)
|
}}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
backend := loaded.result.Config.Backends["local"]
|
profile := loaded.result.Config.Profiles[0]
|
||||||
if backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent {
|
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
|
||||||
t.Fatalf("absent queue capacity did not receive its default: %+v", backend)
|
t.Fatalf("profile values were not preserved: %+v", profile)
|
||||||
|
}
|
||||||
|
if profile.Model != nil {
|
||||||
|
t.Fatalf("omitted model was not preserved: %+v", profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
||||||
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("first", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("first", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
mapping("second", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}),
|
mapping("second", map[string]any{"profiles": []any{map[string]any{"id": "shared", "model": "one"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
@@ -406,33 +791,33 @@ func TestLoadRejectsStrictGroupConflict(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": "not a URL"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": "not a URL"}},
|
||||||
"profiles": map[string]any{"partial": map[string]any{"backend": "local"}},
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080/v1"}}})
|
main := mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080/v1"}}})
|
||||||
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
t.Fatalf("replaced lower-layer domain value should not fail: %v", err)
|
||||||
}
|
}
|
||||||
if loaded.result.Config.Profiles["partial"].Model != nil {
|
if loaded.result.Config.Profiles[0].Model != nil {
|
||||||
t.Fatal("unused partial profile unexpectedly gained a model")
|
t.Fatal("unused partial profile unexpectedly gained a model")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
_, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
mapping("invalid", map[string]any{"backends": map[string]any{"unused": map[string]any{"endpoint": "not a URL"}}}),
|
mapping("invalid", map[string]any{"backends": []any{map[string]any{"id": "unused", "endpoint": "not a URL"}}}),
|
||||||
}}})
|
}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
t.Fatalf("expected path-aware effective backend failure, got %v", err)
|
||||||
}
|
}
|
||||||
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/unused/endpoint" {
|
if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/0/endpoint" {
|
||||||
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
||||||
underlay := mapping("defaults", map[string]any{
|
underlay := mapping("defaults", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local",
|
||||||
"endpoint": "https://backend.example/v1",
|
"endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": " ",
|
"default_profile": " ",
|
||||||
@@ -442,7 +827,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
main := mapping("operator", map[string]any{
|
main := mapping("operator", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://backend.example/v1",
|
||||||
"smoke_tests": map[string]any{
|
"smoke_tests": map[string]any{
|
||||||
"default_profile": "dangling-profile",
|
"default_profile": "dangling-profile",
|
||||||
"reachability": map[string]any{"endpoint": nil},
|
"reachability": map[string]any{"endpoint": nil},
|
||||||
@@ -465,7 +850,7 @@ func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) {
|
||||||
_, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}),
|
_, err := load(mapping("operator", map[string]any{"backends": []any{map[string]any{"id": "local", "endpoint": "http://localhost:8080"}}}),
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
[]configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
"go.maximumdirect.net/promptkit/internal/filecatalog"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -20,6 +20,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type promptEntry struct {
|
type promptEntry struct {
|
||||||
|
resource promptConfig
|
||||||
definition domain.PromptDefinition
|
definition domain.PromptDefinition
|
||||||
inspection domain.PromptInspection
|
inspection domain.PromptInspection
|
||||||
requirementInputs []prompt.InputMetadata
|
requirementInputs []prompt.InputMetadata
|
||||||
@@ -28,59 +29,6 @@ type promptEntry struct {
|
|||||||
messages []*template.Template
|
messages []*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
|
||||||
for _, id := range sortedKeys(prompts) {
|
|
||||||
definition := prompts[id]
|
|
||||||
base := basePath.Key(id)
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"description", &definition.Description},
|
|
||||||
{"session_id", &definition.SessionID},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inputsChanged := false
|
|
||||||
for index := range definition.Inputs {
|
|
||||||
input := &definition.Inputs[index]
|
|
||||||
inputPath := base.Key("inputs").Index(index)
|
|
||||||
normalizedName := strings.TrimSpace(input.Name)
|
|
||||||
if normalizedName != input.Name {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
for _, field := range []struct {
|
|
||||||
name string
|
|
||||||
value **string
|
|
||||||
}{
|
|
||||||
{"content_type", &input.ContentType},
|
|
||||||
{"description", &input.Description},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalized := strings.TrimSpace(**field.value)
|
|
||||||
if normalized != **field.value {
|
|
||||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
|
||||||
inputsChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if inputsChanged {
|
|
||||||
resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
|
|
||||||
}
|
|
||||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
|
||||||
// records sequence mutations as whole-value replacements, which would
|
|
||||||
// discard the effective source contexts of unchanged content_file fields.
|
|
||||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
|
||||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
|
||||||
}
|
|
||||||
prompts[id] = definition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *loaded) materializePrompts() error {
|
func (state *loaded) materializePrompts() error {
|
||||||
state.prompts = make(map[string]*promptEntry)
|
state.prompts = make(map[string]*promptEntry)
|
||||||
var metadata *configkit.Metadata
|
var metadata *configkit.Metadata
|
||||||
@@ -92,14 +40,13 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return metadata.Lookup(path)
|
return metadata.Lookup(path)
|
||||||
}
|
}
|
||||||
for _, resources := range resourceSets(&state.result.Config) {
|
for _, resources := range resourceSets(&state.result.Config) {
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
for index, raw := range resources.prompts {
|
||||||
promptBase := resources.bases.prompts.Key(id)
|
promptBase := resources.bases.prompts.Index(index)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||||
}
|
}
|
||||||
fullID := indexedResourceID(resources.namespace, id)
|
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||||
raw := resources.prompts[id]
|
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,11 +56,11 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
func (state *loaded) materializePrompt(id string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||||
if len(raw.Messages) == 0 {
|
if len(raw.Messages) == 0 {
|
||||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
||||||
}
|
}
|
||||||
definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)}
|
definition := domain.PromptDefinition{ID: id}
|
||||||
if raw.DefaultProfile != nil {
|
if raw.DefaultProfile != nil {
|
||||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||||
@@ -126,107 +73,21 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
if raw.SessionID != nil {
|
if raw.SessionID != nil {
|
||||||
definition.SessionID = *raw.SessionID
|
definition.SessionID = *raw.SessionID
|
||||||
}
|
}
|
||||||
seenInputs := make(map[string]bool, len(raw.Inputs))
|
inputs, inputMetadata, err := state.capturePromptInputs(id, raw.Inputs, base)
|
||||||
definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs))
|
if err != nil {
|
||||||
entry := &promptEntry{
|
return nil, err
|
||||||
messages: make([]*template.Template, 0, len(raw.Messages)),
|
|
||||||
requirementInputs: make([]prompt.InputMetadata, 0, len(raw.Inputs)),
|
|
||||||
}
|
}
|
||||||
for index, input := range raw.Inputs {
|
messages, compiledMessages, err := state.capturePromptMessages(raw.Messages, base, lookupOrigin)
|
||||||
path := base.Key("inputs").Index(index)
|
if err != nil {
|
||||||
if input.Name == "" {
|
return nil, err
|
||||||
return nil, fmt.Errorf("%s: input name is required", path.Key("name"))
|
|
||||||
}
|
|
||||||
if seenInputs[input.Name] {
|
|
||||||
return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
|
|
||||||
}
|
|
||||||
seenInputs[input.Name] = true
|
|
||||||
contentType := ""
|
|
||||||
if input.ContentType != nil {
|
|
||||||
contentType = *input.ContentType
|
|
||||||
}
|
|
||||||
description := ""
|
|
||||||
if input.Description != nil {
|
|
||||||
description = *input.Description
|
|
||||||
}
|
|
||||||
definition.Inputs = append(definition.Inputs, domain.PromptInput{
|
|
||||||
Name: input.Name, Required: input.Required, ContentType: contentType, Description: description,
|
|
||||||
})
|
|
||||||
presence := state.promptMetadata[id][index]
|
|
||||||
metadata := prompt.InputMetadata{Name: input.Name, Required: input.Required}
|
|
||||||
if presence.contentType == fieldValue {
|
|
||||||
metadata.ContentType = stringPointer(contentType)
|
|
||||||
}
|
|
||||||
if presence.description == fieldValue {
|
|
||||||
metadata.Description = stringPointer(description)
|
|
||||||
}
|
|
||||||
entry.requirementInputs = append(entry.requirementInputs, metadata)
|
|
||||||
}
|
}
|
||||||
definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
contract, err := capturePromptOutput(raw.Output, base)
|
||||||
for index, message := range raw.Messages {
|
if err != nil {
|
||||||
path := base.Key("messages").Index(index)
|
return nil, err
|
||||||
if message.Role == nil {
|
|
||||||
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
|
||||||
}
|
|
||||||
role, err := domain.NormalizeMessageRole(*message.Role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
|
||||||
}
|
|
||||||
if (message.Content != nil) == (message.ContentFile != nil) {
|
|
||||||
return nil, fmt.Errorf("%s: exactly one of content or content_file is required", path)
|
|
||||||
}
|
|
||||||
content := ""
|
|
||||||
if message.Content != nil {
|
|
||||||
content = *message.Content
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var control *domain.CacheControl
|
|
||||||
if message.CacheControl != nil {
|
|
||||||
if message.CacheControl.Type == nil {
|
|
||||||
return nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type"))
|
|
||||||
}
|
|
||||||
control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)}
|
|
||||||
if message.CacheControl.TTL != nil {
|
|
||||||
control.TTL = *message.CacheControl.TTL
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
control, err = domain.NormalizeCacheControl(control)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: invalid template syntax: %w", path, err)
|
|
||||||
}
|
|
||||||
entry.messages = append(entry.messages, compiled)
|
|
||||||
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
|
|
||||||
Role: role, Content: content, CacheControl: control,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if raw.Output == nil {
|
definition.Inputs = inputs
|
||||||
return nil, fmt.Errorf("%s: output is required", base.Key("output"))
|
definition.Templates = messages
|
||||||
}
|
entry := &promptEntry{resource: raw, requirementInputs: inputMetadata, messages: compiledMessages}
|
||||||
if raw.Output.Format == nil || raw.Output.ValidationMode == nil {
|
|
||||||
return nil, fmt.Errorf("%s: format and validation_mode are required", base.Key("output"))
|
|
||||||
}
|
|
||||||
contract := domain.OutputContract{
|
|
||||||
Format: domain.OutputFormat(*raw.Output.Format),
|
|
||||||
ValidationMode: domain.ValidationMode(*raw.Output.ValidationMode),
|
|
||||||
RepairAttempts: raw.Output.RepairAttempts,
|
|
||||||
}
|
|
||||||
if raw.Output.SchemaPath != nil {
|
|
||||||
contract.SchemaPath = *raw.Output.SchemaPath
|
|
||||||
}
|
|
||||||
if err := domain.ValidateOutputContract(contract); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err)
|
|
||||||
}
|
|
||||||
definition.OutputFormat = contract.Format
|
|
||||||
definition.Validation = contract
|
definition.Validation = contract
|
||||||
if raw.SessionID != nil {
|
if raw.SessionID != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -242,13 +103,120 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
|||||||
entry.definition = definition
|
entry.definition = definition
|
||||||
entry.hash = hash
|
entry.hash = hash
|
||||||
entry.inspection = domain.PromptInspection{
|
entry.inspection = domain.PromptInspection{
|
||||||
PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash,
|
PromptID: id, PromptHash: hash,
|
||||||
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||||
OutputContract: contract,
|
OutputContract: contract,
|
||||||
}
|
}
|
||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (state *loaded) capturePromptInputs(id string, rawInputs []promptInputConfig, base configkit.Path) ([]domain.PromptInput, []prompt.InputMetadata, error) {
|
||||||
|
seenInputs := make(map[string]bool, len(rawInputs))
|
||||||
|
inputs := make([]domain.PromptInput, 0, len(rawInputs))
|
||||||
|
metadata := make([]prompt.InputMetadata, 0, len(rawInputs))
|
||||||
|
for index, input := range rawInputs {
|
||||||
|
path := base.Key("inputs").Index(index)
|
||||||
|
if input.Name == "" {
|
||||||
|
return nil, nil, fmt.Errorf("%s: input name is required", path.Key("name"))
|
||||||
|
}
|
||||||
|
if seenInputs[input.Name] {
|
||||||
|
return nil, nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
|
||||||
|
}
|
||||||
|
seenInputs[input.Name] = true
|
||||||
|
contentType := ""
|
||||||
|
if input.ContentType != nil {
|
||||||
|
contentType = *input.ContentType
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
if input.Description != nil {
|
||||||
|
description = *input.Description
|
||||||
|
}
|
||||||
|
inputs = append(inputs, domain.PromptInput{
|
||||||
|
Name: input.Name, Required: input.Required, ContentType: contentType, Description: description,
|
||||||
|
})
|
||||||
|
presence := state.promptMetadata[id][index]
|
||||||
|
item := prompt.InputMetadata{Name: input.Name, Required: input.Required}
|
||||||
|
if presence.contentType == fieldValue {
|
||||||
|
item.ContentType = stringPointer(contentType)
|
||||||
|
}
|
||||||
|
if presence.description == fieldValue {
|
||||||
|
item.Description = stringPointer(description)
|
||||||
|
}
|
||||||
|
metadata = append(metadata, item)
|
||||||
|
}
|
||||||
|
return inputs, metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *loaded) capturePromptMessages(rawMessages []promptMessageConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) ([]domain.PromptMessageTemplate, []*template.Template, error) {
|
||||||
|
templates := make([]domain.PromptMessageTemplate, 0, len(rawMessages))
|
||||||
|
compiledMessages := make([]*template.Template, 0, len(rawMessages))
|
||||||
|
for index, message := range rawMessages {
|
||||||
|
path := base.Key("messages").Index(index)
|
||||||
|
if message.Role == nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: role is required", path.Key("role"))
|
||||||
|
}
|
||||||
|
if _, err := domain.NormalizeMessageRole(*message.Role); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", path.Key("role"), err)
|
||||||
|
}
|
||||||
|
if (message.Content != nil) == (message.ContentFile != nil) {
|
||||||
|
return nil, nil, fmt.Errorf("%s: exactly one of content or content_file is required", path)
|
||||||
|
}
|
||||||
|
content := ""
|
||||||
|
if message.Content != nil {
|
||||||
|
content = *message.Content
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var control *domain.CacheControl
|
||||||
|
if message.CacheControl != nil {
|
||||||
|
if message.CacheControl.Type == nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type"))
|
||||||
|
}
|
||||||
|
control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)}
|
||||||
|
if message.CacheControl.TTL != nil {
|
||||||
|
control.TTL = *message.CacheControl.TTL
|
||||||
|
}
|
||||||
|
if _, err := domain.NormalizeCacheControl(control); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: invalid template syntax: %w", path, err)
|
||||||
|
}
|
||||||
|
compiledMessages = append(compiledMessages, compiled)
|
||||||
|
templates = append(templates, domain.PromptMessageTemplate{
|
||||||
|
Role: *message.Role, Content: content, CacheControl: control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return templates, compiledMessages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePromptOutput(output *promptOutputConfig, base configkit.Path) (domain.OutputContract, error) {
|
||||||
|
if output == nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: output is required", base.Key("output"))
|
||||||
|
}
|
||||||
|
if output.Format == nil || output.ValidationMode == nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: format and validation_mode are required", base.Key("output"))
|
||||||
|
}
|
||||||
|
contract := domain.OutputContract{
|
||||||
|
Format: domain.OutputFormat(*output.Format),
|
||||||
|
ValidationMode: domain.ValidationMode(*output.ValidationMode),
|
||||||
|
RepairAttempts: output.RepairAttempts,
|
||||||
|
}
|
||||||
|
if output.SchemaPath != nil {
|
||||||
|
contract.SchemaPath = *output.SchemaPath
|
||||||
|
}
|
||||||
|
if err := domain.ValidateOutputContract(contract); err != nil {
|
||||||
|
return domain.OutputContract{}, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err)
|
||||||
|
}
|
||||||
|
return contract, nil
|
||||||
|
}
|
||||||
|
|
||||||
func stringPointer(value string) *string {
|
func stringPointer(value string) *string {
|
||||||
copy := value
|
copy := value
|
||||||
return ©
|
return ©
|
||||||
@@ -305,7 +273,6 @@ func (state *loaded) lookupPromptEntry(id string) (*promptEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition {
|
||||||
definition.Version = stringPointerValue(definition.Version)
|
|
||||||
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
|
||||||
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
|
||||||
for index := range definition.Templates {
|
for index := range definition.Templates {
|
||||||
@@ -318,7 +285,6 @@ func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection {
|
||||||
inspection.PromptVersion = stringPointerValue(inspection.PromptVersion)
|
|
||||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||||
return &inspection
|
return &inspection
|
||||||
}
|
}
|
||||||
@@ -351,8 +317,8 @@ func (state *loaded) GetPromptSnapshot(ctx context.Context, id string) (*domain.
|
|||||||
return ownPromptDefinition(entry.definition), entry.hash, nil
|
return ownPromptDefinition(entry.definition), entry.hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPromptInspection returns an independently owned inspection of one
|
// GetPromptInspection returns lightweight metadata used for requirement
|
||||||
// captured prompt without copying its execution templates.
|
// selection, independently of the full resource inspection view.
|
||||||
func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -364,12 +330,6 @@ func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domai
|
|||||||
return ownPromptInspection(entry.inspection), nil
|
return ownPromptInspection(entry.inspection), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPromptDefinition implements the runner's prompt lookup boundary.
|
|
||||||
func (state *loaded) GetPromptDefinition(ctx context.Context, id string) (*domain.PromptDefinition, error) {
|
|
||||||
definition, _, err := state.GetPromptSnapshot(ctx, id)
|
|
||||||
return definition, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCompiledTemplates returns construction-time parses for one prompt ID.
|
// GetCompiledTemplates returns construction-time parses for one prompt ID.
|
||||||
func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) {
|
func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) {
|
||||||
if state == nil {
|
if state == nil {
|
||||||
|
|||||||
@@ -10,23 +10,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"go.maximumdirect.net/promptkit/internal/promptdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\n - path: ../other/definition.yaml\ncollections:\n docs:\n prompts:\n child:\n extends: base\n")},
|
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n description: ' description '\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||||
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
"app/body.md": {Data: []byte("Replacement {{.name}}")},
|
||||||
|
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n description: ' description '\n session_id: ' session '\n inputs:\n - name: ' document '\n content_type: ' text/plain '\n description: ' document source '\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
|
||||||
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
|
||||||
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n child:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
|
||||||
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||||
}
|
}
|
||||||
namespace := configkit.NewFilesystem("embedded", files)
|
namespace := configkit.NewFilesystem("embedded", files)
|
||||||
state, err := load(configkit.Input{
|
state, err := load(configkit.Input{
|
||||||
Source: namespace.File("app/main.yaml"), Imports: true,
|
Source: namespace.File("app/main.yaml"), Imports: true,
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
||||||
}, nil)
|
}, []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("other/definition.yaml"), Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) {
|
if errors.As(err, &loadError) {
|
||||||
@@ -41,11 +42,29 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
||||||
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
||||||
}
|
}
|
||||||
|
if definition.Description != "description" || definition.SessionID != "session" || len(definition.Inputs) != 1 ||
|
||||||
|
definition.Inputs[0].Name != "document" || definition.Inputs[0].ContentType != "text/plain" || definition.Inputs[0].Description != "document source" {
|
||||||
|
t.Fatalf("inherited metadata was not interpreted: %+v", definition)
|
||||||
|
}
|
||||||
message := definition.Templates[0]
|
message := definition.Templates[0]
|
||||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||||
}
|
}
|
||||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file"))
|
childIndex := -1
|
||||||
|
for index, item := range state.result.Config.Collections["docs"].Prompts {
|
||||||
|
if item.ID == "child" {
|
||||||
|
childIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if childIndex < 0 {
|
||||||
|
t.Fatal("inherited prompt missing from effective list")
|
||||||
|
}
|
||||||
|
composed := state.result.Config.Collections["docs"].Prompts[childIndex]
|
||||||
|
if *composed.Description != "description" || *composed.SessionID != "session" || composed.Inputs[0].Name != "document" ||
|
||||||
|
*composed.Inputs[0].ContentType != "text/plain" || *composed.Inputs[0].Description != "document source" {
|
||||||
|
t.Fatalf("resolved metadata was not normalized: %+v", composed)
|
||||||
|
}
|
||||||
|
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts").Index(childIndex).Key("messages").Index(0).Key("content_file"))
|
||||||
if !ok || len(field.Origin.Locations) != 1 {
|
if !ok || len(field.Origin.Locations) != 1 {
|
||||||
t.Fatalf("content origin was lost during normalization: %+v", field)
|
t.Fatalf("content origin was lost during normalization: %+v", field)
|
||||||
}
|
}
|
||||||
@@ -59,11 +78,16 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||||
}
|
}
|
||||||
other, otherInspection, err := state.prompt("other/child")
|
other, otherInspection, err := state.prompt("other/child")
|
||||||
if err != nil || other.Templates[0].Content != "Other collection {{.name}}" {
|
if err != nil || other.Templates[0].Content != "Replacement {{.name}}" {
|
||||||
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||||
}
|
}
|
||||||
files["catalog/body.md"].Data = []byte("Changed")
|
files["catalog/body.md"].Data = []byte("Changed")
|
||||||
|
files["app/body.md"].Data = []byte("Changed replacement")
|
||||||
files["other/body.md"].Data = []byte("Changed other collection")
|
files["other/body.md"].Data = []byte("Changed other collection")
|
||||||
|
resource, err := state.InspectPrompt(context.Background(), "docs/child")
|
||||||
|
if err != nil || resource.Messages[0].Content != "Captured {{.name}}" || resource.Messages[0].CacheControl == nil || resource.Messages[0].CacheControl.TTL == nil || *resource.Messages[0].CacheControl.TTL != "1h" || resource.Inputs == nil || (*resource.Inputs)[0].Required != nil {
|
||||||
|
t.Fatalf("captured resource = %+v, %v", resource, err)
|
||||||
|
}
|
||||||
again, againInspection, err := state.prompt("docs/child")
|
again, againInspection, err := state.prompt("docs/child")
|
||||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
||||||
t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err)
|
t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err)
|
||||||
@@ -74,11 +98,47 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||||
}
|
}
|
||||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
||||||
if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
if err != nil || otherAgain.Templates[0].Content != "Replacement {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||||
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptResourcePresence(t *testing.T) {
|
||||||
|
state, err := load(mapping("prompt resources", map[string]any{"prompts": []any{
|
||||||
|
map[string]any{"id": "base", "default_profile": "missing", "inputs": []any{map[string]any{"name": "x", "required": false}, map[string]any{"name": "y"}},
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "", "cache_control": map[string]any{"type": "ephemeral", "ttl": ""}}, map[string]any{"role": "assistant", "content": "{{.missing}}", "cache_control": map[string]any{"type": "ephemeral"}}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic", "repair_attempts": 0}},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "base"}},
|
||||||
|
}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := state.InspectPrompt(context.Background(), "child")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DefaultProfile == nil || *got.DefaultProfile != "missing" || got.Inputs == nil || (*got.Inputs)[0].Required == nil || *(*got.Inputs)[0].Required || (*got.Inputs)[1].Required != nil || got.Output.RepairAttempts == nil || *got.Output.RepairAttempts != 0 || got.Messages[0].Content != "" || got.Messages[0].CacheControl.TTL == nil || *got.Messages[0].CacheControl.TTL != "" || got.Messages[1].CacheControl.TTL != nil {
|
||||||
|
t.Fatalf("prompt resource = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHeaderlessImportedItem(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yaml": {Data: []byte("version: 2\nconfig:\n imports:\n - path: item.yaml\n destination: [prompts]\n placement: sequence-item\n required: true\n")},
|
||||||
|
"item.yaml": {Data: []byte("id: example\nmessages:\n - role: user\n content: hello\noutput:\n format: text\n validation_mode: basic\n")},
|
||||||
|
}
|
||||||
|
namespace := configkit.NewFilesystem("items", files)
|
||||||
|
state, err := load(configkit.Input{Source: namespace.File("main.yaml"), Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
definition, _, err := state.prompt("example")
|
||||||
|
if err != nil || definition.Templates[0].Content != "hello" {
|
||||||
|
t.Fatalf("headerless item = %+v, %v", definition, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||||
filesystem := &countingFS{FS: fstest.MapFS{
|
filesystem := &countingFS{FS: fstest.MapFS{
|
||||||
"assets/body.md": {Data: []byte("From mapping")},
|
"assets/body.md": {Data: []byte("From mapping")},
|
||||||
@@ -97,7 +157,7 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
|||||||
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") {
|
if err == nil || !strings.Contains(err.Error(), "/prompts/0/messages/0/content_file") {
|
||||||
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +166,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
definitionPath := filepath.Join(root, "definition.yaml")
|
definitionPath := filepath.Join(root, "definition.yaml")
|
||||||
contentPath := filepath.Join(root, "body.md")
|
contentPath := filepath.Join(root, "body.md")
|
||||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
if err := os.WriteFile(definitionPath, []byte("prompts:\n - id: example\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
||||||
@@ -125,127 +185,14 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRejectsMalformedStaticSyntaxAndCapturesOptionalVersion(t *testing.T) {
|
func TestLoadRejectsMalformedStaticSyntax(t *testing.T) {
|
||||||
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err := load(mapping("bad-template", map[string]any{"prompts": []any{map[string]any{"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}), nil)
|
}}}), nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
|
||||||
t.Fatalf("expected construction-time syntax error, got %v", err)
|
t.Fatalf("expected construction-time syntax error, got %v", err)
|
||||||
}
|
}
|
||||||
state, err := load(mapping("version metadata", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
|
||||||
"version": "release label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "one"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}}}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version == nil || *definition.Version != "release label" ||
|
|
||||||
inspection.PromptVersion == nil || *inspection.PromptVersion != "release label" {
|
|
||||||
t.Fatalf("optional version metadata was not retained: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionLabelPresenceAndOwnership(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
version any
|
|
||||||
include bool
|
|
||||||
wantPresent bool
|
|
||||||
wantValue string
|
|
||||||
}{
|
|
||||||
{name: "absent"},
|
|
||||||
{name: "null", include: true},
|
|
||||||
{name: "empty", version: "", include: true, wantPresent: true},
|
|
||||||
{name: "exact text", version: " Release/v1 ", include: true, wantPresent: true, wantValue: " Release/v1 "},
|
|
||||||
}
|
|
||||||
|
|
||||||
hashes := make(map[string]string, len(tests))
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
definition := map[string]any{
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
if test.include {
|
|
||||||
definition["version"] = test.version
|
|
||||||
}
|
|
||||||
state, err := load(mapping("prompt version label", map[string]any{
|
|
||||||
"prompts": map[string]any{"example": definition},
|
|
||||||
}), nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
captured, inspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
identities, err := state.ListPrompts(context.Background())
|
|
||||||
if err != nil || len(identities) != 1 {
|
|
||||||
t.Fatalf("list prompt labels: %#v, %v", identities, err)
|
|
||||||
}
|
|
||||||
check := func(name string, value *string) {
|
|
||||||
t.Helper()
|
|
||||||
if !test.wantPresent {
|
|
||||||
if value != nil {
|
|
||||||
t.Errorf("%s label = %q, want absent", name, *value)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if value == nil || *value != test.wantValue {
|
|
||||||
t.Errorf("%s label = %v, want %q", name, value, test.wantValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check("definition", captured.Version)
|
|
||||||
check("inspection", inspection.PromptVersion)
|
|
||||||
check("listing", identities[0].Version)
|
|
||||||
if captured.Version != nil && (captured.Version == inspection.PromptVersion || captured.Version == identities[0].Version || inspection.PromptVersion == identities[0].Version) {
|
|
||||||
t.Fatal("returned prompt labels share mutable pointers")
|
|
||||||
}
|
|
||||||
hashes[test.name] = inspection.PromptHash
|
|
||||||
|
|
||||||
if captured.Version != nil {
|
|
||||||
*captured.Version = "mutated definition"
|
|
||||||
}
|
|
||||||
if inspection.PromptVersion != nil {
|
|
||||||
*inspection.PromptVersion = "mutated inspection"
|
|
||||||
}
|
|
||||||
if identities[0].Version != nil {
|
|
||||||
*identities[0].Version = "mutated listing"
|
|
||||||
}
|
|
||||||
again, againInspection, err := state.prompt("example")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
check("captured definition after mutation", again.Version)
|
|
||||||
check("captured inspection after mutation", againInspection.PromptVersion)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if hashes["absent"] == hashes["empty"] {
|
|
||||||
t.Fatal("absent and declared-empty labels produced the same definition hash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptVersionNullClearsInheritedLabel(t *testing.T) {
|
|
||||||
prompt := map[string]any{
|
|
||||||
"version": "base-label",
|
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
|
||||||
}
|
|
||||||
state, err := load(
|
|
||||||
mapping("clear inherited label", map[string]any{"prompts": map[string]any{"example": map[string]any{"version": nil}}}),
|
|
||||||
[]configkit.Underlay{{Inputs: []configkit.Input{mapping("base prompt", map[string]any{"prompts": map[string]any{"example": prompt}})}}},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
definition, inspection, err := state.prompt("example")
|
|
||||||
if err != nil || definition.Version != nil || inspection.PromptVersion != nil {
|
|
||||||
t.Fatalf("null overlay retained inherited label: definition=%+v inspection=%+v err=%v", definition, inspection, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.T) {
|
||||||
@@ -261,12 +208,10 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedPrompt := makePrompt("selected")
|
selectedPrompt := makePrompt("selected")
|
||||||
selectedPrompt["version"] = "v2"
|
selectedPrompt["id"] = "selected"
|
||||||
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
state, err := load(mapping("prompt inspection lookup", map[string]any{
|
||||||
"profiles": map[string]any{"partial": map[string]any{}},
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
"prompts": map[string]any{
|
"prompts": []any{selectedPrompt},
|
||||||
"selected": selectedPrompt,
|
|
||||||
},
|
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -281,8 +226,7 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("snapshot prompt ID: %v", err)
|
t.Fatalf("snapshot prompt ID: %v", err)
|
||||||
}
|
}
|
||||||
if inspection.PromptID != definition.ID || inspection.PromptVersion == nil || definition.Version == nil ||
|
if inspection.PromptID != definition.ID ||
|
||||||
*inspection.PromptVersion != *definition.Version ||
|
|
||||||
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
inspection.PromptHash != hash || inspection.DefaultProfileID != "partial" ||
|
||||||
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "document" ||
|
||||||
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
inspection.Inputs[0].ContentType != "text/plain" || inspection.Inputs[0].Description != "source document" {
|
||||||
@@ -322,9 +266,8 @@ func TestPromptInspectionLookupMatchesSnapshotSelectionAndOwnsInputs(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
|
||||||
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{
|
state, err := load(mapping("empty-content", map[string]any{"prompts": []any{map[string]any{"id": " exact.id ",
|
||||||
"version": "v1",
|
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
||||||
"inputs": []any{map[string]any{"name": " note ", "required": true}},
|
|
||||||
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
|
||||||
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -344,7 +287,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
if _, _, err := state.prompt("exact.id"); !errors.Is(err, errPromptNotFound) {
|
||||||
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{
|
_, err = load(mapping("obsolete-field", map[string]any{"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -353,7 +296,7 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
if !errors.As(err, &loadError) {
|
if !errors.As(err, &loadError) {
|
||||||
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
|
||||||
}
|
}
|
||||||
_, err = load(mapping("null-prompt", map[string]any{"prompts": map[string]any{"example": nil}}), nil)
|
_, err = load(mapping("null-prompt", map[string]any{"prompts": []any{nil}}), nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("null prompt collection was accepted")
|
t.Fatal("null prompt collection was accepted")
|
||||||
}
|
}
|
||||||
@@ -361,8 +304,8 @@ func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T
|
|||||||
|
|
||||||
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
func TestRequirementSnapshotCapturesMetadataPresenceAndNormalizedValues(t *testing.T) {
|
||||||
state, err := load(mapping("requirement-metadata", map[string]any{
|
state, err := load(mapping("requirement-metadata", map[string]any{
|
||||||
"prompts": map[string]any{"example": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "example",
|
||||||
"session_id": "{{.session}}",
|
"session_id": "{{.session}}",
|
||||||
"inputs": []any{
|
"inputs": []any{
|
||||||
map[string]any{"name": "absent"},
|
map[string]any{"name": "absent"},
|
||||||
@@ -435,8 +378,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
input[name] = value
|
input[name] = value
|
||||||
}
|
}
|
||||||
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
state, err := load(mapping("hash-"+variant.name, map[string]any{
|
||||||
"prompts": map[string]any{"same": map[string]any{
|
"prompts": []any{map[string]any{
|
||||||
"version": "v1",
|
"id": "same",
|
||||||
"inputs": []any{input},
|
"inputs": []any{input},
|
||||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
@@ -466,7 +409,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func promptWithFile(name string) map[string]any {
|
func promptWithFile(name string) map[string]any {
|
||||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{
|
return map[string]any{"prompts": []any{map[string]any{
|
||||||
|
"id": "example",
|
||||||
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}
|
}}}
|
||||||
|
|||||||
68
internal/configuration/resolve_prompts.go
Normal file
68
internal/configuration/resolve_prompts.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolvePrompts(entries []promptConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v promptConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&entry.Description, "description"}, {&entry.SessionID, "session_id"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j := range entry.Inputs {
|
||||||
|
input := &entry.Inputs[j]
|
||||||
|
inputPath := path.Key("inputs").Index(j)
|
||||||
|
if normalized := strings.TrimSpace(input.Name); normalized != input.Name {
|
||||||
|
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalized, "Normalize input name")
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&input.ContentType, "content_type"}, {&input.Description, "description"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, inputPath.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.Output != nil && entry.Output.SchemaPath != nil {
|
||||||
|
recordStringNormalization(&entry.Output.SchemaPath, path.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*entry.Output.SchemaPath))
|
||||||
|
}
|
||||||
|
for j := range entry.Messages {
|
||||||
|
message := &entry.Messages[j]
|
||||||
|
messagePath := path.Key("messages").Index(j)
|
||||||
|
if message.Role != nil {
|
||||||
|
if value, err := domain.NormalizeMessageRole(*message.Role); err == nil {
|
||||||
|
recordStringNormalization(&message.Role, messagePath.Key("role"), resolution, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cache := message.CacheControl
|
||||||
|
control := &domain.CacheControl{Type: domain.CacheControlType(*cache.Type)}
|
||||||
|
if cache.TTL != nil {
|
||||||
|
control.TTL = *cache.TTL
|
||||||
|
}
|
||||||
|
if normalized, err := domain.NormalizeCacheControl(control); err == nil {
|
||||||
|
cachePath := messagePath.Key("cache_control")
|
||||||
|
recordStringNormalization(&cache.Type, cachePath.Key("type"), resolution, string(normalized.Type))
|
||||||
|
if cache.TTL != nil {
|
||||||
|
recordStringNormalization(&cache.TTL, cachePath.Key("ttl"), resolution, normalized.TTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/configuration/resolve_targets.go
Normal file
102
internal/configuration/resolve_targets.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveBackends(entries []backendConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v backendConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
if entry.Endpoint != nil {
|
||||||
|
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
|
||||||
|
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.APIKeyEnv != nil {
|
||||||
|
recordStringNormalization(&entry.APIKeyEnv, path.Key("api_key_env"), resolution, strings.TrimSpace(*entry.APIKeyEnv))
|
||||||
|
}
|
||||||
|
if entry.ConcurrencyLimit != nil && *entry.ConcurrencyLimit > 0 && entry.QueueCapacity == nil {
|
||||||
|
capacity := 1024
|
||||||
|
entry.QueueCapacity = &capacity
|
||||||
|
resolution.RecordDerivedDefault(path.Key("queue_capacity"), "Derive queue capacity", path.Key("concurrency_limit"))
|
||||||
|
}
|
||||||
|
if entry.SmokeTests == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
smoke := entry.SmokeTests
|
||||||
|
smokePath := path.Key("smoke_tests")
|
||||||
|
if smoke.Reachability != nil {
|
||||||
|
resolveDiagnosticEndpoint(&smoke.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution)
|
||||||
|
}
|
||||||
|
if smoke.Authentication != nil {
|
||||||
|
auth := smoke.Authentication
|
||||||
|
authPath := smokePath.Key("authentication")
|
||||||
|
if auth.Kind != nil {
|
||||||
|
recordStringNormalization(&auth.Kind, authPath.Key("kind"), resolution, strings.ToLower(strings.TrimSpace(*auth.Kind)))
|
||||||
|
}
|
||||||
|
resolveDiagnosticEndpoint(&auth.Endpoint, authPath.Key("endpoint"), resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticEndpointValid(value string) bool {
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
request, err := http.NewRequest(http.MethodGet, normalized, nil)
|
||||||
|
return normalized != "" && err == nil && request.URL != nil && (strings.EqualFold(request.URL.Scheme, "http") || strings.EqualFold(request.URL.Scheme, "https")) && request.URL.Hostname() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBackendEndpoints(entry backendConfig, path configkit.Path, validation *configkit.Validation) {
|
||||||
|
if entry.Endpoint != nil {
|
||||||
|
if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err != nil {
|
||||||
|
validation.Error(path.Key("endpoint"), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.SmokeTests == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
smoke := entry.SmokeTests
|
||||||
|
path = path.Key("smoke_tests")
|
||||||
|
if smoke.Reachability != nil && smoke.Reachability.Endpoint != nil && !diagnosticEndpointValid(*smoke.Reachability.Endpoint) {
|
||||||
|
validation.Error(path.Key("reachability").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||||
|
}
|
||||||
|
if smoke.Authentication != nil && smoke.Authentication.Endpoint != nil && !diagnosticEndpointValid(*smoke.Authentication.Endpoint) {
|
||||||
|
validation.Error(path.Key("authentication").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if *value != nil && diagnosticEndpointValid(**value) {
|
||||||
|
recordStringNormalization(value, path, resolution, strings.TrimSpace(**value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveProfiles(entries []profileConfig, base configkit.Path, resolution *configkit.Resolution) {
|
||||||
|
if !eligibleResourceList(entries, func(v profileConfig) string { return v.ID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
path := base.Index(i)
|
||||||
|
if entry.Endpoint != nil && *entry.Endpoint != "" {
|
||||||
|
if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil {
|
||||||
|
recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
value **string
|
||||||
|
name string
|
||||||
|
}{{&entry.ServiceTier, "service_tier"}, {&entry.ReasoningEffort, "reasoning_effort"}, {&entry.APIKeyEnv, "api_key_env"}} {
|
||||||
|
if *field.value != nil {
|
||||||
|
recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/configuration/resources.go
Normal file
140
internal/configuration/resources.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileResource is the resolved profile before execution completion.
|
||||||
|
// Its fields borrow immutable configuration captured by the adapter.
|
||||||
|
type ProfileResource struct {
|
||||||
|
ID string
|
||||||
|
Backend, Endpoint, Model *string
|
||||||
|
Temperature, TopP *float64
|
||||||
|
MaxTokens, TimeoutSeconds *int
|
||||||
|
ServiceTier, ReasoningEffort *string
|
||||||
|
APIKeyEnv *string
|
||||||
|
APIKeyRequired *bool
|
||||||
|
ExtraParams map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendResource is the resolved backend and its configured diagnostics.
|
||||||
|
// Its fields borrow immutable configuration captured by the adapter.
|
||||||
|
type BackendResource struct {
|
||||||
|
ID, Endpoint string
|
||||||
|
APIKeyEnv *string
|
||||||
|
ConcurrencyLimit, QueueCapacity *int
|
||||||
|
ExtraParams map[string]any
|
||||||
|
SmokeTests *domain.BackendSmokeTestConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptResource is the resolved prompt with already captured message text.
|
||||||
|
type PromptResource struct {
|
||||||
|
ID, Hash string
|
||||||
|
DefaultProfile, Description, SessionID *string
|
||||||
|
Inputs *[]PromptInputResource
|
||||||
|
Messages []PromptMessageResource
|
||||||
|
Output PromptOutputResource
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptInputResource struct {
|
||||||
|
Name string
|
||||||
|
Required *bool
|
||||||
|
ContentType, Description *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptMessageResource struct {
|
||||||
|
Role, Content string
|
||||||
|
CacheControl *PromptCacheControlResource
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptCacheControlResource struct {
|
||||||
|
Type domain.CacheControlType
|
||||||
|
TTL *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptOutputResource struct {
|
||||||
|
Format domain.OutputFormat
|
||||||
|
ValidationMode domain.ValidationMode
|
||||||
|
SchemaPath *string
|
||||||
|
RepairAttempts *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *loaded) InspectPrompt(ctx context.Context, id string) (*PromptResource, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, err := state.lookupPromptEntry(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, promptLookupError(err)
|
||||||
|
}
|
||||||
|
raw := entry.resource
|
||||||
|
result := &PromptResource{ID: id, Hash: entry.hash, DefaultProfile: raw.DefaultProfile, Description: raw.Description, SessionID: raw.SessionID}
|
||||||
|
if raw.Inputs != nil {
|
||||||
|
inputs := make([]PromptInputResource, len(raw.Inputs))
|
||||||
|
presence := state.promptMetadata[id]
|
||||||
|
for i, input := range raw.Inputs {
|
||||||
|
inputs[i] = PromptInputResource{Name: input.Name, ContentType: input.ContentType, Description: input.Description}
|
||||||
|
if presence[i].required == fieldValue {
|
||||||
|
required := input.Required
|
||||||
|
inputs[i].Required = &required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Inputs = &inputs
|
||||||
|
}
|
||||||
|
result.Messages = make([]PromptMessageResource, len(entry.definition.Templates))
|
||||||
|
for i, message := range entry.definition.Templates {
|
||||||
|
result.Messages[i] = PromptMessageResource{Role: message.Role, Content: message.Content}
|
||||||
|
if message.CacheControl != nil {
|
||||||
|
result.Messages[i].CacheControl = &PromptCacheControlResource{Type: message.CacheControl.Type, TTL: raw.Messages[i].CacheControl.TTL}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Output = PromptOutputResource{Format: entry.definition.Validation.Format, ValidationMode: entry.definition.Validation.ValidationMode, SchemaPath: raw.Output.SchemaPath}
|
||||||
|
if state.promptRepair[id] {
|
||||||
|
value := raw.Output.RepairAttempts
|
||||||
|
result.Output.RepairAttempts = &value
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *loaded) InspectProfile(ctx context.Context, id string) (*ProfileResource, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q", profile.ErrProfileNotFound, id)
|
||||||
|
}
|
||||||
|
config, ok := state.profileRecords[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %q", profile.ErrProfileNotFound, id)
|
||||||
|
}
|
||||||
|
return &ProfileResource{
|
||||||
|
ID: id, Backend: config.Backend, Endpoint: config.Endpoint, Model: config.Model,
|
||||||
|
Temperature: config.Temperature, TopP: config.TopP, MaxTokens: config.MaxTokens,
|
||||||
|
TimeoutSeconds: config.TimeoutSeconds, ServiceTier: config.ServiceTier,
|
||||||
|
ReasoningEffort: config.ReasoningEffort, APIKeyEnv: config.APIKeyEnv,
|
||||||
|
APIKeyRequired: config.APIKeyRequired, ExtraParams: config.ExtraParams,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *loaded) InspectBackend(ctx context.Context, id string) (*BackendResource, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q", backend.ErrBackendNotFound, id)
|
||||||
|
}
|
||||||
|
config, ok := state.backendRecords[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %q", backend.ErrBackendNotFound, id)
|
||||||
|
}
|
||||||
|
return &BackendResource{
|
||||||
|
ID: id, Endpoint: valueOrZero(config.Endpoint), APIKeyEnv: config.APIKeyEnv,
|
||||||
|
ConcurrencyLimit: config.ConcurrencyLimit, QueueCapacity: config.QueueCapacity,
|
||||||
|
ExtraParams: config.ExtraParams, SmokeTests: captureBackend(id, config).SmokeTests,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
67
internal/configuration/resources_test.go
Normal file
67
internal/configuration/resources_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package configuration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolvedBackendAndProfileResources(t *testing.T) {
|
||||||
|
state, err := load(mapping("resources", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.test/v1", "concurrency_limit": 2,
|
||||||
|
"extra_params": map[string]any{"backend": true}, "smoke_tests": map[string]any{"default_profile": "missing", "reachability": map[string]any{}}}},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "parent", "backend": "local", "temperature": 0.4},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "parent"}, "temperature": 0, "endpoint": "", "extra_params": map[string]any{}},
|
||||||
|
},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resource, err := state.InspectProfile(context.Background(), "child")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resource.ID != "child" || resource.Backend == nil || *resource.Backend != "local" || resource.Endpoint == nil || *resource.Endpoint != "" || resource.Temperature == nil || *resource.Temperature != 0 || resource.Model != nil || resource.ExtraParams == nil || len(resource.ExtraParams) != 0 {
|
||||||
|
t.Fatalf("profile resource = %+v", resource)
|
||||||
|
}
|
||||||
|
target, err := state.GetProfile(context.Background(), "child")
|
||||||
|
if err != nil || target.ResolvedTarget.ExtraParams["backend"] != true {
|
||||||
|
t.Fatalf("execution target = %+v, %v", target, err)
|
||||||
|
}
|
||||||
|
resourceBackend, err := state.InspectBackend(context.Background(), "local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resourceBackend.Endpoint != "https://example.test/v1" || resourceBackend.QueueCapacity == nil || *resourceBackend.QueueCapacity != 1024 || resourceBackend.SmokeTests == nil || resourceBackend.SmokeTests.DefaultProfile == nil || *resourceBackend.SmokeTests.DefaultProfile != "missing" || resourceBackend.SmokeTests.Reachability == nil || resourceBackend.SmokeTests.Reachability.Endpoint != nil {
|
||||||
|
t.Fatalf("backend resource = %+v", resourceBackend)
|
||||||
|
}
|
||||||
|
if _, err := state.InspectBackend(context.Background(), "missing"); !errors.Is(err, backend.ErrBackendNotFound) {
|
||||||
|
t.Fatalf("backend error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := state.InspectProfile(context.Background(), "missing"); !errors.Is(err, profile.ErrProfileNotFound) {
|
||||||
|
t.Fatalf("profile error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := state.InspectBackend(ctx, "local"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendResourcePreservesInheritedDiagnostics(t *testing.T) {
|
||||||
|
state, err := load(mapping("backend diagnostics", map[string]any{"backends": []any{
|
||||||
|
map[string]any{"id": "parent", "endpoint": "https://provider.test/v1", "smoke_tests": map[string]any{"reachability": map[string]any{"endpoint": "https://probe.test/ready"}, "authentication": map[string]any{"kind": "none"}}},
|
||||||
|
map[string]any{"id": "child", "extends": map[string]any{"id": "parent"}},
|
||||||
|
}}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := state.InspectBackend(context.Background(), "child")
|
||||||
|
if err != nil || got.ID != "child" || got.ConcurrencyLimit != nil || got.QueueCapacity != nil || got.SmokeTests == nil || got.SmokeTests.Reachability == nil || got.SmokeTests.Reachability.Endpoint == nil || *got.SmokeTests.Reachability.Endpoint != "https://probe.test/ready" || got.SmokeTests.Authentication == nil || got.SmokeTests.Authentication.Kind == nil || *got.SmokeTests.Authentication.Kind != "none" || got.SmokeTests.Authentication.Endpoint != nil {
|
||||||
|
t.Fatalf("inherited diagnostics = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,122 +4,127 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"go.maximumdirect.net/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"go.maximumdirect.net/promptkit/internal/jsonvalue"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (state *loaded) buildExecutionProfiles() error {
|
func (state *loaded) buildExecutionProfiles() error {
|
||||||
backendConfigs := indexedBackends(&state.result.Config)
|
backendConfigs := indexedBackends(&state.result.Config)
|
||||||
profileConfigs := indexedProfiles(&state.result.Config)
|
profileConfigs := indexedProfiles(&state.result.Config)
|
||||||
|
state.backendRecords = backendConfigs
|
||||||
|
state.profileRecords = profileConfigs
|
||||||
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
definitions := make(map[string]domain.Backend, len(backendConfigs))
|
||||||
for _, id := range sortedKeys(backendConfigs) {
|
for _, id := range sortedKeys(backendConfigs) {
|
||||||
config := backendConfigs[id]
|
config := backendConfigs[id]
|
||||||
definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams}
|
definitions[id] = captureBackend(id, config)
|
||||||
if config.SmokeTests != nil {
|
|
||||||
smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile}
|
|
||||||
if config.SmokeTests.Reachability != nil {
|
|
||||||
smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint}
|
|
||||||
}
|
|
||||||
if config.SmokeTests.Authentication != nil {
|
|
||||||
smokeTests.Authentication = &domain.AuthenticationProbeConfig{
|
|
||||||
Kind: config.SmokeTests.Authentication.Kind,
|
|
||||||
Endpoint: config.SmokeTests.Authentication.Endpoint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
definition.SmokeTests = smokeTests
|
|
||||||
}
|
|
||||||
if config.Endpoint != nil {
|
|
||||||
definition.Endpoint = *config.Endpoint
|
|
||||||
}
|
|
||||||
if config.APIKeyEnv != nil {
|
|
||||||
definition.APIKeyEnv = *config.APIKeyEnv
|
|
||||||
}
|
|
||||||
if config.ConcurrencyLimit != nil {
|
|
||||||
definition.ConcurrencyLimit = *config.ConcurrencyLimit
|
|
||||||
}
|
|
||||||
if config.QueueCapacity != nil {
|
|
||||||
definition.QueueCapacity = *config.QueueCapacity
|
|
||||||
definition.QueueCapacitySet = definition.ConcurrencyLimit > 0
|
|
||||||
}
|
|
||||||
definitions[id] = definition
|
|
||||||
}
|
}
|
||||||
registry, err := backend.NewEffectiveRegistry(definitions)
|
state.registry = backend.NewEffectiveRegistry(definitions)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
state.registry = registry
|
|
||||||
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
|
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
|
||||||
for _, id := range sortedKeys(profileConfigs) {
|
for _, id := range sortedKeys(profileConfigs) {
|
||||||
config := profileConfigs[id]
|
config := profileConfigs[id]
|
||||||
fields := state.profiles[id]
|
profile, err := captureExecutionProfile(id, config, state.registry)
|
||||||
target := defaults.ExecutionTargetDefault()
|
if err != nil {
|
||||||
if config.Backend != nil && *config.Backend != "" {
|
return err
|
||||||
selected, err := registry.GetBackend(*config.Backend)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("profile %q backend: %w", id, err)
|
|
||||||
}
|
|
||||||
target.BackendID = selected.ID
|
|
||||||
target.Endpoint = selected.Endpoint
|
|
||||||
target.APIKeyEnv = selected.APIKeyEnv
|
|
||||||
target.ExtraParams = selected.ExtraParams
|
|
||||||
}
|
}
|
||||||
presence := domain.ExecutionTargetPresence{}
|
|
||||||
if fields["endpoint"] != fieldAbsent {
|
|
||||||
target.Endpoint = valueOrZero(config.Endpoint)
|
|
||||||
}
|
|
||||||
if fields["model"] != fieldAbsent {
|
|
||||||
target.Model = valueOrZero(config.Model)
|
|
||||||
}
|
|
||||||
if fields["temperature"] != fieldAbsent {
|
|
||||||
target.Temperature = valueOrZero(config.Temperature)
|
|
||||||
presence.Temperature = fields["temperature"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["top_p"] != fieldAbsent {
|
|
||||||
target.TopP = valueOrZero(config.TopP)
|
|
||||||
presence.TopP = fields["top_p"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["max_tokens"] != fieldAbsent {
|
|
||||||
target.MaxTokens = valueOrZero(config.MaxTokens)
|
|
||||||
presence.MaxTokens = fields["max_tokens"] == fieldValue
|
|
||||||
}
|
|
||||||
if fields["timeout_seconds"] == fieldValue {
|
|
||||||
target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds)
|
|
||||||
presence.TimeoutSeconds = true
|
|
||||||
}
|
|
||||||
if fields["service_tier"] != fieldAbsent {
|
|
||||||
target.ServiceTier = valueOrZero(config.ServiceTier)
|
|
||||||
}
|
|
||||||
if fields["reasoning_effort"] != fieldAbsent {
|
|
||||||
target.ReasoningEffort = valueOrZero(config.ReasoningEffort)
|
|
||||||
}
|
|
||||||
if fields["api_key_env"] != fieldAbsent {
|
|
||||||
target.APIKeyEnv = valueOrZero(config.APIKeyEnv)
|
|
||||||
}
|
|
||||||
if fields["api_key_required"] != fieldAbsent {
|
|
||||||
target.APIKeyRequired = valueOrZero(config.APIKeyRequired)
|
|
||||||
}
|
|
||||||
if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent {
|
|
||||||
target.APIKeyEnv = ""
|
|
||||||
}
|
|
||||||
switch fields["extra_params"] {
|
|
||||||
case fieldNull:
|
|
||||||
target.ExtraParams = nil
|
|
||||||
case fieldValue:
|
|
||||||
merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("profile %q extra parameters: %w", id, err)
|
|
||||||
}
|
|
||||||
target.ExtraParams = merged
|
|
||||||
}
|
|
||||||
profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence}
|
|
||||||
state.indexedProfiles[id] = profile
|
state.indexedProfiles[id] = profile
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func captureBackend(id string, config backendConfig) domain.Backend {
|
||||||
|
definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams}
|
||||||
|
if config.SmokeTests != nil {
|
||||||
|
smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile}
|
||||||
|
if config.SmokeTests.Reachability != nil {
|
||||||
|
smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint}
|
||||||
|
}
|
||||||
|
if config.SmokeTests.Authentication != nil {
|
||||||
|
smokeTests.Authentication = &domain.AuthenticationProbeConfig{
|
||||||
|
Kind: config.SmokeTests.Authentication.Kind,
|
||||||
|
Endpoint: config.SmokeTests.Authentication.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
definition.SmokeTests = smokeTests
|
||||||
|
}
|
||||||
|
if config.Endpoint != nil {
|
||||||
|
definition.Endpoint = *config.Endpoint
|
||||||
|
}
|
||||||
|
if config.APIKeyEnv != nil {
|
||||||
|
definition.APIKeyEnv = *config.APIKeyEnv
|
||||||
|
}
|
||||||
|
if config.ConcurrencyLimit != nil {
|
||||||
|
definition.ConcurrencyLimit = *config.ConcurrencyLimit
|
||||||
|
}
|
||||||
|
if config.QueueCapacity != nil {
|
||||||
|
definition.QueueCapacity = *config.QueueCapacity
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureExecutionProfile(id string, config profileConfig, registry *backend.Registry) (domain.ExecutionProfile, error) {
|
||||||
|
target := defaults.ExecutionTargetDefault()
|
||||||
|
if config.Backend != nil && *config.Backend != "" {
|
||||||
|
selected, err := registry.GetBackend(*config.Backend)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionProfile{}, fmt.Errorf("profile %q backend: %w", id, err)
|
||||||
|
}
|
||||||
|
target.BackendID = selected.ID
|
||||||
|
target.Endpoint = selected.Endpoint
|
||||||
|
target.APIKeyEnv = selected.APIKeyEnv
|
||||||
|
target.ExtraParams = selected.ExtraParams
|
||||||
|
}
|
||||||
|
presence := domain.ExecutionTargetPresence{}
|
||||||
|
if config.Endpoint != nil {
|
||||||
|
target.Endpoint = valueOrZero(config.Endpoint)
|
||||||
|
}
|
||||||
|
if config.Model != nil {
|
||||||
|
target.Model = valueOrZero(config.Model)
|
||||||
|
}
|
||||||
|
if config.Temperature != nil {
|
||||||
|
target.Temperature = valueOrZero(config.Temperature)
|
||||||
|
presence.Temperature = true
|
||||||
|
}
|
||||||
|
if config.TopP != nil {
|
||||||
|
target.TopP = valueOrZero(config.TopP)
|
||||||
|
presence.TopP = true
|
||||||
|
}
|
||||||
|
if config.MaxTokens != nil {
|
||||||
|
target.MaxTokens = valueOrZero(config.MaxTokens)
|
||||||
|
presence.MaxTokens = true
|
||||||
|
}
|
||||||
|
if config.TimeoutSeconds != nil {
|
||||||
|
target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds)
|
||||||
|
presence.TimeoutSeconds = true
|
||||||
|
}
|
||||||
|
if config.ServiceTier != nil {
|
||||||
|
target.ServiceTier = valueOrZero(config.ServiceTier)
|
||||||
|
}
|
||||||
|
if config.ReasoningEffort != nil {
|
||||||
|
target.ReasoningEffort = valueOrZero(config.ReasoningEffort)
|
||||||
|
}
|
||||||
|
if config.APIKeyEnv != nil {
|
||||||
|
target.APIKeyEnv = valueOrZero(config.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if config.APIKeyRequired != nil {
|
||||||
|
target.APIKeyRequired = valueOrZero(config.APIKeyRequired)
|
||||||
|
}
|
||||||
|
if target.APIKeyRequired && config.APIKeyEnv == nil {
|
||||||
|
target.APIKeyEnv = ""
|
||||||
|
}
|
||||||
|
if config.ExtraParams != nil {
|
||||||
|
merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ExecutionProfile{}, fmt.Errorf("profile %q extra parameters: %w", id, err)
|
||||||
|
}
|
||||||
|
target.ExtraParams = merged
|
||||||
|
}
|
||||||
|
profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
func valueOrZero[T any](value *T) T {
|
func valueOrZero[T any](value *T) T {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
var zero T
|
var zero T
|
||||||
@@ -138,24 +143,33 @@ func overlayProviderMap(lower, upper map[string]any) (map[string]any, error) {
|
|||||||
if merged == nil {
|
if merged == nil {
|
||||||
merged = make(map[string]any)
|
merged = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
if err := overlayOwnedProviderMap(merged, upper); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayOwnedProviderMap(merged, upper map[string]any) error {
|
||||||
for key, value := range upper {
|
for key, value := range upper {
|
||||||
if child, ok := value.(map[string]any); ok {
|
if child, ok := value.(map[string]any); ok {
|
||||||
if existing, ok := merged[key].(map[string]any); ok {
|
if existing, ok := merged[key].(map[string]any); ok {
|
||||||
combined, err := overlayProviderMap(existing, child)
|
if existing == nil {
|
||||||
if err != nil {
|
existing = make(map[string]any)
|
||||||
return nil, err
|
merged[key] = existing
|
||||||
|
}
|
||||||
|
if err := overlayOwnedProviderMap(existing, child); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
merged[key] = combined
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
copied, err := jsonvalue.Copy(value)
|
copied, err := jsonvalue.Copy(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
merged[key] = copied
|
merged[key] = copied
|
||||||
}
|
}
|
||||||
return merged, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) lookupProfile(ctx context.Context, id string) (domain.ExecutionProfile, error) {
|
func (state *loaded) lookupProfile(ctx context.Context, id string) (domain.ExecutionProfile, error) {
|
||||||
@@ -180,11 +194,7 @@ func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.Executi
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
target := *profile.ResolvedTarget
|
target := *profile.ResolvedTarget
|
||||||
params, err := jsonvalue.CopyMap(target.ExtraParams)
|
target.ExtraParams = jsonvalue.CloneTrusted(target.ExtraParams).(map[string]any)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
target.ExtraParams = params
|
|
||||||
profile.ResolvedTarget = &target
|
profile.ResolvedTarget = &target
|
||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,43 +9,117 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"go.maximumdirect.net/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"go.maximumdirect.net/promptkit/internal/llm"
|
||||||
profilepkg "gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
profilepkg "go.maximumdirect.net/promptkit/internal/profile"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/prompt"
|
||||||
|
"go.maximumdirect.net/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestLoadIndexesIdentifiedTargets(t *testing.T) {
|
||||||
|
values := map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://root.example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
|
||||||
|
"collections": map[string]any{},
|
||||||
|
}
|
||||||
|
collections := values["collections"].(map[string]any)
|
||||||
|
for _, namespace := range []string{"north", "south"} {
|
||||||
|
collections[namespace] = map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "local", "endpoint": "https://" + namespace + ".example/v1"}},
|
||||||
|
"profiles": []any{map[string]any{"id": "partial", "backend": namespace + "/local"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state, err := load(mapping("identified targets", values), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for id, backendID := range map[string]string{"partial": "local", "north/partial": "north/local", "south/partial": "south/local"} {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil || profile.ID != id || profile.ResolvedTarget == nil || profile.ResolvedTarget.Model != "" || profile.ResolvedTarget.BackendID != backendID {
|
||||||
|
t.Fatalf("partial profile %q = %+v, %v", id, profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayProviderMapOwnsInputsAndReplacesValues(t *testing.T) {
|
||||||
|
lower := map[string]any{"nested": map[string]any{"keep": "lower", "replace": []any{"old"}, "null": "old"}, "disjoint": []any{"original"}}
|
||||||
|
upper := map[string]any{"nested": map[string]any{"replace": []any{"new"}, "null": nil, "added": map[string]any{"value": "upper"}}}
|
||||||
|
got, err := overlayProviderMap(lower, upper)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]any{"nested": map[string]any{"keep": "lower", "replace": []any{"new"}, "null": nil, "added": map[string]any{"value": "upper"}}, "disjoint": []any{"original"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("overlay = %#v", got)
|
||||||
|
}
|
||||||
|
lower["disjoint"].([]any)[0] = "changed"
|
||||||
|
upper["nested"].(map[string]any)["replace"].([]any)[0] = "changed"
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("overlay shares input storage: %#v", got)
|
||||||
|
}
|
||||||
|
got["nested"].(map[string]any)["added"].(map[string]any)["value"] = "changed"
|
||||||
|
if upper["nested"].(map[string]any)["added"].(map[string]any)["value"] != "upper" {
|
||||||
|
t.Fatal("result mutation reached upper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCompletesLoadedPartialProfile(t *testing.T) {
|
||||||
|
state, err := load(mapping("partial profile", map[string]any{
|
||||||
|
"profiles": []any{map[string]any{"id": "partial"}},
|
||||||
|
"prompts": []any{map[string]any{
|
||||||
|
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
|
}},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := usecase.NewRunner(state, state, nil, prompt.NewCompiledRenderer(state), nil, nil, nil)
|
||||||
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "example", ProfileID: "partial",
|
||||||
|
Execution: &domain.ExecutionTargetOverride{Endpoint: "https://example.invalid/v1", Model: "request-model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if prepared.SelectedProfileID != "partial" || prepared.EffectiveModelParams.Endpoint != "https://example.invalid/v1" ||
|
||||||
|
prepared.EffectiveModelParams.Model != "request-model" {
|
||||||
|
t.Fatalf("request did not complete the loaded profile: %+v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
||||||
state, err := load(mapping("effective-targets", map[string]any{
|
state, err := load(mapping("effective-targets", map[string]any{
|
||||||
"backends": map[string]any{
|
"backends": []any{
|
||||||
"first": map[string]any{"endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
map[string]any{"id": "first", "endpoint": "https://first.example/v1", "api_key_env": "FIRST_KEY",
|
||||||
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
"extra_params": map[string]any{"source": "first", "nested": map[string]any{"old": true}}},
|
||||||
"second": map[string]any{"endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
map[string]any{"id": "second", "endpoint": "https://second.example/v1", "api_key_env": "SECOND_KEY",
|
||||||
"concurrency_limit": 2, "queue_capacity": 0,
|
"concurrency_limit": 2, "queue_capacity": 0,
|
||||||
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
"extra_params": map[string]any{"source": "second", "nested": map[string]any{"backend": true}}},
|
||||||
},
|
},
|
||||||
"profiles": map[string]any{
|
"profiles": []any{
|
||||||
"base": map[string]any{"backend": "first", "model": "model", "temperature": 0.8,
|
map[string]any{"id": "base", "backend": "first", "model": "model", "temperature": 0.8,
|
||||||
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
"top_p": 0.9, "max_tokens": 100, "timeout_seconds": 30, "api_key_required": true,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
"extra_params": map[string]any{"nested": map[string]any{"profile": true}}},
|
||||||
"child": map[string]any{"extends": "base", "backend": "second", "temperature": 0.0,
|
map[string]any{"id": "child", "extends": map[string]any{"id": "base"}, "backend": "second", "temperature": 0.0,
|
||||||
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
"top_p": nil, "max_tokens": 0, "timeout_seconds": nil, "api_key_env": nil,
|
||||||
"api_key_required": false,
|
"api_key_required": false,
|
||||||
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
"extra_params": map[string]any{"nested": map[string]any{"child": nil}}},
|
||||||
"direct-key": map[string]any{"backend": "second", "api_key_required": true},
|
map[string]any{"id": "direct-key", "backend": "second", "api_key_required": true},
|
||||||
"named-key": map[string]any{"backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
map[string]any{"id": "named-key", "backend": "second", "api_key_required": true, "api_key_env": "CUSTOM_KEY"},
|
||||||
"no-provider-map": map[string]any{"backend": "second", "extra_params": nil},
|
map[string]any{"id": "no-provider-map", "backend": "second", "extra_params": nil},
|
||||||
"no-timeout": map[string]any{"backend": "second", "timeout_seconds": 0},
|
map[string]any{"id": "no-timeout", "backend": "second", "timeout_seconds": 0},
|
||||||
"partial": map[string]any{},
|
map[string]any{"id": "partial"},
|
||||||
"endpoint-only": map[string]any{"endpoint": "https://standalone.example/v1"},
|
map[string]any{"id": "endpoint-only", "endpoint": "https://standalone.example/v1"},
|
||||||
"cleared-endpoint": map[string]any{"extends": "base", "endpoint": nil},
|
map[string]any{"id": "cleared-endpoint", "extends": map[string]any{"id": "base"}, "endpoint": nil},
|
||||||
},
|
},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
selected, err := state.registry.GetBackend("second")
|
selected, err := state.registry.GetBackend("second")
|
||||||
if err != nil || selected.QueueCapacity != 0 || !selected.QueueCapacitySet {
|
if err != nil || selected.QueueCapacity != 0 || selected.ConcurrencyLimit != 2 {
|
||||||
t.Fatalf("backend capacity was not preserved: %+v, %v", selected, err)
|
t.Fatalf("backend capacity was not preserved: %+v, %v", selected, err)
|
||||||
}
|
}
|
||||||
child, err := state.GetProfile(context.Background(), "child")
|
child, err := state.GetProfile(context.Background(), "child")
|
||||||
@@ -58,7 +132,7 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
|||||||
target := child.ResolvedTarget
|
target := child.ResolvedTarget
|
||||||
if target.BackendID != "second" || target.Endpoint != "https://second.example/v1" || target.Model != "model" ||
|
if target.BackendID != "second" || target.Endpoint != "https://second.example/v1" || target.Model != "model" ||
|
||||||
target.Temperature != 0 || target.TopP != 0 || target.MaxTokens != 0 ||
|
target.Temperature != 0 || target.TopP != 0 || target.MaxTokens != 0 ||
|
||||||
target.TimeoutSeconds != defaults.ExecutionDefaultTimeoutSeconds || target.APIKeyEnv != "" || target.APIKeyRequired {
|
target.TimeoutSeconds != defaults.ExecutionDefaultTimeoutSeconds || target.APIKeyEnv != "SECOND_KEY" || target.APIKeyRequired {
|
||||||
t.Fatalf("effective child target is wrong: %+v", target)
|
t.Fatalf("effective child target is wrong: %+v", target)
|
||||||
}
|
}
|
||||||
if !child.TargetPresence.Temperature || !child.TargetPresence.MaxTokens || child.TargetPresence.TopP || child.TargetPresence.TimeoutSeconds {
|
if !child.TargetPresence.Temperature || !child.TargetPresence.MaxTokens || child.TargetPresence.TopP || child.TargetPresence.TimeoutSeconds {
|
||||||
@@ -80,8 +154,8 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
noMap, _ := state.GetProfile(context.Background(), "no-provider-map")
|
noMap, _ := state.GetProfile(context.Background(), "no-provider-map")
|
||||||
if noMap.ResolvedTarget.ExtraParams != nil {
|
if !reflect.DeepEqual(noMap.ResolvedTarget.ExtraParams, selected.ExtraParams) {
|
||||||
t.Fatalf("explicit null provider map did not suppress backend defaults: %+v", noMap.ResolvedTarget.ExtraParams)
|
t.Fatalf("explicit null provider map did not inherit backend defaults: %+v", noMap.ResolvedTarget.ExtraParams)
|
||||||
}
|
}
|
||||||
noTimeout, _ := state.GetProfile(context.Background(), "no-timeout")
|
noTimeout, _ := state.GetProfile(context.Background(), "no-timeout")
|
||||||
if noTimeout.ResolvedTarget.TimeoutSeconds != 0 || !noTimeout.TargetPresence.TimeoutSeconds {
|
if noTimeout.ResolvedTarget.TimeoutSeconds != 0 || !noTimeout.TargetPresence.TimeoutSeconds {
|
||||||
@@ -109,8 +183,8 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
|||||||
t.Fatalf("endpoint-only profile was not retained: %+v", endpointOnly.ResolvedTarget)
|
t.Fatalf("endpoint-only profile was not retained: %+v", endpointOnly.ResolvedTarget)
|
||||||
}
|
}
|
||||||
clearedEndpoint, _ := state.GetProfile(context.Background(), "cleared-endpoint")
|
clearedEndpoint, _ := state.GetProfile(context.Background(), "cleared-endpoint")
|
||||||
if clearedEndpoint.ResolvedTarget.Endpoint != "" || clearedEndpoint.ResolvedTarget.BackendID != "first" {
|
if clearedEndpoint.ResolvedTarget.Endpoint != "https://first.example/v1" || clearedEndpoint.ResolvedTarget.BackendID != "first" {
|
||||||
t.Fatalf("explicit null recovered backend endpoint: %+v", clearedEndpoint.ResolvedTarget)
|
t.Fatalf("explicit null did not recover backend endpoint: %+v", clearedEndpoint.ResolvedTarget)
|
||||||
}
|
}
|
||||||
child.ResolvedTarget.ExtraParams["source"] = "mutated"
|
child.ResolvedTarget.ExtraParams["source"] = "mutated"
|
||||||
again, _ := state.GetProfile(context.Background(), "child")
|
again, _ := state.GetProfile(context.Background(), "child")
|
||||||
@@ -119,6 +193,48 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProfileNullFallbackAndExplicitValues(t *testing.T) {
|
||||||
|
state, err := load(mapping("profile fallback", map[string]any{
|
||||||
|
"backends": []any{map[string]any{"id": "backend", "endpoint": "https://backend.example/v1", "api_key_env": "BACKEND_KEY", "extra_params": map[string]any{"backend": true}}},
|
||||||
|
"profiles": []any{
|
||||||
|
map[string]any{"id": "parent", "backend": "backend", "endpoint": "https://parent.example/v1", "extra_params": map[string]any{"parent": true}},
|
||||||
|
map[string]any{"id": "inherited-null", "extends": map[string]any{"id": "parent"}, "endpoint": nil, "extra_params": nil, "api_key_env": nil},
|
||||||
|
map[string]any{"id": "omitted", "backend": "backend"},
|
||||||
|
map[string]any{"id": "null", "backend": "backend", "endpoint": nil, "api_key_env": nil, "extra_params": nil},
|
||||||
|
map[string]any{"id": "empty", "backend": "backend", "endpoint": "", "api_key_env": "", "extra_params": map[string]any{}},
|
||||||
|
map[string]any{"id": "required-null", "backend": "backend", "api_key_required": true, "api_key_env": nil},
|
||||||
|
map[string]any{"id": "required-omitted", "backend": "backend", "api_key_required": true},
|
||||||
|
map[string]any{"id": "required-explicit", "backend": "backend", "api_key_required": true, "api_key_env": "PROFILE_KEY"},
|
||||||
|
},
|
||||||
|
}), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"inherited-null", "omitted", "null", "empty", "required-null", "required-omitted", "required-explicit"} {
|
||||||
|
profile, err := state.GetProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := profile.ResolvedTarget
|
||||||
|
wantEndpoint, wantKey := "https://backend.example/v1", "BACKEND_KEY"
|
||||||
|
switch id {
|
||||||
|
case "empty":
|
||||||
|
wantEndpoint, wantKey = "", ""
|
||||||
|
case "required-null", "required-omitted":
|
||||||
|
wantKey = ""
|
||||||
|
case "required-explicit":
|
||||||
|
wantKey = "PROFILE_KEY"
|
||||||
|
}
|
||||||
|
if target.Endpoint != wantEndpoint || target.APIKeyEnv != wantKey || !reflect.DeepEqual(target.ExtraParams, map[string]any{"backend": true}) {
|
||||||
|
t.Errorf("%s target = %+v", id, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child := state.result.Config.Profiles[1]
|
||||||
|
if child.Endpoint != nil || child.ExtraParams != nil || child.APIKeyEnv != nil {
|
||||||
|
t.Fatalf("inherited nulls were not retained in resolved values: %+v", child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
||||||
payloads := make(chan map[string]any, 1)
|
payloads := make(chan map[string]any, 1)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -133,9 +249,9 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
state, err := load(mapping("wire-controls", map[string]any{
|
state, err := load(mapping("wire-controls", map[string]any{
|
||||||
"backends": map[string]any{"local": map[string]any{"endpoint": server.URL + "/v1"}},
|
"backends": []any{map[string]any{"id": "local", "endpoint": server.URL + "/v1"}},
|
||||||
"profiles": map[string]any{"direct": map[string]any{
|
"profiles": []any{map[string]any{
|
||||||
"backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
"id": "direct", "backend": "local", "model": "example", "temperature": 0.0, "max_tokens": 0, "top_p": nil,
|
||||||
}},
|
}},
|
||||||
}), nil)
|
}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,10 +261,7 @@ func TestLoadedZeroControlsReachBuiltInRequest(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
client, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
client := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
|
||||||
Target: *profile.ResolvedTarget, TargetPresence: profile.TargetPresence,
|
Target: *profile.ResolvedTarget, TargetPresence: profile.TargetPresence,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package defaults
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"go.maximumdirect.net/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ type RunResult struct {
|
|||||||
RawOutput string
|
RawOutput string
|
||||||
Validation ValidationResult
|
Validation ValidationResult
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SessionID string
|
SessionID string
|
||||||
RenderedPromptHash string
|
RenderedPromptHash string
|
||||||
@@ -100,7 +99,6 @@ type RunResult struct {
|
|||||||
// It must never include resolved API key values, model output, or validation data.
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
type PreparedRun struct {
|
type PreparedRun struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
SelectedProfileID string
|
SelectedProfileID string
|
||||||
SelectedBackendID string
|
SelectedBackendID string
|
||||||
@@ -134,29 +132,25 @@ type Artifact struct {
|
|||||||
Hash string
|
Hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptIdentity identifies one configured prompt and its descriptive version label.
|
// PromptIdentity identifies one configured prompt.
|
||||||
type PromptIdentity struct {
|
type PromptIdentity struct {
|
||||||
ID string
|
ID string
|
||||||
Version *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptDefinition represents a configured prompt execution definition.
|
// PromptDefinition represents a configured prompt execution definition.
|
||||||
type PromptDefinition struct {
|
type PromptDefinition struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Version *string `yaml:"version"`
|
|
||||||
DefaultProfile string `yaml:"default_profile"`
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
Inputs []PromptInput `yaml:"inputs"`
|
Inputs []PromptInput `yaml:"inputs"`
|
||||||
Templates []PromptMessageTemplate `yaml:"templates"`
|
Templates []PromptMessageTemplate `yaml:"templates"`
|
||||||
OutputFormat OutputFormat `yaml:"output_format"`
|
|
||||||
Validation OutputContract `yaml:"validation"`
|
Validation OutputContract `yaml:"validation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptInspection is the resolved result of exact prompt inspection.
|
// PromptInspection is lightweight metadata for prompt requirement selection.
|
||||||
type PromptInspection struct {
|
type PromptInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
PromptHash string
|
PromptHash string
|
||||||
DefaultProfileID string
|
DefaultProfileID string
|
||||||
Inputs []PromptInput
|
Inputs []PromptInput
|
||||||
@@ -175,7 +169,6 @@ type PromptInput struct {
|
|||||||
type PromptMessageTemplate struct {
|
type PromptMessageTemplate struct {
|
||||||
Role string `yaml:"role"`
|
Role string `yaml:"role"`
|
||||||
Content string `yaml:"content"`
|
Content string `yaml:"content"`
|
||||||
ContentFile string `yaml:"content_file"`
|
|
||||||
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +182,6 @@ type Backend struct {
|
|||||||
SmokeTests *BackendSmokeTestConfig
|
SmokeTests *BackendSmokeTestConfig
|
||||||
ConcurrencyLimit int
|
ConcurrencyLimit int
|
||||||
QueueCapacity int
|
QueueCapacity int
|
||||||
QueueCapacitySet bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendCapacityPolicy describes normalized run and generation capacity for
|
// BackendCapacityPolicy describes normalized run and generation capacity for
|
||||||
@@ -247,13 +239,6 @@ type ExecutionTarget struct {
|
|||||||
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileInspection is the resolved result of exact profile inspection.
|
|
||||||
type ProfileInspection struct {
|
|
||||||
ProfileID string
|
|
||||||
EffectiveModelParams ExecutionTarget
|
|
||||||
APIKeyRequired bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// OutputContract defines the requirements for the output artifact.
|
// OutputContract defines the requirements for the output artifact.
|
||||||
type OutputContract struct {
|
type OutputContract struct {
|
||||||
Format OutputFormat `yaml:"format"`
|
Format OutputFormat `yaml:"format"`
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ type ExecutionEvent struct {
|
|||||||
RunID string
|
RunID string
|
||||||
Time time.Time
|
Time time.Time
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
|
||||||
ProfileID string
|
ProfileID string
|
||||||
BackendID string
|
BackendID string
|
||||||
Model string
|
Model string
|
||||||
|
|||||||
@@ -48,11 +48,10 @@ type RequirementIssue struct {
|
|||||||
|
|
||||||
// RequirementInspection contains the inventories discovered for one prompt.
|
// RequirementInspection contains the inventories discovered for one prompt.
|
||||||
type RequirementInspection struct {
|
type RequirementInspection struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion *string
|
ProfileID string
|
||||||
ProfileID string
|
Inputs []InputRequirement
|
||||||
Inputs []InputRequirement
|
Variables []VariableRequirement
|
||||||
Variables []VariableRequirement
|
Complete bool
|
||||||
Complete bool
|
Issues []RequirementIssue
|
||||||
Issues []RequirementIssue
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,6 @@ func qualifyResourceID(id, namespace string) (string, bool) {
|
|||||||
return namespace + "/" + id, true
|
return namespace + "/" + id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourceNamespaceFromID(id string) (string, bool) {
|
|
||||||
if !validResourceID(id) || !strings.Contains(id, "/") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
namespace, _, _ := strings.Cut(id, "/")
|
|
||||||
return namespace, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidResourceID reports whether id is an unqualified resource ID or one
|
// IsValidResourceID reports whether id is an unqualified resource ID or one
|
||||||
// valid namespace/local-ID pair.
|
// valid namespace/local-ID pair.
|
||||||
func IsValidResourceID(id string) bool { return validResourceID(id) }
|
func IsValidResourceID(id string) bool { return validResourceID(id) }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user