28 Commits

Author SHA1 Message Date
8ff9aa3efd Prepare v0.20.0 release documentation 2026-11-06 18:13:52 +00:00
78b78ea00b Avoid repeated prompt metadata lookups 2026-11-06 18:00:32 +00:00
63e9f0b731 Clarify resource inspection documentation 2026-11-06 17:59:40 +00:00
20b0f78af2 Record prompt inspection lookup optimization 2026-11-06 17:56:06 +00:00
101a2840d4 Record resource inspection documentation gaps 2026-11-06 16:40:33 +00:00
f083ad5309 Complete resource inspection validation and coverage 2026-11-06 15:34:21 +00:00
19a0604874 Document resource inspection and source migrations 2026-11-06 15:31:16 +00:00
ea31e71280 Test typed sources through configuration composition 2026-11-06 15:28:45 +00:00
27966c1686 Add typed prompt sources and backend inheritance 2026-11-06 15:27:08 +00:00
ea63a7b0ff Verify resource inspection isolation and selection 2026-11-06 15:26:16 +00:00
b5b9b1f209 Verify JSON and YAML resource inspection rendering 2026-11-06 15:24:55 +00:00
129959b24c Migrate inspection consumers to resource values 2026-11-06 15:23:59 +00:00
dfe9d6566f Expose captured resource inspection through the engine 2026-11-06 15:21:00 +00:00
bf3d2e19bd Capture complete prompt resources for inspection 2026-11-06 15:18:57 +00:00
4cbd4e2da0 Capture resolved backend and profile resources 2026-11-06 15:17:36 +00:00
b0a16ccce8 Simplify profile null fallback behavior 2026-11-06 15:16:35 +00:00
8add7c49e6 Define resource inspection result types 2026-11-06 15:14:59 +00:00
8563379216 Plan resource inspection and typed configuration sources 2026-11-06 15:03:30 +00:00
951739eda0 Require generically renderable inspection results 2026-11-06 13:58:28 +00:00
a0f696181f Plan null simplification after ConfigKit resolution 2026-11-06 12:56:26 +00:00
3951dcf1f3 Select typed inspection with meaningful null distinctions 2026-11-06 12:43:56 +00:00
363920deb2 Plan symmetric typed prompt source helpers 2026-11-06 12:15:00 +00:00
06315062b3 Define inspection around resolved resource configuration 2026-11-06 03:43:00 +00:00
c669f14610 Record backend inspection error decision 2026-11-06 03:29:41 +00:00
52287ab2d1 Keep inspection roadmap contracts proportionate 2026-11-06 03:04:00 +00:00
a43e0f17ee Align inspection roadmap with operator reporting needs 2026-11-06 02:49:13 +00:00
ffaaf552c8 Expand backend roadmap with configuration API recommendations 2026-11-06 02:40:56 +00:00
d7007eb072 Plan backend inspection API 2026-11-06 02:34:38 +00:00
48 changed files with 1315 additions and 642 deletions

View File

@@ -45,6 +45,14 @@ boundary and constraints that framework work must preserve.
## 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;

View File

