Expose profile inspection through the engine
This commit is contained in:
11
convert.go
11
convert.go
@@ -170,6 +170,17 @@ func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fromDomainProfileInspection(inspection *domain.ProfileInspection) *ProfileInspection {
|
||||||
|
if inspection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ProfileInspection{
|
||||||
|
ProfileID: inspection.ProfileID,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(inspection.EffectiveModelParams),
|
||||||
|
APIKeyRequired: inspection.APIKeyRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
||||||
return ExecutionTargetPresence{
|
return ExecutionTargetPresence{
|
||||||
Temperature: presence.Temperature,
|
Temperature: presence.Temperature,
|
||||||
|
|||||||
38
doc.go
38
doc.go
@@ -3,25 +3,27 @@
|
|||||||
//
|
//
|
||||||
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
||||||
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
||||||
// call [Engine.Prepare], [Engine.PrepareExecution], [Engine.Run], or
|
// call [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||||
// [Engine.RunPrepared]. Concrete registries, repositories, validators, and the
|
// [Engine.Run], or [Engine.RunPrepared]. Concrete registries, repositories,
|
||||||
// built-in OpenAI-compatible client remain internal implementation details.
|
// validators, and the built-in OpenAI-compatible client remain internal
|
||||||
|
// implementation details.
|
||||||
//
|
//
|
||||||
// # Concurrency and ownership
|
// # Concurrency and ownership
|
||||||
//
|
//
|
||||||
// An Engine supports concurrent Prepare, PrepareExecution, Run, and RunPrepared
|
// An Engine supports concurrent InspectProfile, Prepare, PrepareExecution, Run,
|
||||||
// calls. Engine-local backend policies bound admitted Run and RunPrepared calls
|
// and RunPrepared calls. Engine-local backend policies bound admitted Run and
|
||||||
// and model generations where configured, while different backend pools and
|
// RunPrepared calls and model generations where configured, while different
|
||||||
// unlimited backends continue independently. An injected [LLMClient] or
|
// backend pools and unlimited backends continue independently. An injected
|
||||||
// [ArtifactReader] can therefore still receive concurrent calls and must be
|
// [LLMClient] or [ArtifactReader] can therefore still receive concurrent calls
|
||||||
// safe for that use.
|
// and must be safe for that use.
|
||||||
//
|
//
|
||||||
// NewEngine copies in-memory profiles and backend definitions. Prepare,
|
// NewEngine copies in-memory profiles and backend definitions. Prepare,
|
||||||
// PrepareExecution, and Run copy request maps, slices, pointer values, and
|
// PrepareExecution, and Run copy request maps, slices, pointer values, and
|
||||||
// JSON-compatible extra parameters before using them. Returned values and
|
// JSON-compatible extra parameters before using them. InspectProfile returns
|
||||||
// values passed to extension interfaces are likewise isolated from engine
|
// copied profile inspection values. Returned values and values passed to
|
||||||
// state. Callers own those copies and may mutate them after the call that
|
// extension interfaces are likewise isolated from engine state. Callers own
|
||||||
// supplied or returned them.
|
// those copies and may mutate them after the call that supplied or returned
|
||||||
|
// them.
|
||||||
//
|
//
|
||||||
// # Security and sensitive data
|
// # Security and sensitive data
|
||||||
//
|
//
|
||||||
@@ -47,11 +49,11 @@
|
|||||||
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||||
// used by those values.
|
// used by those values.
|
||||||
//
|
//
|
||||||
// Construction and handle values, including [Config], [Backend], [RunRequest],
|
// Construction, inspection, and handle values, including [Config], [Backend],
|
||||||
// [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
// [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
// [OpenAICompatibleProfileConfig], and [PreparedExecution], do not have stable
|
// [OpenAICompatibleProfileConfig], [ProfileInspection], and
|
||||||
// JSON representations. Direct API keys are nevertheless excluded from JSON
|
// [PreparedExecution], do not have stable JSON representations. Direct API
|
||||||
// for every public value.
|
// keys are nevertheless excluded from JSON for every public value.
|
||||||
//
|
//
|
||||||
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
||||||
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
||||||
|
|||||||
54
engine.go
54
engine.go
@@ -78,14 +78,14 @@ var (
|
|||||||
ErrValidation = errors.New("failed to validate output")
|
ErrValidation = errors.New("failed to validate output")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine prepares and runs Promptkit prompt requests.
|
// Engine inspects profiles and prepares and runs Promptkit prompt requests.
|
||||||
//
|
//
|
||||||
// An Engine is safe for concurrent calls to [Engine.Prepare],
|
// An Engine is safe for concurrent calls to [Engine.InspectProfile],
|
||||||
// [Engine.PrepareExecution], [Engine.Run], and [Engine.RunPrepared]. Each
|
// [Engine.Prepare], [Engine.PrepareExecution], [Engine.Run], and
|
||||||
// Engine owns independent backend-capacity pools that coordinate Run and
|
// [Engine.RunPrepared]. Each Engine owns independent backend-capacity pools
|
||||||
// RunPrepared admission and model generation. Injected collaborators may still
|
// that coordinate Run and RunPrepared admission and model generation. Injected
|
||||||
// be invoked concurrently across different backend pools or for unlimited
|
// collaborators may still be invoked concurrently across different backend
|
||||||
// backends.
|
// pools or for unlimited backends.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
runner *usecase.Runner
|
runner *usecase.Runner
|
||||||
}
|
}
|
||||||
@@ -428,6 +428,46 @@ func fileSource(name string) (fs.FS, string, error) {
|
|||||||
return os.DirFS(dir), filepath.ToSlash(base), nil
|
return os.DirFS(dir), filepath.ToSlash(base), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectProfile resolves one explicit profile without selecting a prompt or
|
||||||
|
// starting execution work.
|
||||||
|
//
|
||||||
|
// InspectProfile trims surrounding whitespace from profileID and looks up the
|
||||||
|
// resulting nonblank ID exactly and case-sensitively through the engine's
|
||||||
|
// ordinary in-memory, configured-source, and built-in profile precedence. It
|
||||||
|
// applies framework defaults, the selected backend, and then the selected
|
||||||
|
// profile to EffectiveModelParams without a request override. BackendID is
|
||||||
|
// empty for an endpoint-only profile.
|
||||||
|
//
|
||||||
|
// APIKeyEnv in the returned target is an environment-variable name, never its
|
||||||
|
// value. APIKeyRequired instead reports a direct credential requirement and is
|
||||||
|
// mutually exclusive with a nonblank APIKeyEnv. 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.
|
||||||
|
//
|
||||||
|
// The returned ProfileInspection and all nested mutable values are
|
||||||
|
// caller-owned. Filesystem-backed inspection is a point-in-time lookup and
|
||||||
|
// does not freeze the profile for a later execution. This method does not load
|
||||||
|
// a prompt, render, read artifacts or schemas, admit backend capacity, contact
|
||||||
|
// a provider, or generate model output.
|
||||||
|
//
|
||||||
|
// A nil Engine returns an error matching ErrInvalidConfig. A blank profile ID
|
||||||
|
// matches ErrInvalidRequest. An absent exact ID matches ErrProfileNotFound and
|
||||||
|
// not ErrProfileLoad. Malformed or unreadable profile data, an unknown backend,
|
||||||
|
// or an invalid resolved target matches ErrProfileLoad. Cancellation during
|
||||||
|
// profile loading matches ErrProfileLoad while preserving the context error.
|
||||||
|
// InspectProfile returns no partial result on error.
|
||||||
|
func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspection, err := e.runner.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainProfileInspection(inspection), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare resolves and renders a prompt request without calling an LLM.
|
// Prepare resolves and renders a prompt request without calling an LLM.
|
||||||
//
|
//
|
||||||
// Prepare selects the prompt and profile, resolves any selected backend and
|
// Prepare selects the prompt and profile, resolves any selected backend and
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -16,6 +17,163 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type inspectionCountingFS struct {
|
||||||
|
opens atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *inspectionCountingFS) Open(string) (fs.File, error) {
|
||||||
|
f.opens.Add(1)
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectProfileResolvesCredentialStatesWithoutPromptOrGeneration(t *testing.T) {
|
||||||
|
const environmentName = "PROMPTKIT_INSPECTION_ABSENT_KEY"
|
||||||
|
t.Setenv(environmentName, "")
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "unexpected"}}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(fstest.MapFS{}, "."),
|
||||||
|
promptkit.WithProfileFS(fstest.MapFS{
|
||||||
|
"environment.yaml": &fstest.MapFile{Data: []byte(`id: environment
|
||||||
|
endpoint: http://environment.example/v1
|
||||||
|
model: environment-model
|
||||||
|
api_key_env: PROMPTKIT_INSPECTION_ABSENT_KEY
|
||||||
|
`)},
|
||||||
|
}, "."),
|
||||||
|
promptkit.WithProfiles(
|
||||||
|
promptkit.Profile{ID: "direct", Endpoint: "http://direct.example/v1", Model: "direct-model", APIKeyRequired: true},
|
||||||
|
promptkit.Profile{ID: "none", Endpoint: "http://none.example/v1", Model: "none-model"},
|
||||||
|
),
|
||||||
|
promptkit.WithLLMClient(client),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct inspection engine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
profileID string
|
||||||
|
wantEnv string
|
||||||
|
wantDirectKey bool
|
||||||
|
wantEndpoint string
|
||||||
|
}{
|
||||||
|
{profileID: " environment ", wantEnv: environmentName, wantEndpoint: "http://environment.example/v1"},
|
||||||
|
{profileID: "direct", wantDirectKey: true, wantEndpoint: "http://direct.example/v1"},
|
||||||
|
{profileID: "none", wantEndpoint: "http://none.example/v1"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.profileID, func(t *testing.T) {
|
||||||
|
inspection, err := engine.InspectProfile(context.Background(), tc.profileID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect profile: %v", err)
|
||||||
|
}
|
||||||
|
if inspection.ProfileID != strings.TrimSpace(tc.profileID) ||
|
||||||
|
inspection.EffectiveModelParams.Endpoint != tc.wantEndpoint ||
|
||||||
|
inspection.EffectiveModelParams.BackendID != "" ||
|
||||||
|
inspection.EffectiveModelParams.APIKeyEnv != tc.wantEnv ||
|
||||||
|
inspection.APIKeyRequired != tc.wantDirectKey {
|
||||||
|
t.Fatalf("unexpected inspection: %#v", inspection)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(client.requests) != 0 {
|
||||||
|
t.Fatalf("inspection invoked the model client %d times", len(client.requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectProfilePreservesPublicErrorIdentities(t *testing.T) {
|
||||||
|
newEngine := func(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
|
||||||
|
t.Helper()
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
append([]promptkit.Option{promptkit.WithPromptFS(fstest.MapFS{}, ".")}, options...)...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct inspection engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilEngine *promptkit.Engine
|
||||||
|
if result, err := nilEngine.InspectProfile(context.Background(), "profile"); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("nil engine result=(%#v, %v), want ErrInvalidConfig", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := newEngine(t, promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "profile", Endpoint: "http://profile.example/v1", Model: "model",
|
||||||
|
}))
|
||||||
|
if result, err := valid.InspectProfile(context.Background(), " \t "); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("blank profile result=(%#v, %v), want ErrInvalidRequest", result, err)
|
||||||
|
}
|
||||||
|
if result, err := valid.InspectProfile(context.Background(), "missing"); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrProfileNotFound) || errors.Is(err, promptkit.ErrProfileLoad) {
|
||||||
|
t.Fatalf("missing profile result=(%#v, %v), want only ErrProfileNotFound", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malformed := newEngine(t, promptkit.WithProfileFS(fstest.MapFS{
|
||||||
|
"broken.yaml": &fstest.MapFile{Data: []byte("id: broken\nendpoint: http://broken.example/v1\nmodel: model\nunknown: value\n")},
|
||||||
|
}, "."))
|
||||||
|
if result, err := malformed.InspectProfile(context.Background(), "broken"); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrProfileLoad) {
|
||||||
|
t.Fatalf("malformed profile result=(%#v, %v), want ErrProfileLoad", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknownBackend := newEngine(t, promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "unknown-backend", BackendID: "unknown", Model: "model",
|
||||||
|
}))
|
||||||
|
if result, err := unknownBackend.InspectProfile(context.Background(), "unknown-backend"); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrProfileLoad) {
|
||||||
|
t.Fatalf("unknown backend result=(%#v, %v), want ErrProfileLoad", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
countingFS := &inspectionCountingFS{}
|
||||||
|
canceled := newEngine(t, promptkit.WithProfileFS(countingFS, "."))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if result, err := canceled.InspectProfile(ctx, "profile"); result != nil ||
|
||||||
|
!errors.Is(err, promptkit.ErrProfileLoad) || !errors.Is(err, context.Canceled) || countingFS.opens.Load() != 0 {
|
||||||
|
t.Fatalf("canceled inspection result=(%#v, %v), opens=%d", result, err, countingFS.opens.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectProfileReturnsIndependentTargetMatchingPreparation(t *testing.T) {
|
||||||
|
extraParams := map[string]any{
|
||||||
|
"nested": map[string]any{"value": "original"},
|
||||||
|
}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "profile", Endpoint: "http://profile.example/v1", Model: "model", ExtraParams: extraParams,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct inspection engine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := engine.InspectProfile(context.Background(), "profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first inspection: %v", err)
|
||||||
|
}
|
||||||
|
first.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||||
|
first.EffectiveModelParams.ExtraParams["later"] = true
|
||||||
|
|
||||||
|
second, err := engine.InspectProfile(context.Background(), "profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second inspection: %v", err)
|
||||||
|
}
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare after inspection mutation: %v", err)
|
||||||
|
}
|
||||||
|
for _, target := range []promptkit.ExecutionTarget{second.EffectiveModelParams, prepared.EffectiveModelParams} {
|
||||||
|
if target.ExtraParams["nested"].(map[string]any)["value"] != "original" || target.ExtraParams["later"] != nil {
|
||||||
|
t.Fatalf("inspection mutation reached engine-owned target: %#v", target.ExtraParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(second.EffectiveModelParams, prepared.EffectiveModelParams) {
|
||||||
|
t.Fatalf("inspection target=%#v, preparation target=%#v", second.EffectiveModelParams, prepared.EffectiveModelParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreparedRunJSONOmitsZeroTimingValues(t *testing.T) {
|
func TestPreparedRunJSONOmitsZeroTimingValues(t *testing.T) {
|
||||||
payload, err := json.Marshal(promptkit.PreparedRun{})
|
payload, err := json.Marshal(promptkit.PreparedRun{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
19
types.go
19
types.go
@@ -320,6 +320,25 @@ type ExecutionTarget struct {
|
|||||||
ExtraParams map[string]any `json:"extra_params"`
|
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 trimmed, exact profile ID inspected by the engine.
|
||||||
|
ProfileID string
|
||||||
|
// EffectiveModelParams contains framework defaults overlaid by the selected
|
||||||
|
// backend and then the profile, without a request override. APIKeyEnv is an
|
||||||
|
// environment-variable name, never its credential value.
|
||||||
|
EffectiveModelParams ExecutionTarget
|
||||||
|
// APIKeyRequired reports that a later execution must supply a direct API
|
||||||
|
// key or an explicit request environment override. It is mutually exclusive
|
||||||
|
// with a nonblank EffectiveModelParams.APIKeyEnv.
|
||||||
|
APIKeyRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionTargetOverride represents per-request runtime setting overrides and
|
// ExecutionTargetOverride represents per-request runtime setting overrides and
|
||||||
// has no stable JSON representation.
|
// has no stable JSON representation.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user