From dfe9d6566f316b83d1f27d7792ad453676f0aeb8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 6 Nov 2026 15:21:00 +0000 Subject: [PATCH] Expose captured resource inspection through the engine --- convert.go | 70 ++++++++++++++++++++++----------- docs/roadmap/implementation.md | 4 ++ engine.go | 45 +++++++++++++++++---- internal/engine/engine.go | 33 ++++++++++++++-- resource_inspection_api_test.go | 45 +++++++++++++++++++++ 5 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 resource_inspection_api_test.go diff --git a/convert.go b/convert.go index 5b06d95..d625b8b 100644 --- a/convert.go +++ b/convert.go @@ -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 { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4d74369..4923c35 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -343,6 +343,10 @@ the resource report or trigger a second read. ## Stage 5: Wire Resource Inspection Through The Engine And Facade +**Completed:** Captured resource views are wired through namespace selection +and detached public projections. An isolated assembled public-API test passes; +existing consumers and examples are migrated in the next stage. + **Scope:** Selection, public conversion, backend entry point, and errors. 1. Add the three-method resource-inspection interface and captured collaborator diff --git a/engine.go b/engine.go index b7ca260..fe436b6 100644 --- a/engine.go +++ b/engine.go @@ -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,8 +270,9 @@ 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 @@ -299,7 +303,7 @@ 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 @@ -310,8 +314,8 @@ func (e *Engine) InspectPrompt( // resource without backend completion, execution defaults, or request overrides. // // APIKeyEnv in the returned resource 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 +// 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. // @@ -335,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. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 239db47..1819b94 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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) { diff --git a/resource_inspection_api_test.go b/resource_inspection_api_test.go new file mode 100644 index 0000000..8203292 --- /dev/null +++ b/resource_inspection_api_test.go @@ -0,0 +1,45 @@ +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) + } +}