@@ -19,10 +19,13 @@ const BackendLocal = "local"
// 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
// 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 {
// 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 *string
// APIKeyEnv optionally names an environment variable, not a credential.
@@ -44,13 +47,13 @@ type BackendSmokeTestConfig struct {
// DefaultProfile is the exact profile ID selected as the backend's
// diagnostic inference default. A supplied value must not be blank; the ID
// is resolved only when that default is selected.
DefaultProfile *string
DefaultProfile *string `json:"default_profile,omitempty" yaml:"default_profile,omitempty"`
// Reachability optionally selects a full HTTP destination for reachability
// 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.
// A supplied object requires Kind.
Authentication *AuthenticationProbeConfig
Authentication *AuthenticationProbeConfig `json:"authentication,omitempty" yaml:"authentication,omitempty"`
}
// ReachabilityProbeConfig configures an optional reachability destination.
@@ -58,7 +61,7 @@ type ReachabilityProbeConfig struct {
// Endpoint is an absolute HTTP(S) URL, including any desired path or query.
// Nil selects the effective backend endpoint; an explicit blank value is
// invalid.
Endpoint *string
Endpoint *string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
}
// AuthenticationProbeConfig configures an optional named authentication
@@ -66,11 +69,11 @@ type ReachabilityProbeConfig struct {
type AuthenticationProbeConfig struct {
// Kind selects the authentication adapter. Supported values are "none" and
// "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.
// Nil selects the adapter's backend-relative destination; an explicit blank
// value is invalid.
Endpoint *string
Endpoint *string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
}
// BackendSource creates a labeled ConfigKit mapping containing backend records
@@ -84,6 +87,9 @@ func BackendSource(label string, backends []BackendConfig) configkit.Source {
definitions := make([]any, 0, len(backends))
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 {
fields["endpoint"] = *config.Endpoint
}

View File

@@ -19,7 +19,7 @@ func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
t.Fatal(err)
}
underlayProfile, err := underlayEngine.InspectProfile(context.Background(), "gpt-sol-latest")
if err != nil || underlayProfile.EffectiveModelParams.Model != "underlay-model" || underlayProfile.EffectiveModelParams.BackendID != "" {
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{
@@ -38,29 +38,29 @@ func TestBundledCompositionReplacementAndInheritance(t *testing.T) {
if err != nil {
t.Fatal(err)
}
inspect := func(id string) promptkit.ExecutionTarget {
inspect := func(id string) *promptkit.ProfileInspection {
t.Helper()
profile, err := engine.InspectProfile(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return profile.EffectiveModelParams
return profile
}
if target := inspect("gpt-sol-latest"); target.Model != "main-model" || target.BackendID != "" {
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 != "custom-model" || target.BackendID != "openrouter" || target.Endpoint != "https://local.invalid/v1" {
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 target := inspect("gpt-luna-latest"); target.Endpoint != "https://local.invalid/v1" || target.APIKeyEnv != "" {
t.Fatalf("backend replacement = %+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.BackendID != "openrouter/local" || target.Model != "collection-model" {
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.BackendID != "other" || target.Model != "other-model" || !reflect.DeepEqual(target.ExtraParams, wantPlugins) {
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)
}
}

View File

@@ -59,7 +59,7 @@ func TestBundledOpenRouterOptionsAndInventory(t *testing.T) {
t.Fatalf("bundle inventory: %v %v", profiles, backends)
}
profile, err := engine.InspectProfile(context.Background(), "auto")
if err != nil || profile.EffectiveModelParams.BackendID != "openrouter" {
if err != nil || profile.Backend == nil || *profile.Backend != "openrouter" {
t.Fatalf("bundle profile = %+v, %v", profile, err)
}
view, err := engine.InspectConfiguration()

View File

@@ -58,7 +58,7 @@ func TestCollectionInputLoadsNativeImportsAndLaterOverride(t *testing.T) {
t.Fatalf("construct engine with native collection import: %v", err)
}
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
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)
}
}
@@ -85,7 +85,7 @@ func TestCollectionInputLoadsEmbeddedImportsAndLaterOverride(t *testing.T) {
t.Fatalf("construct engine with embedded collection import: %v", err)
}
inspection, err := engine.InspectPrompt(context.Background(), "docs/greeting")
if err != nil || inspection.PromptID != "docs/greeting" || len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "overridden" {
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)
}
}

View File

@@ -134,7 +134,7 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) {
"rakestrawhome-profile": rakestrawhomeID,
} {
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)
}
}
@@ -242,8 +242,9 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T
if err != nil {
t.Fatalf("inspect configured profile: %v", err)
}
if inspection.EffectiveModelParams.Endpoint != endpoint {
t.Fatalf("effective endpoint = %q, want %q", inspection.EffectiveModelParams.Endpoint, endpoint)
backendInspection, err := engine.InspectBackend(context.Background(), backendID)
if err != nil || inspection.Endpoint != nil || backendInspection.Endpoint != endpoint {
t.Fatalf("profile/backend = %+v / %+v, %v", inspection, backendInspection, err)
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"unicode/utf8"
"go.maximumdirect.net/promptkit/internal/configuration"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/jsonvalue"
)
@@ -228,37 +229,58 @@ func fromDomainExecutionTargetWithOwnedParams(target domain.ExecutionTarget, ext
}
}
func fromDomainProfileInspection(inspection *domain.ProfileInspection) *ProfileInspection {
if inspection == nil {
return nil
}
return &ProfileInspection{
ProfileID: inspection.ProfileID,
EffectiveModelParams: fromDomainExecutionTarget(inspection.EffectiveModelParams),
APIKeyRequired: inspection.APIKeyRequired,
func fromProfileResource(v *configuration.ProfileResource) *ProfileInspection {
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)}
if v.ExtraParams != nil {
data := copyAnyMap(v.ExtraParams)
out.ExtraParams = &data
}
return out
}
func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInspection {
if inspection == nil {
return nil
func fromBackendResource(v *configuration.BackendResource) *BackendInspection {
out := &BackendInspection{BackendID: v.ID, Endpoint: v.Endpoint, APIKeyEnv: copyStringPtr(v.APIKeyEnv), ConcurrencyLimit: copyIntPtr(v.ConcurrencyLimit), QueueCapacity: copyIntPtr(v.QueueCapacity)}
if v.ExtraParams != nil {
data := copyAnyMap(v.ExtraParams)
out.ExtraParams = &data
}
inputs := make([]PromptInputDefinition, len(inspection.Inputs))
for i, input := range inspection.Inputs {
inputs[i] = PromptInputDefinition{
Name: input.Name,
Required: input.Required,
ContentType: input.ContentType,
Description: input.Description,
if v.SmokeTests != nil {
out.SmokeTests = &BackendSmokeTestConfig{DefaultProfile: copyStringPtr(v.SmokeTests.DefaultProfile)}
if v.SmokeTests.Reachability != nil {
out.SmokeTests.Reachability = &ReachabilityProbeConfig{Endpoint: copyStringPtr(v.SmokeTests.Reachability.Endpoint)}
}
if v.SmokeTests.Authentication != nil {
out.SmokeTests.Authentication = &AuthenticationProbeConfig{Kind: copyStringPtr(v.SmokeTests.Authentication.Kind), Endpoint: copyStringPtr(v.SmokeTests.Authentication.Endpoint)}
}
}
return &PromptInspection{
PromptID: inspection.PromptID,
PromptHash: inspection.PromptHash,
DefaultProfileID: inspection.DefaultProfileID,
Inputs: inputs,
OutputContract: fromDomainOutputContract(inspection.OutputContract),
return out
}
func fromPromptResource(v *configuration.PromptResource) *PromptInspection {
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)}}
if v.Inputs != nil {
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 {

26
doc.go
View File

@@ -4,17 +4,17 @@
// Applications construct an [Engine] with [NewEngine], select ConfigKit
// sources for prompts, profiles, and backends, and
// call [Engine.InspectConfiguration], [Engine.InspectPrompt],
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.ListProfiles],
// [Engine.ListPrompts], [Engine.Prepare], [Engine.PrepareExecution],
// [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.InspectBackend],
// [Engine.ListProfiles], [Engine.ListPrompts], [Engine.Prepare],
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
// validators, and the built-in OpenAI-compatible client remain internal
// implementation details.
//
// # Concurrency and ownership
//
// An Engine supports concurrent InspectConfiguration, InspectPrompt,
// InspectRequirements, InspectProfile, ListProfiles, ListPrompts, Prepare,
// PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
// InspectRequirements, InspectProfile, InspectBackend, ListProfiles, ListPrompts,
// Prepare, PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
// bound admitted Run and RunPrepared calls and model generations where
// configured, while different backend pools and unlimited backends continue
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
@@ -23,7 +23,8 @@
// ConfigKit captures the selected configuration when NewEngine constructs an
// Engine. Prepare, PrepareExecution, and Run copy request maps, slices, pointer
// values, and JSON-compatible extra parameters before using them. InspectPrompt,
// InspectRequirements, and InspectProfile return copied inspection values.
// InspectRequirements, InspectProfile, and InspectBackend return copied
// inspection values.
// ListProfiles and ListPrompts return caller-owned identity slices.
// Returned values and values passed to extension interfaces are likewise
// isolated from engine state.
@@ -56,17 +57,22 @@
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
// 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],
// [ProfileConfig], [RunRequest],
// [ArtifactRef], [ExecutionTargetOverride], [ProfileInspection],
// [PromptIdentity], [PromptInputDefinition], [PromptInspection],
// [ArtifactRef], [ExecutionTargetOverride],
// [PromptIdentity], [PromptInputDefinition],
// [RequirementInspectionRequest], [RequirementInspection], [PreparedExecution],
// [CapacityError], [GenerationError], [RetryExhaustedError], and
// [ExecutionEvent], do not have stable JSON
// representations. Direct API keys are nevertheless excluded from JSON for
// every public value.
//
// Provider-derived [GenerationError] accessor values are untrusted and can
// contain sensitive request or schema fragments. Applications must apply their
// own disclosure policy before logging, displaying, or returning them.

View File

@@ -135,8 +135,10 @@ Replace former maps passed to `BackendSource` in the same way, setting each
inspection paths now address list indexes, for example
`configkit.Keys("profiles").Index(0).Key("model")`. Configuration inspection
and diffs expose resolved values, recorded normalization and provenance.
`InspectProfile` additionally applies backend completion; request preparation
can apply further overrides, so the views are not promised to be identical.
`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

View File

@@ -94,7 +94,7 @@ engine, err := promptkit.NewEngine(promptkit.Config{
For a single highest-priority document, set `Sources.Main` to a
`*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
collaborator options remain available. Construction validates the final
effective configuration and captures referenced prompt content; construct a
@@ -177,8 +177,12 @@ input := configkit.Input{Source: document}
// Add input to a later underlay, or set Sources.Main = &input.
```
`ProfileSource` and `BackendSource` are smaller typed conveniences when null
is not needed. Their provider maps use ConfigKit Mapping input forms: built-in
`PromptSource`, `ProfileSource`, and `BackendSource` are typed conveniences when
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;
@@ -264,9 +268,10 @@ is not necessarily safe to share. Inspection uses the engine's captured result
and does not reopen sources or resolve credentials. It retains effective values,
including recorded field normalization and queue defaulting, alongside source
provenance.
`InspectProfile` instead shows backend-completed execution settings for one profile, and `Prepare` or
`PreparedExecution.Details` shows request-specific rendered messages, inputs,
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
their inspection views:
@@ -380,8 +385,10 @@ if err != nil {
return err
}
for _, input := range inspection.Inputs {
// Compare the declared input with application configuration.
if inspection.Inputs != nil {
for _, input := range *inspection.Inputs {
// Compare the declared input with application configuration.
}
}
```
@@ -707,10 +714,9 @@ composition rules.
### Inspect A Profile Before Prompt Work
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
profile without constructing a synthetic prompt or placeholder inputs. It
resolves the profile's effective target but does not prepare or execute a
prompt:
Use [`Engine.InspectProfile`](../../engine.go) to inspect a resolved profile
without constructing a synthetic prompt or placeholder inputs. It reports the
profile's own fields, not a backend-completed target:
```go
inspection, err := engine.InspectProfile(ctx, profileID)
@@ -718,24 +724,48 @@ if err != nil {
return err
}
target := inspection.EffectiveModelParams
if inspection.APIKeyRequired {
if inspection.APIKeyRequired != nil && *inspection.APIKeyRequired {
// Arrange a usable direct or configured environment credential.
}
if target.APIKeyEnv != "" {
if inspection.APIKeyEnv != nil {
// This is the configured environment lookup source, not its value.
}
```
Use this configuration-time boundary when only the profile and its target need
checking. Use `Prepare` when the application also needs prompt, input, schema,
or rendering work; use prepared execution when that work must remain tied to a
later execution. A reported `APIKeyEnv` identifies a configured source, while
`APIKeyRequired` is the explicit requirement; both may be present. The
Use [`InspectBackend`](../../engine.go) to inspect the referenced backend
separately. Its diagnostics are configured metadata, not live checks or derived
destinations; `ErrBackendNotFound` identifies an absent backend. References in
all three resource reports remain as authored. Use `Prepare` for completed
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
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
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
Supply a direct session ID when one prompt should be correlated with a

View File

@@ -110,8 +110,8 @@ and static template syntax at construction. It does not reread configuration or
message files on ordinary runs; construct a new engine to observe edits.
Schema and request artifact reads still happen during preparation. Use
`InspectConfiguration` for an explicit effective-configuration or provenance
report, `InspectProfile` for backend-completed profile settings, and preparation
for request-specific rendered details. For identity discovery before inspection,
report, resource inspection for resolved prompt, profile, and backend fields,
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).
## Prompt Definitions
@@ -315,17 +315,33 @@ only a model or backend does not clear unrelated explicitly inherited fields.
| Profile field | Absent | Explicit value | Explicit null |
| --- | --- | --- | --- |
| `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 |
| `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 |
| `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 |
| `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.
Request overrides otherwise follow the [public value GoDoc](../types.go).
For example, a runtime `extra_params["extends"]` key is ordinary provider

View File

@@ -11,15 +11,15 @@ contributor workflow and validation.
| 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/run` | Demonstrates an offline downstream consumer using a prompt file, programmatic profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Holds a defensive, immutable lookup 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/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/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) |

View File

@@ -45,12 +45,10 @@ 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 directly from the repository. It
returns owned metadata without
rendering templates or touching profile, artifact, schema, renderer, validator,
admission, or model collaborators. The root
[`Engine.InspectPrompt`](../../engine.go) GoDoc owns the public operation's
exact contract.
Resource inspection bypasses the runner: the internal engine selects a captured
configuration resource, and the facade detaches its values for the caller.
Prompt resource inspection includes captured message text without rendering or
touching profile, artifact, schema, admission, or model collaborators.
`InspectRequirements` uses the same metadata selection to resolve the
request-profile/default identity, then obtains the captured requirement
@@ -64,17 +62,12 @@ operation's exact contract.
## Shared Profile Selection
The runner uses one profile-selection and target-resolution boundary for
ordinary preparation and exact profile inspection. Preparation first selects a
request profile or a prompt default; inspection begins with its required
explicit profile ID. Both use the captured, presence-aware target constructed
from the final effective profile and backend configuration. Request overrides
apply afterward; preparation checks endpoint and model completeness only after
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.
The runner selects a request profile or a prompt default and uses its captured,
presence-aware target constructed from final profile and backend configuration.
Request overrides apply afterward; preparation checks endpoint and model
completeness only after those overrides. Resource profile inspection bypasses
this path and reports the profile before backend completion. Credential
availability is checked when execution preparation requires it.
## Smoke-Test Diagnostics

View File

@@ -11,8 +11,8 @@ source semantics. Internal packages are not consumer APIs.
## Typed Configuration Loading
`internal/configuration` is the single adapter from ConfigKit's final
effective configuration to execution-owned definitions. It performs one
layered load, captures effective presence (including explicit null), records
effective configuration to execution-owned definitions and resource views. It performs one
layered load, captures the needed effective presence, records
field-level normalization and derived defaults during resolution, and validates all effective
root and collection backend, profile, and prompt values. Replaced lower-layer
domain values are not separately validated. It indexes root resources under
@@ -94,13 +94,13 @@ presence, validation, sensitivity, and resource context; full IDs select
captured runtime indexes. Unchanged content-file fields retain their origins.
Backend-to-profile and request-time completion remain separate runtime work.
The runner selects full definitions from this index for preparation and uses
captured prompt metadata for `InspectPrompt` and `InspectRequirements`; the
internal requirement snapshot lookup uses the selected prompt ID alone.
These paths do not rescan configuration or reopen message files. `InspectPrompt`
returns declared metadata without rendering or resolving profile, input, or
schema resources, and its public shape and prompt hashes do not include the
captured presence state. Runtime rendering still checks request variables and
The runner selects full definitions from this index for preparation and retains
lightweight prompt metadata for `InspectRequirements`; the requirement snapshot
lookup uses the selected prompt ID alone. Separate configuration-owned resource
inspection projects the resolved record and captured messages without rendering
or resolving profile, input, or schema resources. These paths do not rescan
configuration or reopen message files. Presence metadata does not change the
opaque definition hash. Runtime rendering still checks request variables and
input helpers. The [prompt adapter tests](../../internal/configuration/prompts_test.go)
cover resource origins, context containment, capture, and selection.
@@ -149,10 +149,12 @@ ConfigKit resolves profile inheritance before the handoff. Within a collection,
`extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference.
The configuration adapter validates final effective resource references and
field values, then captures backend definitions and profile target snapshots
with backend defaults and presence/suppression state. A profile can
remain partial until runtime overrides complete it. `InspectProfile` selects
that captured target without prompt, artifact, schema, credential lookup, or
provider work. Preparation applies runtime overrides and then checks target
with backend defaults and outbound-control presence. Null profile endpoint,
credential source, and whole provider map permit backend fallback after
composition, subject to the required-credential rule. A profile can remain
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
profile without copying its target or requiring readiness. The
[target tests](../../internal/configuration/targets_test.go) cover effective

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

View File

@@ -9,6 +9,7 @@ import (
"time"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit/internal/backend"
"go.maximumdirect.net/promptkit/internal/domain"
internalengine "go.maximumdirect.net/promptkit/internal/engine"
"go.maximumdirect.net/promptkit/internal/observation"
@@ -30,6 +31,8 @@ var (
// ErrProfileNotFound identifies a selected profile ID that is absent from
// every configured profile source. It does not also match ErrProfileLoad.
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
// nor the selected prompt's default profile is present. Such an error also
// matches ErrInvalidRequest.
@@ -81,12 +84,12 @@ var (
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.
//
// An Engine is safe for concurrent calls to [Engine.WithNamespaces],
// [Engine.InspectConfiguration],
// [Engine.InspectPrompt],
// [Engine.InspectPrompt], [Engine.InspectBackend],
// [Engine.InspectRequirements], [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
// [Engine.Run], [Engine.RunPrepared], [Engine.SmokeTest], [Engine.ListProfiles],
// [Engine.ListPrompts], [Engine.ListBackends], [Engine.ListNamespaces], and
@@ -267,15 +270,16 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
// qualified IDs pass through.
//
// A successful result proves that the selected definition and any referenced
// message content files were structurally loaded. Inputs are returned in
// definition order. DefaultProfileID is declared metadata only and is not
// message content files were structurally loaded. Inputs and captured,
// 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
// Schema path when declared but without loading or compiling that schema.
// PromptHash is the same opaque equality value as PreparedRun.PromptHash for
// the selected captured definition; its spelling, length,
// 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
// credential, read artifacts or schemas, render, validate, admit capacity,
// contact a provider, or generate model output. The returned PromptInspection
@@ -299,21 +303,19 @@ func (e *Engine) InspectPrompt(
if err != nil {
return nil, mapPublicError(err)
}
return fromDomainPromptInspection(inspection), nil
return fromPromptResource(inspection), nil
}
// InspectProfile resolves one explicit profile without selecting a prompt or
// starting execution work.
//
// InspectProfile looks up a nonblank profileID exactly and case-sensitively
// in the captured effective configuration. It applies the framework timeout
// baseline, selected
// backend, and then selected profile to EffectiveModelParams without a request
// override. BackendID is empty for an endpoint-only profile.
// in the captured effective configuration. It reports the resolved profile
// resource without backend completion, execution defaults, or request overrides.
//
// APIKeyEnv in the returned target is an environment-variable name, never its
// value. APIKeyRequired reports that a credential is required; an explicit
// profile environment source can also be present. InspectProfile neither derives
// APIKeyEnv in the returned resource is an environment-variable name, never its
// value. APIKeyRequired, when present and true, reports that a credential is
// 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
// absent or blank named environment variable is not an error.
//
@@ -337,7 +339,32 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
if err != nil {
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.

View File

@@ -110,10 +110,10 @@ func run(ctx context.Context, exampleDir string, out io.Writer) error {
if err != nil {
return err
}
if profile.EffectiveModelParams.TimeoutSeconds != 30 {
if profile.TimeoutSeconds == nil || *profile.TimeoutSeconds != 30 {
return fmt.Errorf(
"later profile override was not applied: timeout is %d seconds",
profile.EffectiveModelParams.TimeoutSeconds,
"later profile override was not applied: timeout is %v",
profile.TimeoutSeconds,
)
}
namespaces, err := scoped.ListNamespaces(ctx)
@@ -156,7 +156,7 @@ func run(ctx context.Context, exampleDir string, out io.Writer) error {
SelectedProfile: prepared.SelectedProfileID,
Model: prepared.EffectiveModelParams.Model,
MessageCount: len(prepared.Messages),
ProfileTimeout: profile.EffectiveModelParams.TimeoutSeconds,
ProfileTimeout: *profile.TimeoutSeconds,
Namespaces: namespaces,
Collections: collectionSummaries,
ScopedPrompts: scopedPromptIDs,

2
go.mod
View File

@@ -5,12 +5,12 @@ go 1.27.0
require (
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 (
github.com/yuin/goldmark/v2 v2.1.6 // indirect
go.maximumdirect.net/releasekit v0.9.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

View File

@@ -119,10 +119,12 @@ const (
type loaded struct {
result *configkit.Result[document]
profiles map[string]map[string]fieldState
promptMetadata map[string][]promptInputPresence
promptRepair map[string]bool
prompts map[string]*promptEntry
registry *backend.Registry
backendRecords map[string]backendConfig
profileRecords map[string]profileConfig
indexedProfiles map[string]domain.ExecutionProfile
backendIDs []string
profileIDs []string
@@ -211,8 +213,6 @@ func indexedProfiles(config *document) map[string]profileConfig {
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
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.
func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
return load(main, underlays)
@@ -235,8 +235,7 @@ func configurationDefinition(state *loaded) configkit.Definition[document] {
Sensitive: secretProviderPath,
Resolve: func(config *document, resolution *configkit.Resolution) error {
for _, resources := range resourceSets(config) {
captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles)
capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata)
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)
@@ -254,8 +253,8 @@ func configurationDefinition(state *loaded) configkit.Definition[document] {
// load performs one ConfigKit lifecycle over the selected source tiers.
func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) {
state := &loaded{
profiles: make(map[string]map[string]fieldState),
promptMetadata: make(map[string][]promptInputPresence),
promptRepair: make(map[string]bool),
}
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{
Underlays: underlays,
@@ -320,11 +319,12 @@ func checkConfigurationVersion(version configkit.Version, validation *configkit.
}
type promptInputPresence struct {
required fieldState
contentType fieldState
description fieldState
}
func capturePromptMetadata(prompts []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 promptIndex, prompt := range prompts {
inputs := prompt.Inputs
states := make([]promptInputPresence, len(inputs))
@@ -332,11 +332,13 @@ func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, name
for index := range inputs {
path := base.Index(index)
states[index] = promptInputPresence{
required: captureFieldState(path.Key("required"), resolution),
contentType: captureFieldState(path.Key("content_type"), resolution),
description: captureFieldState(path.Key("description"), resolution),
}
}
destination[indexedResourceID(namespace, prompt.ID)] = states
repair[indexedResourceID(namespace, prompt.ID)] = captureFieldState(basePath.Index(promptIndex).Key("output").Key("repair_attempts"), resolution) == fieldValue
}
}
@@ -352,24 +354,6 @@ func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fi
}
}
func captureFields[T any](entries []T, identity func(T) string, basePath configkit.Path, namespace string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) {
for index, entry := range entries {
states := make(map[string]fieldState, len(fields))
for _, field := range fields {
info, exists := resolution.EntryInfo(basePath.Index(index).Key(field))
switch {
case !exists || !info.Explicit:
states[field] = fieldAbsent
case info.Null:
states[field] = fieldNull
default:
states[field] = fieldValue
}
}
destination[indexedResourceID(namespace, identity(entry))] = states
}
}
// eligibleResourceList leaves invalid identities to the existing validation
// boundaries, without mutating a list that cannot be recorded by identity.
func eligibleResourceList[T any](entries []T, identity func(T) string) bool {

View File

@@ -488,9 +488,6 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) {
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
t.Fatalf("effective values were not retained: %+v", profile)
}
if loaded.profiles["base/child.v1"]["temperature"] != fieldValue || loaded.profiles["base/child.v1"]["top_p"] != fieldNull || loaded.profiles["base/child.v1"]["api_key_required"] != fieldValue {
t.Fatalf("effective presence was lost: %+v", loaded.profiles["base/child.v1"])
}
provider := profile.ExtraParams["provider"].(map[string]any)
if provider["first"] != int64(1) || provider["second"] != int64(3) {
t.Fatalf("recursive overlay failed: %+v", provider)
@@ -776,9 +773,8 @@ func TestLoadCapturesProfilePresence(t *testing.T) {
if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired {
t.Fatalf("profile values were not preserved: %+v", profile)
}
fields := loaded.profiles["local"]
if fields["temperature"] != fieldValue || fields["top_p"] != fieldNull || fields["api_key_required"] != fieldValue || fields["model"] != fieldAbsent {
t.Fatalf("profile presence was not preserved: %+v", fields)
if profile.Model != nil {
t.Fatalf("omitted model was not preserved: %+v", profile)
}
}

View File

@@ -20,6 +20,7 @@ var (
)
type promptEntry struct {
resource promptConfig
definition domain.PromptDefinition
inspection domain.PromptInspection
requirementInputs []prompt.InputMetadata
@@ -86,7 +87,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk
}
definition.Inputs = inputs
definition.Templates = messages
entry := &promptEntry{requirementInputs: inputMetadata, messages: compiledMessages}
entry := &promptEntry{resource: raw, requirementInputs: inputMetadata, messages: compiledMessages}
definition.Validation = contract
if raw.SessionID != nil {
var err error
@@ -316,8 +317,8 @@ func (state *loaded) GetPromptSnapshot(ctx context.Context, id string) (*domain.
return ownPromptDefinition(entry.definition), entry.hash, nil
}
// GetPromptInspection returns an independently owned inspection of one
// captured prompt without copying its execution templates.
// GetPromptInspection returns lightweight metadata used for requirement
// selection, independently of the full resource inspection view.
func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) {
if err := ctx.Err(); err != nil {
return nil, err

View File

@@ -84,6 +84,10 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
files["catalog/body.md"].Data = []byte("Changed")
files["app/body.md"].Data = []byte("Changed replacement")
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")
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)
@@ -99,6 +103,25 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
}
}
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")},

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

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

View File

@@ -14,6 +14,8 @@ import (
func (state *loaded) buildExecutionProfiles() error {
backendConfigs := indexedBackends(&state.result.Config)
profileConfigs := indexedProfiles(&state.result.Config)
state.backendRecords = backendConfigs
state.profileRecords = profileConfigs
definitions := make(map[string]domain.Backend, len(backendConfigs))
for _, id := range sortedKeys(backendConfigs) {
config := backendConfigs[id]
@@ -23,7 +25,7 @@ func (state *loaded) buildExecutionProfiles() error {
state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs))
for _, id := range sortedKeys(profileConfigs) {
config := profileConfigs[id]
profile, err := captureExecutionProfile(id, config, state.profiles[id], state.registry)
profile, err := captureExecutionProfile(id, config, state.registry)
if err != nil {
return err
}
@@ -62,7 +64,7 @@ func captureBackend(id string, config backendConfig) domain.Backend {
return definition
}
func captureExecutionProfile(id string, config profileConfig, fields map[string]fieldState, registry *backend.Registry) (domain.ExecutionProfile, error) {
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)
@@ -75,47 +77,44 @@ func captureExecutionProfile(id string, config profileConfig, fields map[string]
target.ExtraParams = selected.ExtraParams
}
presence := domain.ExecutionTargetPresence{}
if fields["endpoint"] != fieldAbsent {
if config.Endpoint != nil {
target.Endpoint = valueOrZero(config.Endpoint)
}
if fields["model"] != fieldAbsent {
if config.Model != nil {
target.Model = valueOrZero(config.Model)
}
if fields["temperature"] != fieldAbsent {
if config.Temperature != nil {
target.Temperature = valueOrZero(config.Temperature)
presence.Temperature = fields["temperature"] == fieldValue
presence.Temperature = true
}
if fields["top_p"] != fieldAbsent {
if config.TopP != nil {
target.TopP = valueOrZero(config.TopP)
presence.TopP = fields["top_p"] == fieldValue
presence.TopP = true
}
if fields["max_tokens"] != fieldAbsent {
if config.MaxTokens != nil {
target.MaxTokens = valueOrZero(config.MaxTokens)
presence.MaxTokens = fields["max_tokens"] == fieldValue
presence.MaxTokens = true
}
if fields["timeout_seconds"] == fieldValue {
if config.TimeoutSeconds != nil {
target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds)
presence.TimeoutSeconds = true
}
if fields["service_tier"] != fieldAbsent {
if config.ServiceTier != nil {
target.ServiceTier = valueOrZero(config.ServiceTier)
}
if fields["reasoning_effort"] != fieldAbsent {
if config.ReasoningEffort != nil {
target.ReasoningEffort = valueOrZero(config.ReasoningEffort)
}
if fields["api_key_env"] != fieldAbsent {
if config.APIKeyEnv != nil {
target.APIKeyEnv = valueOrZero(config.APIKeyEnv)
}
if fields["api_key_required"] != fieldAbsent {
if config.APIKeyRequired != nil {
target.APIKeyRequired = valueOrZero(config.APIKeyRequired)
}
if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent {
if target.APIKeyRequired && config.APIKeyEnv == nil {
target.APIKeyEnv = ""
}
switch fields["extra_params"] {
case fieldNull:
target.ExtraParams = nil
case fieldValue:
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)

View File

@@ -132,7 +132,7 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
target := child.ResolvedTarget
if target.BackendID != "second" || target.Endpoint != "https://second.example/v1" || target.Model != "model" ||
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)
}
if !child.TargetPresence.Temperature || !child.TargetPresence.MaxTokens || child.TargetPresence.TopP || child.TargetPresence.TimeoutSeconds {
@@ -154,8 +154,8 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
}
}
noMap, _ := state.GetProfile(context.Background(), "no-provider-map")
if noMap.ResolvedTarget.ExtraParams != nil {
t.Fatalf("explicit null provider map did not suppress backend defaults: %+v", noMap.ResolvedTarget.ExtraParams)
if !reflect.DeepEqual(noMap.ResolvedTarget.ExtraParams, selected.ExtraParams) {
t.Fatalf("explicit null provider map did not inherit backend defaults: %+v", noMap.ResolvedTarget.ExtraParams)
}
noTimeout, _ := state.GetProfile(context.Background(), "no-timeout")
if noTimeout.ResolvedTarget.TimeoutSeconds != 0 || !noTimeout.TargetPresence.TimeoutSeconds {
@@ -183,8 +183,8 @@ func TestLoadBuildsEffectiveTargetsAfterInheritance(t *testing.T) {
t.Fatalf("endpoint-only profile was not retained: %+v", endpointOnly.ResolvedTarget)
}
clearedEndpoint, _ := state.GetProfile(context.Background(), "cleared-endpoint")
if clearedEndpoint.ResolvedTarget.Endpoint != "" || clearedEndpoint.ResolvedTarget.BackendID != "first" {
t.Fatalf("explicit null recovered backend endpoint: %+v", clearedEndpoint.ResolvedTarget)
if clearedEndpoint.ResolvedTarget.Endpoint != "https://first.example/v1" || clearedEndpoint.ResolvedTarget.BackendID != "first" {
t.Fatalf("explicit null did not recover backend endpoint: %+v", clearedEndpoint.ResolvedTarget)
}
child.ResolvedTarget.ExtraParams["source"] = "mutated"
again, _ := state.GetProfile(context.Background(), "child")
@@ -193,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) {
payloads := make(chan map[string]any, 1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {

View File

@@ -148,7 +148,7 @@ type PromptDefinition struct {
Validation OutputContract `yaml:"validation"`
}
// PromptInspection is the resolved result of exact prompt inspection.
// PromptInspection is lightweight metadata for prompt requirement selection.
type PromptInspection struct {
PromptID string
PromptHash string
@@ -239,13 +239,6 @@ type ExecutionTarget struct {
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.
type OutputContract struct {
Format OutputFormat `yaml:"format"`

View File

@@ -87,6 +87,7 @@ type Engine struct {
runner *usecase.Runner
smokeTester *usecase.SmokeTester
inspection configuration.Inspection
resources resourceInspection
listing identityInventory
namespaces resourceNamespaces
}
@@ -105,6 +106,12 @@ type identityInventory interface {
ListCollections(context.Context) ([]configuration.CollectionIdentity, error)
}
type resourceInspection interface {
InspectPrompt(context.Context, string) (*configuration.PromptResource, error)
InspectProfile(context.Context, string) (*configuration.ProfileResource, error)
InspectBackend(context.Context, string) (*configuration.BackendResource, error)
}
// ListingFilterMode selects namespace membership for one identity listing.
type ListingFilterMode uint8
@@ -173,6 +180,7 @@ func New(cfg Config, options Options) (*Engine, error) {
runner: runner,
smokeTester: usecase.NewSmokeTester(runner, diagnosticHTTPClient),
inspection: snapshot.Inspection(),
resources: snapshot,
listing: snapshot,
}, nil
}
@@ -224,13 +232,16 @@ func fileSource(name string) (fs.FS, string, error) {
return os.DirFS(directory), filepath.ToSlash(base), nil
}
func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) {
func (e *Engine) InspectPrompt(ctx context.Context, id string) (*configuration.PromptResource, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: prompt id is required", usecase.ErrInvalidRequest)
}
var err error
id, err = e.qualifyResourceID(id, e.namespaces.prompt, "prompt")
if err != nil {
return nil, err
}
return e.runner.InspectPrompt(ctx, id)
return e.resources.InspectPrompt(ctx, id)
}
func (e *Engine) InspectRequirements(
ctx context.Context,
@@ -247,13 +258,27 @@ func (e *Engine) InspectRequirements(
}
return e.runner.InspectRequirements(ctx, request)
}
func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) {
func (e *Engine) InspectProfile(ctx context.Context, id string) (*configuration.ProfileResource, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: profile id is required", usecase.ErrInvalidRequest)
}
var err error
id, err = e.qualifyResourceID(id, e.namespaces.profile, "profile")
if err != nil {
return nil, err
}
return e.runner.InspectProfile(ctx, id)
return e.resources.InspectProfile(ctx, id)
}
func (e *Engine) InspectBackend(ctx context.Context, id string) (*configuration.BackendResource, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: backend id is required", usecase.ErrInvalidRequest)
}
id, err := e.qualifyResourceID(id, e.namespaces.backend, "backend")
if err != nil {
return nil, err
}
return e.resources.InspectBackend(ctx, id)
}
func (e *Engine) ListBackends(ctx context.Context, filters ...ListingFilter) ([]string, error) {

View File

@@ -14,6 +14,7 @@ var ErrPromptDefinitionNotFound = errors.New("prompt definition not found")
// Repository selects prompt definitions from a captured index.
type Repository interface {
GetPromptSnapshot(ctx context.Context, id string) (*domain.PromptDefinition, string, error)
// GetPromptInspection supplies lightweight requirement-selection metadata.
GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error)
GetRequirementSnapshot(id string) (prompt.RequirementSnapshot, bool)
}

View File

@@ -0,0 +1,38 @@
package usecase
import (
"context"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/jsonvalue"
)
type inspectionProfileRepository struct {
profile *domain.ExecutionProfile
err error
calls int
id string
}
func (r *inspectionProfileRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
r.calls++
r.id = id
if r.err != nil {
return nil, r.err
}
if r.profile == nil {
return nil, nil
}
profile := *r.profile
if profile.ResolvedTarget != nil {
target := *profile.ResolvedTarget
target.ExtraParams = jsonvalue.CloneTrusted(target.ExtraParams).(map[string]any)
profile.ResolvedTarget = &target
}
return &profile, nil
}
func (r *inspectionProfileRepository) GetProfileIdentity(ctx context.Context, id string) (string, error) {
_, err := r.GetProfile(ctx, id)
return id, err
}

View File

@@ -85,30 +85,3 @@ func normalizeResolvedExecutionSettings(target domain.ExecutionTarget) (domain.E
}
return target, nil
}
// InspectProfile resolves one explicit profile without prompt or execution work.
func (r *Runner) InspectProfile(
ctx context.Context,
profileID string,
) (*domain.ProfileInspection, error) {
if strings.TrimSpace(profileID) == "" {
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidRequest)
}
select {
case <-ctx.Done():
return nil, &OperationError{Category: ErrProfileLoad, Cause: ctx.Err()}
default:
}
selection, err := r.resolveProfileSelection(ctx, profileID)
if err != nil {
return nil, err
}
target, _ := resolveExecutionTarget(selection.profile, nil)
target.APIKey = ""
return &domain.ProfileInspection{
ProfileID: selection.id,
EffectiveModelParams: target,
APIKeyRequired: target.APIKeyRequired,
}, nil
}

View File

@@ -1,105 +0,0 @@
package usecase
import (
"context"
"errors"
"reflect"
"testing"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/jsonvalue"
"go.maximumdirect.net/promptkit/internal/profile"
)
type inspectionProfileRepository struct {
profile *domain.ExecutionProfile
err error
calls int
id string
}
func (r *inspectionProfileRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
r.calls++
r.id = id
if r.err != nil {
return nil, r.err
}
if r.profile == nil {
return nil, nil
}
profile := *r.profile
if profile.ResolvedTarget != nil {
target := *profile.ResolvedTarget
target.ExtraParams = jsonvalue.CloneTrusted(target.ExtraParams).(map[string]any)
profile.ResolvedTarget = &target
}
return &profile, nil
}
func (r *inspectionProfileRepository) GetProfileIdentity(ctx context.Context, id string) (string, error) {
_, err := r.GetProfile(ctx, id)
return id, err
}
func TestRunnerInspectProfileUsesExactSnapshotAndCopiesTarget(t *testing.T) {
configured := domain.ExecutionTarget{
BackendID: "backend", Endpoint: "https://backend.example/v1", Model: "model",
Temperature: 0, APIKeyEnv: "OPTIONAL_KEY", ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}},
}
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
ID: " literal ", ResolvedTarget: &configured,
TargetPresence: domain.ExecutionTargetPresence{Temperature: true},
}}
runner := &Runner{profiles: profiles}
first, err := runner.InspectProfile(context.Background(), " literal ")
if err != nil {
t.Fatalf("inspect exact ID: %v", err)
}
if profiles.calls != 1 || profiles.id != " literal " || first.ProfileID != profiles.id || !reflect.DeepEqual(first.EffectiveModelParams, configured) {
t.Fatalf("inspection=%+v lookup=(%d,%q), want exact snapshot", first, profiles.calls, profiles.id)
}
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "changed"
second, err := runner.InspectProfile(context.Background(), " literal ")
if err != nil || second.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" {
t.Fatalf("inspection mutated snapshot: (%+v, %v)", second, err)
}
}
func TestRunnerInspectProfileDoesNotNeedExecutionCollaboratorsOrCredentials(t *testing.T) {
t.Setenv("PROMPTKIT_INSPECTION_TEST_KEY", "")
profiles := &inspectionProfileRepository{profile: &domain.ExecutionProfile{
ID: "endpoint-only", ResolvedTarget: &domain.ExecutionTarget{
Endpoint: "https://profile.example/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_INSPECTION_TEST_KEY",
},
}}
inspection, err := (&Runner{profiles: profiles}).InspectProfile(context.Background(), "endpoint-only")
if err != nil || inspection.EffectiveModelParams.APIKeyEnv != "PROMPTKIT_INSPECTION_TEST_KEY" || inspection.APIKeyRequired {
t.Fatalf("unexpected endpoint-only inspection: (%+v, %v)", inspection, err)
}
}
func TestRunnerInspectProfileClassifiesLookupFailures(t *testing.T) {
profiles := &inspectionProfileRepository{}
runner := &Runner{profiles: profiles}
if _, err := runner.InspectProfile(context.Background(), " \t "); !errors.Is(err, ErrInvalidRequest) || profiles.calls != 0 {
t.Fatalf("blank inspection=(%v, calls=%d)", err, profiles.calls)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := runner.InspectProfile(ctx, "profile"); !errors.Is(err, ErrProfileLoad) || !errors.Is(err, context.Canceled) || profiles.calls != 0 {
t.Fatalf("canceled inspection=(%v, calls=%d)", err, profiles.calls)
}
profiles.err = profile.ErrProfileNotFound
if _, err := runner.InspectProfile(context.Background(), "missing"); !errors.Is(err, ErrProfileLoad) || !errors.Is(err, profile.ErrProfileNotFound) {
t.Fatalf("missing profile error=%v", err)
}
for _, invalid := range []*Runner{
{},
{profiles: &inspectionProfileRepository{}},
{profiles: &inspectionProfileRepository{profile: &domain.ExecutionProfile{ID: "profile"}}},
} {
if _, err := invalid.InspectProfile(context.Background(), "profile"); !errors.Is(err, ErrProfileLoad) {
t.Fatalf("invalid dependency error=%v", err)
}
}
}

View File

@@ -48,17 +48,3 @@ func (r *Runner) resolvePromptInspection(ctx context.Context, promptID string) (
}
return inspection, nil
}
// InspectPrompt resolves one explicit prompt without execution work.
func (r *Runner) InspectPrompt(ctx context.Context, promptID string) (*domain.PromptInspection, error) {
if strings.TrimSpace(promptID) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest)
}
select {
case <-ctx.Done():
return nil, &OperationError{Category: ErrPromptLoad, Cause: ctx.Err()}
default:
}
return r.resolvePromptInspection(ctx, promptID)
}

View File

@@ -1,218 +0,0 @@
package usecase
import (
"context"
"errors"
"reflect"
"testing"
"go.maximumdirect.net/promptkit/internal/domain"
"go.maximumdirect.net/promptkit/internal/prompt"
"go.maximumdirect.net/promptkit/internal/promptdef"
)
type inspectionPromptRepository struct {
definition *domain.PromptDefinition
err error
calls int
id string
}
type promptMetadataRepository struct {
inspection domain.PromptInspection
inspectionErr error
lookupID string
}
func (r *inspectionPromptRepository) GetPromptSnapshot(
_ context.Context,
id string,
) (*domain.PromptDefinition, string, error) {
r.calls++
r.id = id
if r.err != nil {
return nil, "", r.err
}
return r.definition, "captured-hash", nil
}
func (r *inspectionPromptRepository) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) {
definition, hash, err := r.GetPromptSnapshot(ctx, id)
return fixturePromptInspection(definition, hash), err
}
func (*inspectionPromptRepository) GetRequirementSnapshot(string) (prompt.RequirementSnapshot, bool) {
return prompt.RequirementSnapshot{}, false
}
func (r *promptMetadataRepository) GetPromptSnapshot(context.Context, string) (*domain.PromptDefinition, string, error) {
return nil, "", errors.New("full prompt definition should not be requested")
}
func (*promptMetadataRepository) GetRequirementSnapshot(string) (prompt.RequirementSnapshot, bool) {
return prompt.RequirementSnapshot{}, false
}
func (r *promptMetadataRepository) GetPromptInspection(
_ context.Context,
id string,
) (*domain.PromptInspection, error) {
r.lookupID = id
inspection := r.inspection
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
return &inspection, r.inspectionErr
}
func TestRunnerInspectPromptResolvesOneDefinitionWithoutExecutionCollaborators(t *testing.T) {
definition := &domain.PromptDefinition{
ID: "normalized.prompt",
DefaultProfile: "not-resolved",
Inputs: []domain.PromptInput{
{Name: "document", Required: true, ContentType: "text/plain", Description: "Source document."},
{Name: "audience", ContentType: "text/plain", Description: "Intended reader."},
},
Validation: domain.OutputContract{
Format: domain.FormatJSON,
ValidationMode: domain.ValidationJSONSchema,
SchemaPath: "schemas/result.json",
RepairAttempts: 2,
},
}
repository := &inspectionPromptRepository{definition: definition}
runner := &Runner{promptDefs: repository}
inspection, err := runner.InspectPrompt(context.Background(), " prompt-id ")
if err != nil {
t.Fatalf("inspect prompt: %v", err)
}
if repository.calls != 1 || repository.id != " prompt-id " {
t.Fatalf("prompt lookup=(calls=%d id=%q), want one unchanged ID lookup", repository.calls, repository.id)
}
if inspection.PromptID != definition.ID ||
inspection.PromptHash != "captured-hash" ||
inspection.DefaultProfileID != definition.DefaultProfile ||
!reflect.DeepEqual(inspection.Inputs, definition.Inputs) ||
inspection.OutputContract != definition.Validation {
t.Fatalf("inspection=%#v, want definition metadata", inspection)
}
inspection.Inputs[0].Name = "changed"
second, err := runner.InspectPrompt(context.Background(), " prompt-id ")
if err != nil {
t.Fatalf("inspect prompt again: %v", err)
}
if definition.Inputs[0].Name != "document" || second.Inputs[0].Name != "document" {
t.Fatalf("inspection input mutation escaped caller result: definition=%#v next=%#v", definition.Inputs, second.Inputs)
}
}
func TestRunnerInspectPromptUsesOwnedCapturedMetadata(t *testing.T) {
repository := &promptMetadataRepository{inspection: domain.PromptInspection{
PromptID: "captured.prompt", PromptHash: "cached-prompt-hash",
DefaultProfileID: "partial-profile",
Inputs: []domain.PromptInput{{Name: "document", Required: true, ContentType: "text/plain", Description: "Captured."}},
OutputContract: domain.OutputContract{Format: domain.FormatJSON, ValidationMode: domain.ValidationJSON},
}}
runner := &Runner{promptDefs: repository}
inspection, err := runner.InspectPrompt(context.Background(), " requested-id ")
if err != nil || repository.lookupID != " requested-id " ||
inspection.PromptID != "captured.prompt" ||
inspection.PromptHash != "cached-prompt-hash" || inspection.DefaultProfileID != "partial-profile" ||
!reflect.DeepEqual(inspection.Inputs, repository.inspection.Inputs) || inspection.OutputContract != repository.inspection.OutputContract {
t.Fatalf("metadata inspection=(%+v, %v), lookup=%q", inspection, err, repository.lookupID)
}
inspection.Inputs[0].Name = "caller mutation"
inspection.OutputContract.SchemaPath = "caller mutation"
second, err := runner.InspectPrompt(context.Background(), "requested-id")
if err != nil || second.Inputs[0].Name != "document" || second.OutputContract.SchemaPath != "" {
t.Fatalf("metadata inspection was not caller-owned: (%+v, %v)", second, err)
}
}
func TestRunnerInspectPromptUsesCapturedHashAndMetadata(t *testing.T) {
repository := &requirementInspectionPromptRepository{
definition: &domain.PromptDefinition{
ID: "captured.prompt", Inputs: []domain.PromptInput{{Name: "document"}},
},
}
inspection, err := (&Runner{promptDefs: repository}).InspectPrompt(context.Background(), " requested-id ")
if err != nil || inspection.PromptID != "captured.prompt" ||
inspection.PromptHash != "captured-hash" || inspection.Inputs[0].Name != "document" {
t.Fatalf("captured inspection=(%+v, %v)", inspection, err)
}
}
func TestRunnerInspectPromptClassifiesFailuresWithoutRepositoryWorkAfterCancellation(t *testing.T) {
t.Run("blank ID", func(t *testing.T) {
repository := &inspectionPromptRepository{}
_, err := (&Runner{promptDefs: repository}).InspectPrompt(context.Background(), " \t ")
if !errors.Is(err, ErrInvalidRequest) || repository.calls != 0 {
t.Fatalf("blank inspection=(%v, calls=%d), want invalid request without lookup", err, repository.calls)
}
})
t.Run("canceled context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
repository := &inspectionPromptRepository{}
_, err := (&Runner{promptDefs: repository}).InspectPrompt(ctx, "prompt")
if !errors.Is(err, ErrPromptLoad) || !errors.Is(err, context.Canceled) || repository.calls != 0 {
t.Fatalf("canceled inspection=(%v, calls=%d), want prompt load and context identities without lookup", err, repository.calls)
}
})
t.Run("missing prompt", func(t *testing.T) {
repository := &inspectionPromptRepository{err: promptdef.ErrPromptDefinitionNotFound}
_, err := (&Runner{promptDefs: repository}).InspectPrompt(context.Background(), "missing")
if !errors.Is(err, ErrPromptLoad) || !errors.Is(err, promptdef.ErrPromptDefinitionNotFound) {
t.Fatalf("missing prompt error=%v, want prompt load and not-found identities", err)
}
})
t.Run("defensive prompt dependencies", func(t *testing.T) {
var nilRunner *Runner
cases := []struct {
name string
runner *Runner
}{
{name: "nil runner", runner: nilRunner},
{name: "nil repository", runner: &Runner{}},
{name: "nil definition", runner: &Runner{promptDefs: &inspectionPromptRepository{}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.runner.InspectPrompt(context.Background(), "prompt")
if !errors.Is(err, ErrPromptLoad) {
t.Fatalf("inspection error=%v, want prompt load", err)
}
})
}
})
}
func TestRunnerPrepareUsesThePromptInspectionSelectionAndHash(t *testing.T) {
definition := promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)
repository := &fakePromptRepo{def: definition}
runner := NewRunner(
repository,
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
&fakeArtifactReader{},
&fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}}},
nil,
nil,
nil,
)
inspection, err := runner.InspectPrompt(context.Background(), definition.ID)
if err != nil {
t.Fatalf("inspect prompt: %v", err)
}
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: definition.ID, ProfileID: "exec"})
if err != nil {
t.Fatalf("prepare prompt: %v", err)
}
if inspection.PromptHash != prepared.PromptHash {
t.Fatalf("inspection hash=%q, preparation hash=%q", inspection.PromptHash, prepared.PromptHash)
}
}

View File

@@ -9,20 +9,6 @@ import (
"go.maximumdirect.net/promptkit/internal/domain"
)
func TestInspectProfileAllowsIncompleteResolvedTarget(t *testing.T) {
profile := &domain.ExecutionProfile{ID: "partial", ResolvedTarget: &domain.ExecutionTarget{
TimeoutSeconds: defaults.ExecutionDefaultTimeoutSeconds,
}}
runner := &Runner{profiles: staticExecutionProfileRepo{profile: profile}}
inspection, err := runner.InspectProfile(context.Background(), "partial")
if err != nil {
t.Fatalf("partial profile inspection failed: %v", err)
}
if inspection.EffectiveModelParams.Endpoint != "" || inspection.EffectiveModelParams.Model != "" {
t.Fatalf("inspection invented a runnable target: %+v", inspection)
}
}
func TestPreparationCompletesResolvedTargetThroughRequestOverrides(t *testing.T) {
profile := &domain.ExecutionProfile{ID: "partial", ResolvedTarget: &domain.ExecutionTarget{
TimeoutSeconds: defaults.ExecutionDefaultTimeoutSeconds,

View File

@@ -438,11 +438,11 @@ func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) {
}
overlay, err := engine.InspectProfile(context.Background(), "shared/overlay")
if err != nil || overlay == nil || overlay.EffectiveModelParams.Model != "effective-model" {
if err != nil || overlay == nil || overlay.Model == nil || *overlay.Model != "effective-model" {
t.Fatalf("overlay profile did not resolve to its effective definition: (%+v, %v)", overlay, err)
}
child, err := engine.InspectProfile(context.Background(), "shared/child")
if err != nil || child == nil || child.EffectiveModelParams.Model != "base-model" {
if err != nil || child == nil || child.Model == nil || *child.Model != "base-model" {
t.Fatalf("inherited child profile did not resolve its effective definition: (%+v, %v)", child, err)
}

View File

@@ -87,6 +87,26 @@ func TestWithNamespacesRoutesOrdinarySelectionsAndPreservesViews(t *testing.T) {
}
}
func TestInspectBackendNamespaceSelection(t *testing.T) {
engine := newNamespacedContractEngine(t, &fakeLLMClient{})
scoped, err := engine.WithNamespaces(promptkit.Namespaces{Backend: "alpha"})
if err != nil {
t.Fatal(err)
}
for id, want := range map[string]string{"backend": "alpha/backend", "beta/backend": "beta/backend"} {
got, err := scoped.InspectBackend(context.Background(), id)
if err != nil || got.BackendID != want {
t.Errorf("backend %q = %+v, %v", id, got, err)
}
}
if _, err := scoped.InspectBackend(context.Background(), "openrouter"); !errors.Is(err, promptkit.ErrBackendNotFound) {
t.Fatalf("unexpected fallback: %v", err)
}
if got, err := engine.InspectBackend(context.Background(), "openrouter"); err != nil || got.BackendID != "openrouter" {
t.Fatalf("bundled backend = %+v, %v", got, err)
}
}
func TestWithNamespacesHasNoFallbackAndValidatesAtTheCorrectBoundary(t *testing.T) {
engine := newNamespacedContractEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}})
scoped, err := engine.WithNamespaces(promptkit.Namespaces{Prompt: "alpha", Profile: "alpha"})

View File

@@ -41,9 +41,8 @@ func TestProfileInheritanceConfiguredBaseAliasWorkflow(t *testing.T) {
if err != nil {
t.Fatalf("inspect alias: %v", err)
}
if child.ProfileID != "child" || child.EffectiveModelParams.BackendID != base.EffectiveModelParams.BackendID ||
child.EffectiveModelParams.Model != base.EffectiveModelParams.Model ||
child.EffectiveModelParams.ReasoningEffort != reasoning || child.EffectiveModelParams.TimeoutSeconds != timeout {
if child.ProfileID != "child" || child.Backend != nil || base.Backend != nil || child.Model == nil || base.Model == nil || *child.Model != *base.Model ||
child.ReasoningEffort == nil || *child.ReasoningEffort != reasoning || child.TimeoutSeconds == nil || *child.TimeoutSeconds != timeout {
t.Fatalf("alias inspection = %+v, base = %+v", child, base)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prepared"})
@@ -71,7 +70,7 @@ func TestProfileInheritanceYAMLAliasOfConfiguredProfile(t *testing.T) {
t.Fatal(err)
}
inspection, err := engine.InspectProfile(context.Background(), "yaml-alias")
if err != nil || inspection.ProfileID != "yaml-alias" || inspection.EffectiveModelParams.Model == "" {
if err != nil || inspection.ProfileID != "yaml-alias" || inspection.Model == nil || *inspection.Model == "" {
t.Fatalf("YAML alias inspection = (%+v, %v)", inspection, err)
}
}
@@ -92,7 +91,7 @@ func TestProfileInheritanceRetainsRequiredCredentialBehavior(t *testing.T) {
t.Fatal(err)
}
inspection, err := engine.InspectProfile(context.Background(), "credential-child")
if err != nil || !inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "" {
if err != nil || inspection.APIKeyRequired == nil || !*inspection.APIKeyRequired || inspection.APIKeyEnv != nil {
t.Fatalf("credential inspection = (%+v, %v)", inspection, err)
}
}
@@ -144,7 +143,7 @@ func TestProfileInheritanceFreezesPreparedExecution(t *testing.T) {
t.Fatalf("prepared execution = (%+v, %v), model %q", result, err, client.model)
}
inspection, err := engine.InspectProfile(context.Background(), "child")
if err != nil || inspection.EffectiveModelParams.Model != "first-model" {
if err != nil || inspection.Model == nil || *inspection.Model != "first-model" {
t.Fatalf("existing engine inspection = (%+v, %v)", inspection, err)
}
}

View File

@@ -39,7 +39,7 @@ func TestInspectProfileUsesLiteralFileKeyForPreparation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if inspected.ProfileID != " literal " || prepared.SelectedProfileID != inspected.ProfileID || prepared.EffectiveModelParams.Model != inspected.EffectiveModelParams.Model {
if inspected.ProfileID != " literal " || inspected.Model == nil || prepared.SelectedProfileID != inspected.ProfileID || prepared.EffectiveModelParams.Model != *inspected.Model {
t.Fatalf("inspection=%+v preparation=%+v", inspected, prepared)
}
if _, err := engine.InspectProfile(context.Background(), "literal"); !errors.Is(err, promptkit.ErrProfileNotFound) {
@@ -61,12 +61,12 @@ func TestInspectProfileIsIndependentAndDoesNotInvokeProvider(t *testing.T) {
t.Fatal(err)
}
first, err := engine.InspectProfile(context.Background(), "profile")
if err != nil || first == nil || !first.APIKeyRequired {
if err != nil || first == nil || first.APIKeyRequired == nil || !*first.APIKeyRequired || first.ExtraParams == nil {
t.Fatalf("first inspection=(%+v, %v)", first, err)
}
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "changed"
(*first.ExtraParams)["nested"].(map[string]any)["value"] = "changed"
second, err := engine.InspectProfile(context.Background(), "profile")
if err != nil || second == nil || second.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" || len(client.requests) != 0 {
if err != nil || second == nil || second.ExtraParams == nil || (*second.ExtraParams)["nested"].(map[string]any)["value"] != "original" || len(client.requests) != 0 {
t.Fatalf("inspection changed snapshot or invoked provider: (%+v, %v), calls=%d", second, err, len(client.requests))
}
}
@@ -94,7 +94,7 @@ func TestInspectProfilePublicErrorsAndPartialTarget(t *testing.T) {
t.Fatalf("canceled inspection: %v", err)
}
partial, err := engine.InspectProfile(context.Background(), "profile")
if err != nil || partial == nil || partial.EffectiveModelParams.Endpoint != "" || partial.EffectiveModelParams.Model != "model" {
if err != nil || partial == nil || partial.Endpoint != nil || partial.Model == nil || *partial.Model != "model" {
t.Fatalf("partial inspection=(%+v, %v)", partial, err)
}
}

View File

@@ -0,0 +1,106 @@
package promptkit_test
import (
"context"
"errors"
"testing"
"testing/fstest"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestTypedSourcesComposeAndCaptureValues(t *testing.T) {
endpoint, childEndpoint, base := "https://base.test/v1", "https://child.test/v1", "base"
limit, queue := 2, 0
backendSource := promptkit.BackendSource("backends", []promptkit.BackendConfig{{ID: "base", Endpoint: &endpoint, ConcurrencyLimit: &limit}, {ID: "child", Extends: &base, Endpoint: &childEndpoint, QueueCapacity: &queue}})
role, content, secondContent, empty, cacheType := "user", "hello {{.name}}", "second", "", "ephemeral"
format, validation, description, profileID := "text", "none", "description", "local"
required, repairs := false, 0
prompts := []promptkit.PromptConfig{
{ID: "base", DefaultProfile: &profileID, Description: &description, Inputs: []promptkit.PromptInputConfig{{Name: "input", Required: &required, Description: &empty}}, Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content, CacheControl: &promptkit.PromptCacheControlConfig{Type: &cacheType, TTL: &empty}}, {Role: &role, Content: &secondContent}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation, RepairAttempts: &repairs}},
{ID: "child", Extends: &base},
{ID: "layered"},
{ID: "empty-inputs", Inputs: []promptkit.PromptInputConfig{}, Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}},
{ID: "omitted-inputs", Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &content}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}},
}
promptSource := promptkit.PromptSource("typed prompts", prompts)
endpoint, childEndpoint, role, content, description = "changed", "changed", "changed", "changed", "changed"
prompts[0].Inputs[0].Name = "changed"
prompts[0].Messages[1].Content = &empty
prompts[1].ID = "changed"
profileEndpoint, model := "https://profile.test/v1", "model"
main := configkit.Input{Source: configkit.Mapping("completion", map[string]any{"prompts": []any{map[string]any{"id": "layered", "messages": []any{map[string]any{"role": "user", "content": "layer"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}})}
profileSource := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{{ID: "local", Endpoint: &profileEndpoint, Model: &model}})
underlays := []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: promptSource}, {Source: profileSource}}}}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: underlays, Main: &main}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
backend, err := engine.InspectBackend(ctx, "child")
if err != nil || backend.BackendID != "child" || backend.Endpoint != "https://child.test/v1" || backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit != 2 || backend.QueueCapacity == nil || *backend.QueueCapacity != 0 {
t.Fatalf("backend = %+v, %v", backend, err)
}
prompt, err := engine.InspectPrompt(ctx, "child")
if err != nil {
t.Fatal(err)
}
if prompt.DefaultProfileID == nil || *prompt.DefaultProfileID != "local" || prompt.Description == nil || *prompt.Description != "description" || prompt.Inputs == nil || len(*prompt.Inputs) != 1 || (*prompt.Inputs)[0].Name != "input" || (*prompt.Inputs)[0].Required == nil || *(*prompt.Inputs)[0].Required || (*prompt.Inputs)[0].Description == nil || *(*prompt.Inputs)[0].Description != "" || len(prompt.Messages) != 2 || prompt.Messages[0].Content != "hello {{.name}}" || prompt.Messages[1].Content != "second" || prompt.Messages[0].CacheControl.TTL == nil || *prompt.Messages[0].CacheControl.TTL != "" || prompt.OutputContract.RepairAttempts == nil || *prompt.OutputContract.RepairAttempts != 0 {
t.Fatalf("prompt = %+v", prompt)
}
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{PromptID: "child", Vars: map[string]string{"name": "reader"}})
if err != nil || prepared.Messages[0].Content != "hello reader" {
t.Fatalf("preparation = %+v, %v", prepared, err)
}
for _, id := range []string{"layered", "empty-inputs", "omitted-inputs"} {
result, err := engine.InspectPrompt(ctx, id)
if err != nil {
t.Fatal(err)
}
if id == "empty-inputs" && (result.Inputs == nil || len(*result.Inputs) != 0) {
t.Fatalf("empty inputs = %+v", result.Inputs)
}
if id == "omitted-inputs" && result.Inputs != nil {
t.Fatalf("omitted inputs = %+v", result.Inputs)
}
}
}
func TestTypedPromptSourceUsesExplicitFilesystemContext(t *testing.T) {
role, path, format, validation, parent := "user", "body.md", "text", "none", "base"
source := promptkit.PromptSource("file prompts", []promptkit.PromptConfig{{ID: "base", Messages: []promptkit.PromptMessageConfig{{Role: &role, ContentFile: &path}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}, {ID: "child", Extends: &parent}})
files := fstest.MapFS{"templates/body.md": {Data: []byte("captured {{.value}}")}}
filesystem := configkit.NewFilesystem("fixture", files)
input := configkit.Input{Source: source, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "templates"}}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}})
if err != nil {
t.Fatal(err)
}
files["templates/body.md"].Data = []byte("changed")
prompt, err := engine.InspectPrompt(context.Background(), "child")
if err != nil || prompt.Messages[0].Content != "captured {{.value}}" {
t.Fatalf("file prompt = %+v, %v", prompt, err)
}
}
func TestTypedPromptSourceDefersValidation(t *testing.T) {
role, body, path, format, validation := "user", "body", "also-file", "text", "none"
source := promptkit.PromptSource("invalid", []promptkit.PromptConfig{{ID: "bad", Messages: []promptkit.PromptMessageConfig{{Role: &role, Content: &body, ContentFile: &path}}, Output: &promptkit.PromptOutputConfig{Format: &format, ValidationMode: &validation}}})
_, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}})
if !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("invalid final source: %v", err)
}
}
func TestNilTypedPromptSourceSuppliesEmptySequence(t *testing.T) {
source := promptkit.PromptSource("empty", nil)
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}}, promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}
prompts, err := engine.ListPrompts(context.Background())
if err != nil || len(prompts) != 0 {
t.Fatalf("empty source = %v, %v", prompts, err)
}
}

129
prompts.go Normal file
View File

@@ -0,0 +1,129 @@
package promptkit
import "go.maximumdirect.net/configkit"
// PromptConfig is a presence-aware source input for one prompt. Extends names
// a sibling prompt. Nil optional pointers and slices are omitted; non-nil
// pointers and slices preserve explicit zero, blank, and empty values. These
// inputs are mapping constructors, not serialized inspection values. Raw
// ConfigKit mappings can express explicit null and other composition controls.
type PromptConfig struct {
ID string
Extends, DefaultProfile, Description, SessionID *string
Inputs []PromptInputConfig
Messages []PromptMessageConfig
Output *PromptOutputConfig
}
// PromptInputConfig supplies an input declaration. Name is always supplied;
// nil optional pointers omit their fields.
type PromptInputConfig struct {
Name string
Required *bool
ContentType, Description *string
}
// PromptMessageConfig supplies message fields, including inline content or a
// content-file reference. Final content selection is validated by NewEngine.
type PromptMessageConfig struct {
Role, Content, ContentFile *string
CacheControl *PromptCacheControlConfig
}
// PromptCacheControlConfig supplies optional cache-control source fields.
type PromptCacheControlConfig struct{ Type, TTL *string }
// PromptOutputConfig supplies optional output contract source fields.
type PromptOutputConfig struct {
Format, ValidationMode, SchemaPath *string
RepairAttempts *int
}
// PromptSource creates a labeled ConfigKit mapping in caller order. Nil
// top-level input supplies an empty prompts sequence. Records may be partial:
// sibling inheritance and layered sources can complete them before NewEngine
// validates the final configuration. Mapping snapshots supported values when
// constructed; the helper neither resolves inheritance nor reads files.
// Relative content files use the ConfigKit Input filesystem context of the
// contributing field. Raw mappings remain available for explicit null and
// generic ConfigKit operations.
func PromptSource(label string, prompts []PromptConfig) configkit.Source {
definitions := make([]any, 0, len(prompts))
for _, config := range prompts {
fields := map[string]any{"id": config.ID}
if config.Extends != nil {
fields["extends"] = map[string]any{"id": *config.Extends}
}
if config.DefaultProfile != nil {
fields["default_profile"] = *config.DefaultProfile
}
if config.Description != nil {
fields["description"] = *config.Description
}
if config.SessionID != nil {
fields["session_id"] = *config.SessionID
}
if config.Inputs != nil {
inputs := make([]any, 0, len(config.Inputs))
for _, input := range config.Inputs {
item := map[string]any{"name": input.Name}
if input.Required != nil {
item["required"] = *input.Required
}
if input.ContentType != nil {
item["content_type"] = *input.ContentType
}
if input.Description != nil {
item["description"] = *input.Description
}
inputs = append(inputs, item)
}
fields["inputs"] = inputs
}
if config.Messages != nil {
messages := make([]any, 0, len(config.Messages))
for _, message := range config.Messages {
item := make(map[string]any)
if message.Role != nil {
item["role"] = *message.Role
}
if message.Content != nil {
item["content"] = *message.Content
}
if message.ContentFile != nil {
item["content_file"] = *message.ContentFile
}
if message.CacheControl != nil {
cache := make(map[string]any)
if message.CacheControl.Type != nil {
cache["type"] = *message.CacheControl.Type
}
if message.CacheControl.TTL != nil {
cache["ttl"] = *message.CacheControl.TTL
}
item["cache_control"] = cache
}
messages = append(messages, item)
}
fields["messages"] = messages
}
if config.Output != nil {
output := make(map[string]any)
if config.Output.Format != nil {
output["format"] = *config.Output.Format
}
if config.Output.ValidationMode != nil {
output["validation_mode"] = *config.Output.ValidationMode
}
if config.Output.SchemaPath != nil {
output["schema_path"] = *config.Output.SchemaPath
}
if config.Output.RepairAttempts != nil {
output["repair_attempts"] = *config.Output.RepairAttempts
}
fields["output"] = output
}
definitions = append(definitions, fields)
}
return configkit.Mapping(label, map[string]any{"prompts": definitions})
}

85
resource_inspection.go Normal file
View File

@@ -0,0 +1,85 @@
package promptkit
// ProfileInspection describes a resolved profile resource, before backend
// completion or request overrides. References are not resolved. APIKeyEnv is
// an environment-variable name, never a credential value. The result and its
// mutable values are caller-owned. Nil pointers denote absent fields; non-nil
// pointers preserve explicit zero values and empty collections. JSON and YAML
// use the indicated field names and omit absent optional fields.
type ProfileInspection struct {
ProfileID string `json:"id" yaml:"id"`
Backend *string `json:"backend,omitempty" yaml:"backend,omitempty"`
Endpoint *string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
Model *string `json:"model,omitempty" yaml:"model,omitempty"`
Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty" yaml:"top_p,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty" yaml:"max_tokens,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty" yaml:"timeout_seconds,omitempty"`
ServiceTier *string `json:"service_tier,omitempty" yaml:"service_tier,omitempty"`
ReasoningEffort *string `json:"reasoning_effort,omitempty" yaml:"reasoning_effort,omitempty"`
APIKeyEnv *string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"`
APIKeyRequired *bool `json:"api_key_required,omitempty" yaml:"api_key_required,omitempty"`
ExtraParams *map[string]any `json:"extra_params,omitempty" yaml:"extra_params,omitempty"`
}
// BackendInspection describes a resolved backend resource, not runtime capacity
// or diagnostic destinations derived during execution. APIKeyEnv names an
// environment variable, not its value. The result and its mutable values are
// caller-owned. Nil pointers denote absent fields; non-nil pointers preserve
// explicit zero values and empty collections in JSON and YAML.
type BackendInspection struct {
BackendID string `json:"id" yaml:"id"`
Endpoint string `json:"endpoint" yaml:"endpoint"`
APIKeyEnv *string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"`
ConcurrencyLimit *int `json:"concurrency_limit,omitempty" yaml:"concurrency_limit,omitempty"`
QueueCapacity *int `json:"queue_capacity,omitempty" yaml:"queue_capacity,omitempty"`
ExtraParams *map[string]any `json:"extra_params,omitempty" yaml:"extra_params,omitempty"`
SmokeTests *BackendSmokeTestConfig `json:"smoke_tests,omitempty" yaml:"smoke_tests,omitempty"`
}
// PromptInspection describes a resolved prompt resource. Messages contain
// captured inline or imported text, not rendered content. References are not
// resolved by inspection; PromptHash is opaque. The result and its mutable
// values are caller-owned. Nil pointers denote absent fields; non-nil pointers
// preserve explicit zero values and empty collections in JSON and YAML.
type PromptInspection struct {
PromptID string `json:"id" yaml:"id"`
PromptHash string `json:"prompt_hash" yaml:"prompt_hash"`
DefaultProfileID *string `json:"default_profile,omitempty" yaml:"default_profile,omitempty"`
Description *string `json:"description,omitempty" yaml:"description,omitempty"`
SessionID *string `json:"session_id,omitempty" yaml:"session_id,omitempty"`
Inputs *[]PromptInputInspection `json:"inputs,omitempty" yaml:"inputs,omitempty"`
Messages []PromptMessageInspection `json:"messages" yaml:"messages"`
OutputContract PromptOutputInspection `json:"output" yaml:"output"`
}
// PromptInputInspection describes a declared input and its optional metadata.
type PromptInputInspection struct {
Name string `json:"name" yaml:"name"`
Required *bool `json:"required,omitempty" yaml:"required,omitempty"`
ContentType *string `json:"content_type,omitempty" yaml:"content_type,omitempty"`
Description *string `json:"description,omitempty" yaml:"description,omitempty"`
}
// PromptMessageInspection describes a captured, unrendered message.
type PromptMessageInspection struct {
Role string `json:"role" yaml:"role"`
Content string `json:"content" yaml:"content"`
CacheControl *PromptCacheControlInspection `json:"cache_control,omitempty" yaml:"cache_control,omitempty"`
}
// PromptCacheControlInspection describes configured message cache metadata.
// A non-nil TTL preserves an explicitly empty value.
type PromptCacheControlInspection struct {
Type CacheControlType `json:"type" yaml:"type"`
TTL *string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
}
// PromptOutputInspection describes a prompt's output contract. SchemaPath is
// a reference and is not loaded by inspection.
type PromptOutputInspection struct {
Format OutputFormat `json:"format" yaml:"format"`
ValidationMode ValidationMode `json:"validation_mode" yaml:"validation_mode"`
SchemaPath *string `json:"schema_path,omitempty" yaml:"schema_path,omitempty"`
RepairAttempts *int `json:"repair_attempts,omitempty" yaml:"repair_attempts,omitempty"`
}

View File

@@ -0,0 +1,51 @@
package promptkit
import (
"context"
"errors"
"testing"
"go.maximumdirect.net/configkit"
)
func TestResourceInspectionSelections(t *testing.T) {
engine, err := NewEngine(Config{Sources: ConfigurationSources{Main: &configkit.Input{Source: configkit.Mapping("resources", map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.test/v1"}},
"profiles": []any{map[string]any{"id": "partial", "backend": "local"}},
"prompts": []any{map[string]any{"id": "prompt", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "basic"}}},
})}}})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if got, err := engine.InspectBackend(ctx, "local"); err != nil || got.BackendID != "local" {
t.Fatalf("backend = %+v, %v", got, err)
}
if got, err := engine.InspectProfile(ctx, "partial"); err != nil || got.Backend == nil || *got.Backend != "local" || got.Endpoint != nil {
t.Fatalf("profile = %+v, %v", got, err)
}
if got, err := engine.InspectPrompt(ctx, "prompt"); err != nil || got.Messages[0].Content != "hello" {
t.Fatalf("prompt = %+v, %v", got, err)
}
if got, err := engine.InspectBackend(ctx, "missing"); got != nil || !errors.Is(err, ErrBackendNotFound) || errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing = %+v, %v", got, err)
}
if _, err := engine.InspectBackend(ctx, "bad/id/extra"); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("invalid ID = %v", err)
}
var nilEngine *Engine
if _, err := nilEngine.InspectBackend(ctx, "local"); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("nil engine = %v", err)
}
canceled, cancel := context.WithCancel(ctx)
cancel()
if _, err := engine.InspectBackend(canceled, "local"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled = %v", err)
}
if _, err := engine.InspectPrompt(canceled, "prompt"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled prompt = %v", err)
}
if _, err := engine.InspectProfile(canceled, "partial"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled profile = %v", err)
}
}

101
resource_isolation_test.go Normal file
View File

@@ -0,0 +1,101 @@
package promptkit_test
import (
"context"
"strings"
"sync"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
)
func TestResourceInspectionIsolationAndNullInheritance(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "unexpected"}}
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: configkit.Mapping("resources", map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": "https://backend.test/v1", "api_key_env": "UNSET_INSPECTION_KEY", "extra_params": map[string]any{"nested": map[string]any{"array": []any{"captured"}}}, "smoke_tests": map[string]any{"default_profile": "missing", "reachability": map[string]any{}}}},
"profiles": []any{map[string]any{"id": "parent", "backend": "local", "endpoint": "https://parent.test/v1", "extra_params": map[string]any{"parent": true}, "model": "model"}, map[string]any{"id": "child", "extends": map[string]any{"id": "parent"}, "endpoint": nil, "extra_params": nil, "api_key_env": nil}, map[string]any{"id": "partial"}},
"prompts": []any{map[string]any{"id": "reference", "default_profile": "missing", "messages": []any{map[string]any{"role": "user", "content": "{{.missing}}"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "missing.json"}}, map[string]any{"id": "ready", "inputs": []any{map[string]any{"name": "x", "required": false}}, "messages": []any{map[string]any{"role": "user", "content": "hello", "cache_control": map[string]any{"type": "ephemeral", "ttl": ""}}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
})}}}, promptkit.WithLLMClient(client), promptkit.WithBundledOpenRouter(false))
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if got, err := engine.InspectPrompt(ctx, "reference"); err != nil || got.DefaultProfileID == nil || *got.DefaultProfileID != "missing" || got.OutputContract.SchemaPath == nil || *got.OutputContract.SchemaPath != "missing.json" || got.Messages[0].Content != "{{.missing}}" {
t.Fatalf("reference = %+v, %v", got, err)
}
if got, err := engine.InspectProfile(ctx, "partial"); err != nil || got.Endpoint != nil || got.Model != nil {
t.Fatalf("partial = %+v, %v", got, err)
}
profile, err := engine.InspectProfile(ctx, "child")
if err != nil || profile.Endpoint != nil || profile.ExtraParams != nil || profile.APIKeyEnv != nil {
t.Fatalf("child = %+v, %v", profile, err)
}
view, err := engine.InspectConfiguration()
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"endpoint", "extra_params", "api_key_env"} {
path := configkit.Keys("profiles").Index(1).Key(field)
explanation, ok := view.Explain(path)
if !ok || !strings.Contains(explanation.Effective.YAML, "null") || len(explanation.History) == 0 {
t.Errorf("%s provenance = %+v, %t", field, explanation, ok)
}
}
backend, err := engine.InspectBackend(ctx, "local")
if err != nil {
t.Fatal(err)
}
if backend.SmokeTests == nil || backend.SmokeTests.DefaultProfile == nil || *backend.SmokeTests.DefaultProfile != "missing" {
t.Fatalf("diagnostics = %+v", backend)
}
*backend.APIKeyEnv = "MUTATED"
*backend.SmokeTests.DefaultProfile = "mutated"
(*backend.ExtraParams)["nested"].(map[string]any)["array"].([]any)[0] = "mutated"
*profile.Model = "mutated"
prompt, err := engine.InspectPrompt(ctx, "ready")
if err != nil {
t.Fatal(err)
}
(*prompt.Inputs)[0].Name = "changed"
*(*prompt.Inputs)[0].Required = true
prompt.Messages[0].Content = "changed"
*prompt.Messages[0].CacheControl.TTL = "changed"
again, err := engine.InspectBackend(ctx, "local")
if err != nil || *again.APIKeyEnv != "UNSET_INSPECTION_KEY" || *again.SmokeTests.DefaultProfile != "missing" || (*again.ExtraParams)["nested"].(map[string]any)["array"].([]any)[0] != "captured" {
t.Fatalf("backend mutation leaked: %+v, %v", again, err)
}
againPrompt, err := engine.InspectPrompt(ctx, "ready")
if err != nil || (*againPrompt.Inputs)[0].Name != "x" || *(*againPrompt.Inputs)[0].Required || againPrompt.Messages[0].Content != "hello" || *againPrompt.Messages[0].CacheControl.TTL != "" {
t.Fatalf("prompt mutation leaked: %+v, %v", againPrompt, err)
}
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{PromptID: "ready", ProfileID: "child"})
if err != nil || prepared.EffectiveModelParams.Endpoint != "https://backend.test/v1" || prepared.EffectiveModelParams.APIKeyEnv != "UNSET_INSPECTION_KEY" || prepared.EffectiveModelParams.Model != "model" || prepared.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["array"].([]any)[0] != "captured" {
t.Fatalf("prepared = %+v, %v", prepared, err)
}
if len(client.requests) != 0 {
t.Fatal("inspection invoked provider")
}
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
b, err := engine.InspectBackend(ctx, "local")
if err != nil {
t.Error(err)
return
}
(*b.ExtraParams)["nested"].(map[string]any)["array"].([]any)[0] = "local"
p, err := engine.InspectPrompt(ctx, "ready")
if err != nil {
t.Error(err)
return
}
p.Messages[0].Content = "local"
}
}()
}
wg.Wait()
}

View File

@@ -0,0 +1,124 @@
package promptkit_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"go.maximumdirect.net/configkit"
"go.maximumdirect.net/promptkit"
"go.yaml.in/yaml/v4"
)
func TestResourceInspectionGenericEncoding(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: configkit.Mapping("resources", map[string]any{
"backends": []any{map[string]any{"id": "local", "endpoint": "https://example.test/v1", "api_key_env": "BACKEND_KEY", "concurrency_limit": 2, "extra_params": map[string]any{"null": nil, "array": []any{nil}}, "smoke_tests": map[string]any{"reachability": map[string]any{}, "authentication": map[string]any{"kind": "none"}}}, map[string]any{"id": "plain", "endpoint": "https://plain.test/v1"}},
"profiles": []any{map[string]any{"id": "parent", "backend": "local", "model": "model"}, map[string]any{"id": "profile", "extends": map[string]any{"id": "parent"}, "temperature": 0, "top_p": 0, "max_tokens": 0, "timeout_seconds": 0, "service_tier": "", "reasoning_effort": "", "api_key_env": "", "api_key_required": false, "endpoint": "", "extra_params": map[string]any{}}, map[string]any{"id": "unset", "extra_params": nil}},
"prompts": []any{map[string]any{"id": "prompt", "inputs": []any{map[string]any{"name": "a", "required": false, "description": ""}, map[string]any{"name": "b"}}, "messages": []any{map[string]any{"role": "user", "content": "", "cache_control": map[string]any{"type": "ephemeral", "ttl": ""}}}, "output": map[string]any{"format": "text", "validation_mode": "basic", "repair_attempts": 0}}, map[string]any{"id": "empty-inputs", "inputs": []any{}, "messages": []any{map[string]any{"role": "user", "content": "x"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}, map[string]any{"id": "omitted-inputs", "messages": []any{map[string]any{"role": "user", "content": "x"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}},
})}}})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
backend, err := engine.InspectBackend(ctx, "local")
if err != nil {
t.Fatal(err)
}
plain, err := engine.InspectBackend(ctx, "plain")
if err != nil {
t.Fatal(err)
}
profile, err := engine.InspectProfile(ctx, "profile")
if err != nil {
t.Fatal(err)
}
unset, err := engine.InspectProfile(ctx, "unset")
if err != nil {
t.Fatal(err)
}
prompt, err := engine.InspectPrompt(ctx, "prompt")
if err != nil {
t.Fatal(err)
}
empty, err := engine.InspectPrompt(ctx, "empty-inputs")
if err != nil {
t.Fatal(err)
}
omitted, err := engine.InspectPrompt(ctx, "omitted-inputs")
if err != nil {
t.Fatal(err)
}
for name, codec := range map[string]struct {
marshal func(any) ([]byte, error)
unmarshal func([]byte, any) error
}{
"json": {json.Marshal, json.Unmarshal}, "yaml": {yaml.Marshal, yaml.Unmarshal},
} {
t.Run(name, func(t *testing.T) {
decode := func(v any) map[string]any {
t.Helper()
data, err := codec.marshal(v)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := codec.unmarshal(data, &m); err != nil {
t.Fatal(err)
}
return m
}
absent := func(m map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := m[key]; ok {
t.Errorf("unexpected %s in %#v", key, m)
}
}
}
b := decode(backend)
if b["id"] != "local" || b["api_key_env"] != "BACKEND_KEY" || fmt.Sprint(b["concurrency_limit"]) != "2" || fmt.Sprint(b["queue_capacity"]) != "1024" {
t.Errorf("backend = %#v", b)
}
params := b["extra_params"].(map[string]any)
if v, ok := params["null"]; !ok || v != nil || params["array"].([]any)[0] != nil {
t.Errorf("provider nulls = %#v", params)
}
smoke := b["smoke_tests"].(map[string]any)
if smoke["authentication"].(map[string]any)["kind"] != "none" {
t.Errorf("authentication = %#v", smoke)
}
if reach := smoke["reachability"].(map[string]any); len(reach) != 0 {
t.Errorf("reachability = %#v", reach)
}
absent(b, "extends")
absent(decode(plain), "smoke_tests", "queue_capacity", "concurrency_limit")
p := decode(profile)
if p["id"] != "profile" || p["backend"] != "local" || p["model"] != "model" || p["endpoint"] != "" || p["api_key_env"] != "" || p["service_tier"] != "" || p["reasoning_effort"] != "" || p["api_key_required"] != false || fmt.Sprint(p["temperature"]) != "0" || fmt.Sprint(p["top_p"]) != "0" || fmt.Sprint(p["max_tokens"]) != "0" || fmt.Sprint(p["timeout_seconds"]) != "0" || len(p["extra_params"].(map[string]any)) != 0 {
t.Errorf("profile = %#v", p)
}
absent(p, "extends", "effective_model_params")
absent(decode(unset), "extra_params", "endpoint")
q := decode(prompt)
inputs := q["inputs"].([]any)
a := inputs[0].(map[string]any)
bInput := inputs[1].(map[string]any)
if a["required"] != false || a["description"] != "" {
t.Errorf("input = %#v", a)
}
absent(bInput, "required", "description")
message := q["messages"].([]any)[0].(map[string]any)
if message["content"] != "" || message["cache_control"].(map[string]any)["ttl"] != "" {
t.Errorf("message = %#v", message)
}
absent(message, "content_file", "template")
if fmt.Sprint(q["output"].(map[string]any)["repair_attempts"]) != "0" {
t.Errorf("output = %#v", q["output"])
}
if inputs := decode(empty)["inputs"].([]any); len(inputs) != 0 {
t.Errorf("empty inputs = %#v", inputs)
}
absent(decode(omitted), "inputs")
})
}
}

View File

@@ -422,7 +422,7 @@ func TestSmokeTestUsesPrivateInferenceWithoutApplicationResourcesOrListingChange
t.Fatal(err)
}
if !reflect.DeepEqual(profilesBefore, profilesAfter) || !reflect.DeepEqual(promptsBefore, promptsAfter) ||
inspectionBefore.PromptHash != inspectionAfter.PromptHash || inspectionAfter.OutputContract.SchemaPath != schemaMarker {
inspectionBefore.PromptHash != inspectionAfter.PromptHash || inspectionAfter.OutputContract.SchemaPath == nil || *inspectionAfter.OutputContract.SchemaPath != schemaMarker {
t.Fatalf("diagnostic changed captured listings or application inspection: profiles %v -> %v, prompts %v -> %v, inspection %+v -> %+v",
profilesBefore, profilesAfter, promptsBefore, promptsAfter, inspectionBefore, inspectionAfter)
}

View File

@@ -70,7 +70,7 @@ func TestProgrammaticProfileVariantInherits(t *testing.T) {
t.Fatal(err)
}
profile, err := engine.InspectProfile(context.Background(), "child")
if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.Endpoint != endpoint || profile.EffectiveModelParams.TimeoutSeconds != timeout {
if err != nil || profile.Model == nil || *profile.Model != model || profile.Endpoint == nil || *profile.Endpoint != endpoint || profile.TimeoutSeconds == nil || *profile.TimeoutSeconds != timeout {
t.Fatalf("inherited profile = %+v, %v", profile, err)
}
}

View File

@@ -356,27 +356,6 @@ type ExecutionTarget struct {
ExtraParams map[string]any `json:"extra_params"`
}
// ProfileInspection is the caller-owned result of [Engine.InspectProfile].
// It has no stable JSON representation.
//
// EffectiveModelParams contains a copied effective target. APIKeyRequired is
// separate from that target to preserve ExecutionTarget's general execution
// and stable JSON contracts.
type ProfileInspection struct {
// ProfileID is the exact profile ID inspected by the engine.
ProfileID string
// EffectiveModelParams contains settings resolved from the framework timeout
// baseline, selected backend, and then profile, without a request override.
// Unset optional provider controls remain zero rather than reporting a
// provider default. APIKeyEnv is an environment-variable name, never its
// credential value.
EffectiveModelParams ExecutionTarget
// APIKeyRequired reports that a later execution must supply a usable direct
// key or environment credential. An explicit profile environment source can
// coexist with this requirement.
APIKeyRequired bool
}
// PromptInputDefinition describes one declared prompt input.
// It has no stable JSON representation.
type PromptInputDefinition struct {
@@ -392,26 +371,6 @@ type PromptInputDefinition struct {
Description string
}
// PromptInspection is the caller-owned result of [Engine.InspectPrompt].
// It has no stable JSON representation.
//
// Inputs contains copied declared input metadata in definition order.
// OutputContract is the normalized contract declared by the prompt definition,
// rather than a request-level effective override. PromptHash is opaque.
type PromptInspection struct {
// PromptID is the exact selected prompt ID after any namespace-view
// qualification.
PromptID string
// PromptHash is the opaque equality value for the selected definition.
PromptHash string
// DefaultProfileID is declared metadata and is not resolved by inspection.
DefaultProfileID string
// Inputs contains caller-owned declared input metadata in definition order.
Inputs []PromptInputDefinition
// OutputContract is the normalized contract declared by the definition.
OutputContract OutputContract
}
// ExecutionTargetOverride represents per-request runtime setting overrides and
// has no stable JSON representation.
//