Compare commits
53 Commits
e81bd80031
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bd6cffc9d0 | |||
| e40c4f182b | |||
| 7428e50c2c | |||
| 63c67a4520 | |||
| 25a7052a3d | |||
| fc3255967e | |||
| e920168b30 | |||
| 272b6a4bc1 | |||
| dde48a31fc | |||
| 242eace4a7 | |||
| 0bf5f88136 | |||
| 369ab5392d | |||
| 2ba0146e5d | |||
| 6112c2af0c | |||
| f5e12c00f5 | |||
| 49fe402dd2 | |||
| c301eb8d55 | |||
| c13e9710d9 | |||
| 87b5ec3d75 | |||
| cb4028a637 | |||
| 5a1bff4529 | |||
| 805a7f965d | |||
| 147f5e5ff5 | |||
| e361c97bb5 | |||
| be67707582 | |||
| e61ab700c7 | |||
| d2c4051dd0 | |||
| 861da355d8 | |||
| a752f88166 | |||
| 238fa90bfa | |||
| ffe6d261a9 | |||
| dc39562ff7 | |||
| 0a839aa16d | |||
| f6ee18f6b3 | |||
| eb8ab215e8 | |||
| f89cb94ed2 | |||
| 359b7313f4 | |||
| ae210b3c26 | |||
| 810f80e7c9 | |||
| 8d00354c59 | |||
| d0010689f3 | |||
| b462153483 | |||
| 086cf0fc86 | |||
| c1cecb1ee8 | |||
| bcb327f643 | |||
| 9e68a2bbf7 | |||
| e4899fb54d | |||
| 18b12a25c1 | |||
| 7e94ab133b | |||
| 62b26fb29e | |||
| ebc1f3e919 | |||
| ad1f2674ab | |||
| 0d56d986af |
44
README.md
44
README.md
@@ -1,18 +1,50 @@
|
|||||||
# Promptkit
|
# Promptkit
|
||||||
|
|
||||||
Promptkit is the reusable Go prompt-execution framework being separated from
|
Promptkit is a reusable Go library for preparing and executing prompt-defined
|
||||||
Scriptorium. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
gitea.maximumdirect.net/eric/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository currently provides the independent Go module and its root public
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
package boundary. Framework behavior and consumer APIs have not yet been
|
can configure filesystem or in-memory prompt, profile, and schema sources,
|
||||||
extracted, so there is no installation or usage example at this time.
|
prepare requests without generation, run requests with the built-in
|
||||||
|
OpenAI-compatible client, or inject their own model client and artifact reader.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Run the maintained offline preparation example from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
It loads a repository-local prompt, supplies an in-memory profile and inline
|
||||||
|
input, and prints deterministic preparation metadata without contacting a
|
||||||
|
provider or requiring credentials. Read the
|
||||||
|
[example source](examples/go-library/prepare/main.go), the
|
||||||
|
[Go package consumer guide](docs/consumers/pkg-promptkit.md), and the
|
||||||
|
[framework format reference](docs/formats.md) to build a consumer workflow.
|
||||||
|
|
||||||
Contributors should start with the [development guide](docs/development.md).
|
Contributors should start with the [development guide](docs/development.md).
|
||||||
The [architecture policy](docs/policy/architecture.md) defines the library
|
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||||
boundary and constraints that future framework work must preserve.
|
boundary and constraints that framework work must preserve.
|
||||||
|
|
||||||
|
## Release Guidance
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.3.0` to `v0.4.0` should read the
|
||||||
|
[v0.4.0 changelog and adoption guide](docs/releases/v0.4.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.2.0` to `v0.3.0` should read the
|
||||||
|
[v0.3.0 changelog](docs/releases/v0.3.0.md).
|
||||||
|
|
||||||
|
Consumers moving from `v0.1.0` to `v0.2.0` should read the
|
||||||
|
[v0.2.0 changelog and migration guide](docs/releases/v0.2.0.md).
|
||||||
|
|
||||||
|
## Related Project
|
||||||
|
|
||||||
|
[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and
|
||||||
|
HTTP application built on Promptkit.
|
||||||
|
|
||||||
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
||||||
|
|||||||
89
architecture_test.go
Normal file
89
architecture_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const formerModulePath = "gitea.maximumdirect.net/eric/" + "scrip" + "torium"
|
||||||
|
|
||||||
|
func TestRepositoryDoesNotImportFormerModule(t *testing.T) {
|
||||||
|
violations, err := findFormerModuleImports(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect repository imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
t.Fatalf("repository imports the former module:\n%s", strings.Join(violations, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormerModuleGuardFindsNestedImport(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
nested := filepath.Join(root, "nested", "package")
|
||||||
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||||
|
t.Fatalf("create nested package: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(nested, "violation.go")
|
||||||
|
source := "package nested\n\nimport _ " + strconv.Quote(formerModulePath+"/internal/domain") + "\n"
|
||||||
|
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
|
||||||
|
t.Fatalf("write nested source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
violations, err := findFormerModuleImports(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect nested imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("violations = %v, want one nested import", violations)
|
||||||
|
}
|
||||||
|
if !strings.Contains(violations[0], "violation.go") ||
|
||||||
|
!strings.Contains(violations[0], formerModulePath+"/internal/domain") {
|
||||||
|
t.Fatalf("violation = %q, want file and import path", violations[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFormerModuleImports(root string) ([]string, error) {
|
||||||
|
var violations []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
switch entry.Name() {
|
||||||
|
case ".git", "generated", "vendor":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".go" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly|parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ast.IsGenerated(file) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, spec := range file.Imports {
|
||||||
|
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if importPath == formerModulePath || strings.HasPrefix(importPath, formerModulePath+"/") {
|
||||||
|
violations = append(violations, path+": "+importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return violations, err
|
||||||
|
}
|
||||||
39
artifact_reader.go
Normal file
39
artifact_reader.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|
||||||
|
type publicArtifactReaderAdapter struct {
|
||||||
|
reader ArtifactReader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ artifactadapter.Reader = publicArtifactReaderAdapter{}
|
||||||
|
|
||||||
|
func (a publicArtifactReaderAdapter) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
artifact, err := a.reader.Read(ctx, ArtifactRef{
|
||||||
|
Type: ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if artifact == nil {
|
||||||
|
return nil, errNilArtifactReaderResponse
|
||||||
|
}
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
36
artifact_reader_internal_test.go
Normal file
36
artifact_reader_internal_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
reader := internalArtifactReaderFake{
|
||||||
|
artifact: &Artifact{Body: []byte("original")},
|
||||||
|
}
|
||||||
|
adapter := publicArtifactReaderAdapter{reader: &reader}
|
||||||
|
|
||||||
|
artifact, err := adapter.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
URI: "memory://input",
|
||||||
|
Body: "input",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact: %v", err)
|
||||||
|
}
|
||||||
|
artifact.Body[0] = 'X'
|
||||||
|
if got := string(reader.artifact.Body); got != "original" {
|
||||||
|
t.Fatalf("reader artifact body was mutated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalArtifactReaderFake struct {
|
||||||
|
artifact *Artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *internalArtifactReaderFake) Read(context.Context, ArtifactRef) (*Artifact, error) {
|
||||||
|
return r.artifact, nil
|
||||||
|
}
|
||||||
99
backends.go
Normal file
99
backends.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendOpenRouter is the reserved ID of Promptkit's built-in OpenRouter
|
||||||
|
// backend.
|
||||||
|
const BackendOpenRouter = backend.OpenRouterID
|
||||||
|
|
||||||
|
// BackendLocal is the case-sensitive conventional ID used by [LocalBackend].
|
||||||
|
// It is not a built-in or reserved backend and must be registered with
|
||||||
|
// [WithBackend].
|
||||||
|
const BackendLocal = "local"
|
||||||
|
|
||||||
|
// Backend configures one engine-scoped OpenAI-compatible backend.
|
||||||
|
//
|
||||||
|
// Backend has no stable JSON representation. Use keyed literals so additions
|
||||||
|
// to this configuration value do not break source compatibility.
|
||||||
|
type Backend struct {
|
||||||
|
// ID is the stable, case-sensitive registry key. NewEngine trims it and
|
||||||
|
// requires a non-blank value. BackendOpenRouter is reserved.
|
||||||
|
ID string
|
||||||
|
// Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and
|
||||||
|
// requires an absolute HTTP or HTTPS URL with a host and without user
|
||||||
|
// information, a query string, or a fragment. Paths are allowed.
|
||||||
|
Endpoint string
|
||||||
|
// APIKeyEnv optionally names the environment variable containing the API
|
||||||
|
// key. NewEngine trims it and requires the portable form
|
||||||
|
// [A-Za-z_][A-Za-z0-9_]*. Store only the name, never a credential value.
|
||||||
|
APIKeyEnv string
|
||||||
|
// ExtraParams contains backend-wide request defaults. Values must be
|
||||||
|
// JSON-compatible, finite, acyclic, and keyed by non-empty strings. Keys
|
||||||
|
// must not be model, session_id, messages, temperature, max_tokens, top_p,
|
||||||
|
// service_tier, reasoning_effort, or response_format. An empty map supplies
|
||||||
|
// no defaults. NewEngine deeply copies the map.
|
||||||
|
ExtraParams map[string]any
|
||||||
|
// ConcurrencyLimit is the maximum number of simultaneous model-generation
|
||||||
|
// calls allowed for this backend within one Engine. Zero leaves the backend
|
||||||
|
// unlimited. A negative value makes NewEngine fail with ErrInvalidConfig.
|
||||||
|
ConcurrencyLimit int
|
||||||
|
// QueueCapacity controls how many additional Run or RunPrepared calls may
|
||||||
|
// be admitted beyond ConcurrencyLimit. Nil uses 1024 when ConcurrencyLimit
|
||||||
|
// is positive; a pointer uses its exact value, including zero. The pointed-to
|
||||||
|
// value must be non-negative, and QueueCapacity must be nil when
|
||||||
|
// ConcurrencyLimit is zero. Their sum must fit in an int. WithBackend copies
|
||||||
|
// the value and does not retain the pointer.
|
||||||
|
QueueCapacity *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalBackend returns a caller-owned Backend for a conventional local
|
||||||
|
// OpenAI-compatible endpoint. It sets ID to BackendLocal and copies endpoint
|
||||||
|
// and concurrencyLimit into Endpoint and ConcurrencyLimit without
|
||||||
|
// normalization or validation. APIKeyEnv, ExtraParams, and QueueCapacity keep
|
||||||
|
// their zero values.
|
||||||
|
//
|
||||||
|
// LocalBackend does not read environment variables, register the value, or
|
||||||
|
// mutate engine or package state. Supply the returned value through
|
||||||
|
// [WithBackend]; [NewEngine] then applies the ordinary backend validation and
|
||||||
|
// concurrency semantics, including default queue capacity for a positive
|
||||||
|
// limit, unlimited behavior for zero, and ErrInvalidConfig for a negative
|
||||||
|
// limit.
|
||||||
|
func LocalBackend(endpoint string, concurrencyLimit int) Backend {
|
||||||
|
return Backend{
|
||||||
|
ID: BackendLocal,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
ConcurrencyLimit: concurrencyLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBackend adds one Backend registration to the constructed Engine.
|
||||||
|
//
|
||||||
|
// Registrations accumulate in option order. Every normalized ID must be unique
|
||||||
|
// across consumer registrations and built-ins; a duplicate or invalid
|
||||||
|
// definition makes NewEngine fail with ErrInvalidConfig. In particular,
|
||||||
|
// BackendOpenRouter cannot be replaced. The immutable registration is scoped
|
||||||
|
// to the resulting Engine and cannot be enumerated, replaced, removed, or
|
||||||
|
// mutated after construction. WithBackend does not install package-global
|
||||||
|
// state.
|
||||||
|
func WithBackend(backend Backend) Option {
|
||||||
|
queueCapacity := 0
|
||||||
|
queueCapacitySet := backend.QueueCapacity != nil
|
||||||
|
if queueCapacitySet {
|
||||||
|
queueCapacity = *backend.QueueCapacity
|
||||||
|
}
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
options.backends = append(options.backends, domain.Backend{
|
||||||
|
ID: backend.ID,
|
||||||
|
Endpoint: backend.Endpoint,
|
||||||
|
APIKeyEnv: backend.APIKeyEnv,
|
||||||
|
ExtraParams: backend.ExtraParams,
|
||||||
|
ConcurrencyLimit: backend.ConcurrencyLimit,
|
||||||
|
QueueCapacity: queueCapacity,
|
||||||
|
QueueCapacitySet: queueCapacitySet,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
409
capacity_contract_test.go
Normal file
409
capacity_contract_test.go
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEngineLimitsInjectedClientConcurrency(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 8)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 2, capacityInt(4), nil)
|
||||||
|
|
||||||
|
results := make(chan capacityRunResult, 6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
go runCapacityRequest(engine, context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "prompt",
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{
|
||||||
|
Endpoint: "http://request.example/v1",
|
||||||
|
},
|
||||||
|
}, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := awaitCapacityRequest(t, client.started)
|
||||||
|
second := awaitCapacityRequest(t, client.started)
|
||||||
|
if first.Target.BackendID != "limited" || second.Target.BackendID != "limited" {
|
||||||
|
t.Fatalf("endpoint override changed backend pool: first=%q second=%q",
|
||||||
|
first.Target.BackendID, second.Target.BackendID)
|
||||||
|
}
|
||||||
|
if active, peak, _ := client.snapshot(); active != 2 || peak != 2 {
|
||||||
|
t.Fatalf("client concurrency before release=(active=%d peak=%d), want 2", active, peak)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
outcome := awaitCapacityRun(t, results)
|
||||||
|
if outcome.err != nil || outcome.result == nil {
|
||||||
|
t.Fatalf("run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, peak, calls := client.snapshot(); peak > 2 || calls != 6 {
|
||||||
|
t.Fatalf("client observations=(peak=%d calls=%d), want peak <= 2 and 6 calls", peak, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) {
|
||||||
|
artifactRelease := make(chan struct{})
|
||||||
|
reader := &capacityArtifactReader{
|
||||||
|
entered: make(chan struct{}, 2),
|
||||||
|
release: artifactRelease,
|
||||||
|
}
|
||||||
|
client := newCapacityGateClient(closedCapacityChannel(), 2)
|
||||||
|
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), reader)
|
||||||
|
firstResult := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(engine, context.Background(), capacityInputRequest("http://first.example/v1"), firstResult)
|
||||||
|
|
||||||
|
awaitCapacitySignal(t, reader.entered, "first artifact read")
|
||||||
|
|
||||||
|
canceledContext, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
result, err := engine.Run(canceledContext, capacityInputRequest("http://canceled.example/v1"))
|
||||||
|
if result != nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled capacity admission=(%+v, %v), want context cancellation", result, err)
|
||||||
|
}
|
||||||
|
var canceledCapacityErr *promptkit.CapacityError
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) || errors.As(err, &canceledCapacityErr) {
|
||||||
|
t.Fatalf("canceled admission exposed capacity rejection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://second.example/v1"))
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("capacity rejection returned partial result: %+v", result)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("capacity rejection=%v, want ErrCapacityExceeded", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("capacity rejection had an unrelated category: %v", err)
|
||||||
|
}
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if !errors.As(err, &capacityErr) || capacityErr == nil {
|
||||||
|
t.Fatalf("capacity rejection=%v, want CapacityError", err)
|
||||||
|
}
|
||||||
|
if capacityErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("capacity backend ID=%q, want limited", capacityErr.BackendID)
|
||||||
|
}
|
||||||
|
capacityErr.BackendID = "changed"
|
||||||
|
|
||||||
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://third.example/v1"))
|
||||||
|
var subsequentCapacityErr *promptkit.CapacityError
|
||||||
|
if result != nil || !errors.As(err, &subsequentCapacityErr) ||
|
||||||
|
subsequentCapacityErr == nil || subsequentCapacityErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("subsequent capacity rejection=(%+v, %v), want independent limited CapacityError", result, err)
|
||||||
|
}
|
||||||
|
if calls := reader.callCount(); calls != 1 {
|
||||||
|
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
||||||
|
}
|
||||||
|
if _, _, calls := client.snapshot(); calls != 0 {
|
||||||
|
t.Fatalf("client calls=%d before admitted run was released, want 0", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(artifactRelease)
|
||||||
|
outcome := awaitCapacityRun(t, firstResult)
|
||||||
|
if outcome.err != nil || outcome.result == nil {
|
||||||
|
t.Fatalf("first run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendCapacityIsIndependentBetweenEngines(t *testing.T) {
|
||||||
|
firstRelease := make(chan struct{})
|
||||||
|
firstClient := newCapacityGateClient(firstRelease, 1)
|
||||||
|
firstEngine := newBackendCapacityEngine(t, firstClient, 1, capacityInt(0), nil)
|
||||||
|
secondClient := newCapacityGateClient(closedCapacityChannel(), 1)
|
||||||
|
secondEngine := newBackendCapacityEngine(t, secondClient, 1, capacityInt(0), nil)
|
||||||
|
|
||||||
|
firstResult := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(firstEngine, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, firstResult)
|
||||||
|
awaitCapacityRequest(t, firstClient.started)
|
||||||
|
|
||||||
|
result, err := secondEngine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
||||||
|
if err != nil || result == nil {
|
||||||
|
t.Fatalf("second engine run=(%+v, %v), want independent success", result, err)
|
||||||
|
}
|
||||||
|
if _, _, calls := secondClient.snapshot(); calls != 1 {
|
||||||
|
t.Fatalf("second engine client calls=%d, want 1", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(firstRelease)
|
||||||
|
outcome := awaitCapacityRun(t, firstResult)
|
||||||
|
if outcome.err != nil || outcome.result == nil {
|
||||||
|
t.Fatalf("first engine run=(%+v, %v), want success", outcome.result, outcome.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlimitedBackendsRetainInjectedClientConcurrency(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*testing.T, promptkit.LLMClient) *promptkit.Engine
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "custom backend",
|
||||||
|
configure: func(t *testing.T, client promptkit.LLMClient) *promptkit.Engine {
|
||||||
|
return newBackendCapacityEngine(t, client, 0, nil, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "endpoint-only profile",
|
||||||
|
configure: func(t *testing.T, client promptkit.LLMClient) *promptkit.Engine {
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "profile", Endpoint: "http://endpoint.example/v1", Model: "model",
|
||||||
|
}),
|
||||||
|
promptkit.WithLLMClient(client),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct endpoint-only engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 2)
|
||||||
|
engine := tc.configure(t, client)
|
||||||
|
results := make(chan capacityRunResult, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
go runCapacityRequest(
|
||||||
|
engine,
|
||||||
|
context.Background(),
|
||||||
|
promptkit.RunRequest{PromptID: "prompt"},
|
||||||
|
results,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitCapacityRequest(t, client.started)
|
||||||
|
awaitCapacityRequest(t, client.started)
|
||||||
|
if active, peak, _ := client.snapshot(); active != 2 || peak != 2 {
|
||||||
|
t.Fatalf("unlimited concurrency=(active=%d peak=%d), want 2", active, peak)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
outcome := awaitCapacityRun(t, results)
|
||||||
|
if outcome.err != nil || outcome.result == nil {
|
||||||
|
t.Fatalf("run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapacityExceededSentinelContract(t *testing.T) {
|
||||||
|
if promptkit.ErrCapacityExceeded == nil {
|
||||||
|
t.Fatal("ErrCapacityExceeded is nil")
|
||||||
|
}
|
||||||
|
var nilCapacityErr *promptkit.CapacityError
|
||||||
|
zeroCapacityErr := &promptkit.CapacityError{}
|
||||||
|
populatedCapacityErr := &promptkit.CapacityError{BackendID: "limited"}
|
||||||
|
for _, capacityErr := range []error{nilCapacityErr, zeroCapacityErr} {
|
||||||
|
if !errors.Is(capacityErr, promptkit.ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("capacity error=%v, want ErrCapacityExceeded", capacityErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var discoveredCapacityErr *promptkit.CapacityError
|
||||||
|
if !errors.As(populatedCapacityErr, &discoveredCapacityErr) || discoveredCapacityErr != populatedCapacityErr {
|
||||||
|
t.Fatalf("populated capacity error is not discoverable: %v", populatedCapacityErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, unrelated := range []error{
|
||||||
|
promptkit.ErrInvalidConfig,
|
||||||
|
promptkit.ErrInvalidRequest,
|
||||||
|
promptkit.ErrLLMGenerate,
|
||||||
|
promptkit.ErrValidation,
|
||||||
|
} {
|
||||||
|
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
||||||
|
errors.Is(unrelated, promptkit.ErrCapacityExceeded) ||
|
||||||
|
errors.Is(populatedCapacityErr, unrelated) {
|
||||||
|
t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type capacityRunResult struct {
|
||||||
|
result *promptkit.RunResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCapacityRequest(
|
||||||
|
engine *promptkit.Engine,
|
||||||
|
ctx context.Context,
|
||||||
|
request promptkit.RunRequest,
|
||||||
|
results chan<- capacityRunResult,
|
||||||
|
) {
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
results <- capacityRunResult{result: result, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBackendCapacityEngine(
|
||||||
|
t *testing.T,
|
||||||
|
client promptkit.LLMClient,
|
||||||
|
limit int,
|
||||||
|
queueCapacity *int,
|
||||||
|
reader promptkit.ArtifactReader,
|
||||||
|
) *promptkit.Engine {
|
||||||
|
t.Helper()
|
||||||
|
promptFS := contractPromptFS("prompt", "profile", "message")
|
||||||
|
if reader != nil {
|
||||||
|
promptFS = contractInputPromptFS()
|
||||||
|
}
|
||||||
|
options := []promptkit.Option{
|
||||||
|
promptkit.WithPromptFS(promptFS, "."),
|
||||||
|
promptkit.WithBackend(promptkit.Backend{
|
||||||
|
ID: "limited",
|
||||||
|
Endpoint: "http://backend.example/v1",
|
||||||
|
ConcurrencyLimit: limit,
|
||||||
|
QueueCapacity: queueCapacity,
|
||||||
|
}),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "profile", BackendID: "limited", Model: "model",
|
||||||
|
}),
|
||||||
|
promptkit.WithLLMClient(client),
|
||||||
|
}
|
||||||
|
if reader != nil {
|
||||||
|
options = append(options, promptkit.WithArtifactReader(reader))
|
||||||
|
}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{}, options...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct capacity engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func capacityInputRequest(endpoint string) promptkit.RunRequest {
|
||||||
|
return promptkit.RunRequest{
|
||||||
|
PromptID: "input-prompt",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"input": promptkit.Inline("input"),
|
||||||
|
},
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{Endpoint: endpoint},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type capacityGateClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
active int
|
||||||
|
peak int
|
||||||
|
calls int
|
||||||
|
started chan promptkit.GenerateRequest
|
||||||
|
release <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCapacityGateClient(release <-chan struct{}, buffer int) *capacityGateClient {
|
||||||
|
return &capacityGateClient{
|
||||||
|
started: make(chan promptkit.GenerateRequest, buffer),
|
||||||
|
release: release,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capacityGateClient) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
request promptkit.GenerateRequest,
|
||||||
|
) (*promptkit.GenerateResponse, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.calls++
|
||||||
|
c.active++
|
||||||
|
if c.active > c.peak {
|
||||||
|
c.peak = c.active
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.active--
|
||||||
|
c.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.started <- request
|
||||||
|
select {
|
||||||
|
case <-c.release:
|
||||||
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capacityGateClient) snapshot() (active, peak, calls int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.active, c.peak, c.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
type capacityArtifactReader struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
entered chan struct{}
|
||||||
|
release <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *capacityArtifactReader) Read(
|
||||||
|
ctx context.Context,
|
||||||
|
_ promptkit.ArtifactRef,
|
||||||
|
) (*promptkit.Artifact, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.calls++
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.entered <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-r.release:
|
||||||
|
return &promptkit.Artifact{Body: []byte("input")}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *capacityArtifactReader) callCount() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitCapacityRequest(
|
||||||
|
t *testing.T,
|
||||||
|
requests <-chan promptkit.GenerateRequest,
|
||||||
|
) promptkit.GenerateRequest {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case request := <-requests:
|
||||||
|
return request
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for client invocation")
|
||||||
|
return promptkit.GenerateRequest{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitCapacityRun(t *testing.T, results <-chan capacityRunResult) capacityRunResult {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case result := <-results:
|
||||||
|
return result
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for Run")
|
||||||
|
return capacityRunResult{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitCapacitySignal(t *testing.T, signal <-chan struct{}, name string) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-signal:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capacityInt(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func closedCapacityChannel() <-chan struct{} {
|
||||||
|
channel := make(chan struct{})
|
||||||
|
close(channel)
|
||||||
|
return channel
|
||||||
|
}
|
||||||
40
capacity_error.go
Normal file
40
capacity_error.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CapacityError reports bounded admission rejected for a selected backend.
|
||||||
|
//
|
||||||
|
// Engine-produced values identify only rejection at Promptkit's bounded
|
||||||
|
// [Engine.Run] or [Engine.RunPrepared] admission boundary. BackendID is the
|
||||||
|
// normalized registered backend ID used for routing and capacity; endpoint
|
||||||
|
// overrides do not change it. Every engine-produced value is nonnil and has a
|
||||||
|
// nonblank BackendID. Provider errors, active-generation waiting, and caller
|
||||||
|
// cancellation are not represented by this type.
|
||||||
|
//
|
||||||
|
// Callers own returned values and may mutate BackendID without affecting engine
|
||||||
|
// state or another error. CapacityError and its default Go encoding have no
|
||||||
|
// stable JSON contract. Consumer-constructed values do not establish that an
|
||||||
|
// engine rejected work.
|
||||||
|
type CapacityError struct {
|
||||||
|
// BackendID is the normalized registered backend ID whose admission was
|
||||||
|
// rejected.
|
||||||
|
BackendID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns diagnostic wording that is not a parsing contract. It is safe
|
||||||
|
// to call on a nil receiver or a value with a blank BackendID.
|
||||||
|
func (e *CapacityError) Error() string {
|
||||||
|
if e == nil || strings.TrimSpace(e.BackendID) == "" {
|
||||||
|
return ErrCapacityExceeded.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("backend %q admission: %v", e.BackendID, ErrCapacityExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns ErrCapacityExceeded so errors.Is and errors.As can be used
|
||||||
|
// together. It is safe to call on a nil receiver or a zero value.
|
||||||
|
func (e *CapacityError) Unwrap() error {
|
||||||
|
return ErrCapacityExceeded
|
||||||
|
}
|
||||||
453
convert.go
Normal file
453
convert.go
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
|
execution, err := toDomainExecutionTargetOverride(req.Execution)
|
||||||
|
if err != nil {
|
||||||
|
return domain.RunRequest{}, err
|
||||||
|
}
|
||||||
|
return domain.RunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
PromptVersion: req.PromptVersion,
|
||||||
|
ProfileID: req.ProfileID,
|
||||||
|
SessionID: req.SessionID,
|
||||||
|
APIKey: req.APIKey,
|
||||||
|
Inputs: toDomainArtifactRefMap(req.Inputs),
|
||||||
|
Vars: copyStringMap(req.Vars),
|
||||||
|
Execution: execution,
|
||||||
|
Validation: toDomainOutputContractPtr(req.Validation),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
||||||
|
if prepared == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &PreparedRun{
|
||||||
|
PromptID: prepared.PromptID,
|
||||||
|
PromptVersion: prepared.PromptVersion,
|
||||||
|
PromptHash: prepared.PromptHash,
|
||||||
|
SelectedProfileID: prepared.SelectedProfileID,
|
||||||
|
SelectedBackendID: prepared.SelectedBackendID,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(prepared.EffectiveModelParams),
|
||||||
|
OutputContract: fromDomainOutputContract(prepared.OutputContract),
|
||||||
|
StructuredOutput: fromDomainStructuredOutputSpec(prepared.StructuredOutput),
|
||||||
|
InputHashes: copyStringMap(prepared.InputHashes),
|
||||||
|
SessionID: prepared.SessionID,
|
||||||
|
RenderedPromptHash: prepared.RenderedPromptHash,
|
||||||
|
Messages: fromDomainRenderedMessages(prepared.Messages),
|
||||||
|
StartTime: prepared.StartTime,
|
||||||
|
EndTime: prepared.EndTime,
|
||||||
|
DurationMS: prepared.DurationMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RunResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Artifact: fromDomainArtifact(result.Artifact),
|
||||||
|
RawOutput: result.RawOutput,
|
||||||
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
|
PromptID: result.PromptID,
|
||||||
|
PromptVersion: result.PromptVersion,
|
||||||
|
PromptHash: result.PromptHash,
|
||||||
|
SessionID: result.SessionID,
|
||||||
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
|
SelectedProfileID: result.SelectedProfileID,
|
||||||
|
SelectedBackendID: result.SelectedBackendID,
|
||||||
|
ModelName: result.ModelName,
|
||||||
|
Endpoint: result.Endpoint,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(result.EffectiveModelParams),
|
||||||
|
InputHashes: copyStringMap(result.InputHashes),
|
||||||
|
Usage: fromDomainTokenUsage(result.Usage),
|
||||||
|
StartTime: result.StartTime,
|
||||||
|
EndTime: result.EndTime,
|
||||||
|
Duration: result.Duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainGenerateRequest(req domain.GenerateRequest) GenerateRequest {
|
||||||
|
return GenerateRequest{
|
||||||
|
Prompt: fromDomainRenderedPrompt(req.Prompt),
|
||||||
|
Target: fromDomainExecutionTarget(req.Target),
|
||||||
|
TargetPresence: fromDomainExecutionTargetPresence(req.TargetPresence),
|
||||||
|
StructuredOutput: fromDomainStructuredOutputSpec(req.StructuredOutput),
|
||||||
|
APIKey: req.Target.APIKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: resp.Content,
|
||||||
|
Usage: toDomainTokenUsage(resp.Usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRenderedPrompt(prompt domain.RenderedPrompt) RenderedPrompt {
|
||||||
|
return RenderedPrompt{
|
||||||
|
SessionID: prompt.SessionID,
|
||||||
|
Messages: fromDomainRenderedMessages(prompt.Messages),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainArtifactRefMap(src map[string]ArtifactRef) map[string]domain.ArtifactRef {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]domain.ArtifactRef, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = toDomainArtifactRef(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainArtifactRef(ref ArtifactRef) domain.ArtifactRef {
|
||||||
|
return domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainArtifact(artifact domain.Artifact) Artifact {
|
||||||
|
return Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain.ExecutionTargetOverride, error) {
|
||||||
|
if override == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
extraParams, err := jsonvalue.CopyMap(override.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &domain.ExecutionTargetOverride{
|
||||||
|
Endpoint: override.Endpoint,
|
||||||
|
Model: override.Model,
|
||||||
|
Temperature: copyFloat64Ptr(override.Temperature),
|
||||||
|
MaxTokens: copyIntPtr(override.MaxTokens),
|
||||||
|
TopP: copyFloat64Ptr(override.TopP),
|
||||||
|
TimeoutSeconds: copyIntPtr(override.TimeoutSeconds),
|
||||||
|
ServiceTier: override.ServiceTier,
|
||||||
|
ReasoningEffort: copyStringPtr(override.ReasoningEffort),
|
||||||
|
APIKeyEnv: override.APIKeyEnv,
|
||||||
|
ExtraParams: extraParams,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
|
||||||
|
return ExecutionTarget{
|
||||||
|
BackendID: target.BackendID,
|
||||||
|
Endpoint: target.Endpoint,
|
||||||
|
Model: target.Model,
|
||||||
|
Temperature: target.Temperature,
|
||||||
|
MaxTokens: target.MaxTokens,
|
||||||
|
TopP: target.TopP,
|
||||||
|
TimeoutSeconds: target.TimeoutSeconds,
|
||||||
|
ServiceTier: target.ServiceTier,
|
||||||
|
ReasoningEffort: target.ReasoningEffort,
|
||||||
|
APIKeyEnv: target.APIKeyEnv,
|
||||||
|
ExtraParams: copyAnyMap(target.ExtraParams),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainProfileInspection(inspection *domain.ProfileInspection) *ProfileInspection {
|
||||||
|
if inspection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ProfileInspection{
|
||||||
|
ProfileID: inspection.ProfileID,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(inspection.EffectiveModelParams),
|
||||||
|
APIKeyRequired: inspection.APIKeyRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInspection {
|
||||||
|
if inspection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &PromptInspection{
|
||||||
|
PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion,
|
||||||
|
PromptHash: inspection.PromptHash,
|
||||||
|
DefaultProfileID: inspection.DefaultProfileID,
|
||||||
|
Inputs: inputs,
|
||||||
|
OutputContract: fromDomainOutputContract(inspection.OutputContract),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
||||||
|
return ExecutionTargetPresence{
|
||||||
|
Temperature: presence.Temperature,
|
||||||
|
MaxTokens: presence.MaxTokens,
|
||||||
|
TopP: presence.TopP,
|
||||||
|
TimeoutSeconds: presence.TimeoutSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainOutputContractPtr(contract *OutputContract) *domain.OutputContract {
|
||||||
|
if contract == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := toDomainOutputContract(*contract)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainOutputContract(contract OutputContract) domain.OutputContract {
|
||||||
|
return domain.OutputContract{
|
||||||
|
Format: domain.OutputFormat(contract.Format),
|
||||||
|
ValidationMode: domain.ValidationMode(contract.ValidationMode),
|
||||||
|
SchemaPath: contract.SchemaPath,
|
||||||
|
RepairAttempts: contract.RepairAttempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainOutputContract(contract domain.OutputContract) OutputContract {
|
||||||
|
return OutputContract{
|
||||||
|
Format: OutputFormat(contract.Format),
|
||||||
|
ValidationMode: ValidationMode(contract.ValidationMode),
|
||||||
|
SchemaPath: contract.SchemaPath,
|
||||||
|
RepairAttempts: contract.RepairAttempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainValidationResult(result domain.ValidationResult) ValidationResult {
|
||||||
|
return ValidationResult{
|
||||||
|
Status: ValidationStatus(result.Status),
|
||||||
|
Mode: ValidationMode(result.Mode),
|
||||||
|
Errors: copyStringSlice(result.Errors),
|
||||||
|
SchemaPath: result.SchemaPath,
|
||||||
|
RepairAttempts: result.RepairAttempts,
|
||||||
|
IsValid: result.IsValid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainTokenUsage(usage domain.TokenUsage) TokenUsage {
|
||||||
|
return TokenUsage{
|
||||||
|
PromptTokens: usage.PromptTokens,
|
||||||
|
CompletionTokens: usage.CompletionTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
CachedTokens: usage.CachedTokens,
|
||||||
|
CacheWriteTokens: usage.CacheWriteTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainTokenUsage(usage TokenUsage) domain.TokenUsage {
|
||||||
|
return domain.TokenUsage{
|
||||||
|
PromptTokens: usage.PromptTokens,
|
||||||
|
CompletionTokens: usage.CompletionTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
CachedTokens: usage.CachedTokens,
|
||||||
|
CacheWriteTokens: usage.CacheWriteTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRenderedMessages(messages []domain.RenderedMessage) []RenderedMessage {
|
||||||
|
if messages == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]RenderedMessage, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
out[i] = RenderedMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
CacheControl: fromDomainCacheControl(msg.CacheControl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl {
|
||||||
|
if cacheControl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &CacheControl{
|
||||||
|
Type: CacheControlType(cacheControl.Type),
|
||||||
|
TTL: cacheControl.TTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainStructuredOutputSpec(spec *domain.StructuredOutputSpec) *StructuredOutputSpec {
|
||||||
|
if spec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &StructuredOutputSpec{
|
||||||
|
Type: StructuredOutputType(spec.Type),
|
||||||
|
}
|
||||||
|
if spec.JSONSchema != nil {
|
||||||
|
out.JSONSchema = &StructuredOutputJSONSpec{
|
||||||
|
Name: spec.JSONSchema.Name,
|
||||||
|
Strict: spec.JSONSchema.Strict,
|
||||||
|
Schema: copyAny(spec.JSONSchema.Schema),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(src map[string]string) map[string]string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAnyMap(src map[string]any) map[string]any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = copyAny(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAny(value any) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return copyAnyMap(v)
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
out[i] = copyAny(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
return copyStringSlice(v)
|
||||||
|
case []byte:
|
||||||
|
return copyBytes(v)
|
||||||
|
default:
|
||||||
|
return copyReflectValue(reflect.ValueOf(value)).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyReflectValue(value reflect.Value) reflect.Value {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
copied := copyReflectValue(value.Elem())
|
||||||
|
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
out.Set(copied)
|
||||||
|
return out
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type().Elem())
|
||||||
|
out.Elem().Set(copyReflectValue(value.Elem()))
|
||||||
|
return out
|
||||||
|
case reflect.Map:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Array:
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringSlice(src []string) []string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]byte, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFloat64Ptr(src *float64) *float64 {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *src
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringPtr(src *string) *string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *src
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIntPtr(src *int) *int {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *src
|
||||||
|
return &v
|
||||||
|
}
|
||||||
64
doc.go
64
doc.go
@@ -1,2 +1,64 @@
|
|||||||
// Package promptkit defines the public package boundary for the Promptkit Go module.
|
// Package promptkit provides an embeddable engine for preparing and executing
|
||||||
|
// prompt-defined LLM workflows.
|
||||||
|
//
|
||||||
|
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
||||||
|
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
||||||
|
// call [Engine.InspectPrompt], [Engine.InspectProfile], [Engine.Prepare],
|
||||||
|
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete
|
||||||
|
// registries, repositories, validators, and the built-in OpenAI-compatible
|
||||||
|
// client remain internal implementation details.
|
||||||
|
//
|
||||||
|
// # Concurrency and ownership
|
||||||
|
//
|
||||||
|
// An Engine supports concurrent InspectPrompt, InspectProfile, 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
|
||||||
|
// still receive concurrent calls and must be safe for that use.
|
||||||
|
//
|
||||||
|
// NewEngine copies in-memory profiles and backend definitions. Prepare,
|
||||||
|
// PrepareExecution, and Run copy request maps, slices, pointer values, and
|
||||||
|
// JSON-compatible extra parameters before using them. InspectPrompt and
|
||||||
|
// InspectProfile return copied inspection values. Returned values and values
|
||||||
|
// passed to extension interfaces are likewise isolated from engine state.
|
||||||
|
// Callers own those copies and may mutate them after the call that supplied or
|
||||||
|
// returned them. Returned structured errors are likewise caller-owned and may
|
||||||
|
// be mutated without affecting engine state or another error.
|
||||||
|
//
|
||||||
|
// # Security and sensitive data
|
||||||
|
//
|
||||||
|
// The default artifact reader treats [File] paths as caller-selected operating
|
||||||
|
// system paths. It does not restrict them to an application root or impose an
|
||||||
|
// inbound request-size policy. Promptkit is not an inbound request or
|
||||||
|
// untrusted-input security boundary. Applications must validate and restrict
|
||||||
|
// untrusted input before constructing a request, or install an [ArtifactReader]
|
||||||
|
// that enforces their filesystem, authorization, and size policies.
|
||||||
|
//
|
||||||
|
// Rendered messages, input and output [Artifact] bodies, [RunResult.RawOutput],
|
||||||
|
// and [ValidationResult.Errors] may contain sensitive data. Credential
|
||||||
|
// exclusion and redaction do not sanitize those values. Applications and
|
||||||
|
// injected collaborators are responsible for access control, retention,
|
||||||
|
// logging, and secret handling appropriate to their data.
|
||||||
|
//
|
||||||
|
// # JSON
|
||||||
|
//
|
||||||
|
// Stable JSON representations are provided for [PreparedRun], [RunResult],
|
||||||
|
// [Artifact], [ExecutionTarget], [OutputContract], [ValidationResult],
|
||||||
|
// [TokenUsage], [RenderedPrompt], [RenderedMessage], [CacheControl],
|
||||||
|
// [StructuredOutputSpec], [StructuredOutputJSONSpec], [GenerateRequest],
|
||||||
|
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||||
|
// used by those values.
|
||||||
|
//
|
||||||
|
// Construction, inspection, handle, and error values, including [Config],
|
||||||
|
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
|
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
||||||
|
// [PromptInputDefinition], [PromptInspection], [PreparedExecution], and
|
||||||
|
// [CapacityError], do not have stable JSON representations. Direct API keys
|
||||||
|
// are nevertheless excluded from JSON for every public value.
|
||||||
|
//
|
||||||
|
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
||||||
|
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
||||||
|
// duration_ms and omitted when zero. Run IDs and all exposed hashes are opaque:
|
||||||
|
// their spelling, length, character set, and algorithm are not API contracts.
|
||||||
package promptkit
|
package promptkit
|
||||||
|
|||||||
401
docs/consumers/pkg-promptkit.md
Normal file
401
docs/consumers/pkg-promptkit.md
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
# Package `promptkit`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide helps Go consumers assemble Promptkit and choose the main
|
||||||
|
preparation or execution workflow. The declarations and GoDoc in the
|
||||||
|
[root package](../../doc.go) own exact field, option, serialization,
|
||||||
|
concurrency, ownership, failure, and cancellation semantics. The
|
||||||
|
[framework format reference](../formats.md) owns prompt, profile, and schema
|
||||||
|
file contracts.
|
||||||
|
|
||||||
|
Import the package as:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
```
|
||||||
|
|
||||||
|
The following Go fragments are illustrative and omit surrounding package,
|
||||||
|
import, and error-handling code. Use the maintained examples for complete
|
||||||
|
programs.
|
||||||
|
|
||||||
|
## Construct An Engine
|
||||||
|
|
||||||
|
Create an engine with
|
||||||
|
[`NewEngine`](../../engine.go). A directory-backed setup supplies a prompt
|
||||||
|
directory and may supply profile and schema directories:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
ProfileDir: "profiles",
|
||||||
|
SchemaDir: "schemas",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Options support single-file or `fs.FS` sources, in-memory profiles,
|
||||||
|
engine-scoped backends, and injected artifact or model clients. Consult the
|
||||||
|
[constructor and option GoDoc](../../engine.go) for composition, precedence,
|
||||||
|
validation, and default transport behavior. Source discovery, format
|
||||||
|
validation, and profile precedence are defined by the
|
||||||
|
[framework format reference](../formats.md).
|
||||||
|
|
||||||
|
## Inspect A Prompt Before Preparation
|
||||||
|
|
||||||
|
Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's
|
||||||
|
declared inputs and output workflow without creating placeholder inputs or
|
||||||
|
resolving a profile:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectPrompt(ctx, "meeting.summary", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range inspection.Inputs {
|
||||||
|
// Compare the declared input with application configuration.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this configuration-time boundary when the application needs only the
|
||||||
|
declared prompt interface. Use `InspectProfile` separately when it must also
|
||||||
|
check a configured profile. Use `Prepare` when it needs inputs, schemas, or
|
||||||
|
rendered messages, and use prepared execution when that work must remain tied
|
||||||
|
to later execution. The method's [GoDoc](../../engine.go) owns exact fields,
|
||||||
|
hash, ownership, and error semantics.
|
||||||
|
|
||||||
|
## Prepare Without Model Execution
|
||||||
|
|
||||||
|
[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile,
|
||||||
|
loads inputs and any structured-output schema, and renders messages without
|
||||||
|
calling a model client. Choose it when the prepared value is the final
|
||||||
|
inspection or persistence result and no later execution must be tied to that
|
||||||
|
exact snapshot:
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Synthetic meeting notes"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The maintained
|
||||||
|
[offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
|
shows a complete runnable setup with a prompt file, in-memory profile, and
|
||||||
|
inline input. Exact request requirements and prepared-result fields belong to
|
||||||
|
the [`RunRequest` and `PreparedRun` GoDoc](../../types.go).
|
||||||
|
|
||||||
|
## Prepare Now And Execute The Same Snapshot Later
|
||||||
|
|
||||||
|
Use [`Engine.PrepareExecution`](../../engine.go) when an application must
|
||||||
|
inspect or persist preflight details before deciding whether to start model
|
||||||
|
work, while ensuring that later execution uses those exact rendered messages,
|
||||||
|
inputs, target settings, and validation resources:
|
||||||
|
|
||||||
|
```go
|
||||||
|
preparedExecution, err := engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Synthetic meeting notes"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer preparedExecution.Discard()
|
||||||
|
|
||||||
|
details := preparedExecution.Details()
|
||||||
|
// Inspect or persist an application-selected safe subset of details.
|
||||||
|
|
||||||
|
result, err := engine.RunPrepared(ctx, preparedExecution)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preparation does not call the model or reserve backend capacity.
|
||||||
|
`RunPrepared` executes from the retained snapshot rather than reloading
|
||||||
|
consumer sources. The handle is opaque in-process state, while `Details`
|
||||||
|
contains rendered content and remains subject to the application's data
|
||||||
|
handling policy. The
|
||||||
|
[`PreparedExecution` and method GoDoc](../../prepared_execution.go) and
|
||||||
|
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
||||||
|
credential, cancellation, timing, and error semantics.
|
||||||
|
|
||||||
|
## Execute And Validate
|
||||||
|
|
||||||
|
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
||||||
|
configured model client, classifies the generated artifact, and validates the
|
||||||
|
content in one call. Choose it when the application does not need a preflight
|
||||||
|
boundary tied to the eventual execution. A completed content check may return
|
||||||
|
`ValidationFailed` in the result; an operational inability to validate returns
|
||||||
|
an error.
|
||||||
|
|
||||||
|
The maintained
|
||||||
|
[offline execution example](../../examples/go-library/run/main.go) injects a
|
||||||
|
deterministic model client and exercises `Run` without credentials, network
|
||||||
|
access, or paid calls. It is intentionally separate from the preparation
|
||||||
|
example so each workflow and its small prompt fixture can be copied and run on
|
||||||
|
its own.
|
||||||
|
|
||||||
|
Use the [`RunResult` and `ValidationResult` GoDoc](../../types.go) for the
|
||||||
|
returned data and the `Engine.Run` GoDoc for failure and cancellation
|
||||||
|
semantics. The
|
||||||
|
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
|
||||||
|
owns the built-in client's outbound HTTP behavior.
|
||||||
|
|
||||||
|
## Inputs, Profiles, And Overrides
|
||||||
|
|
||||||
|
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request
|
||||||
|
can select a profile explicitly or use the prompt's default profile, and can
|
||||||
|
replace execution settings or the complete output contract.
|
||||||
|
|
||||||
|
The [public value GoDoc](../../types.go) defines nil, empty, zero, replacement,
|
||||||
|
copy, and credential behavior. The
|
||||||
|
[framework format reference](../formats.md) defines how those request values
|
||||||
|
interact with prompt definitions, file-backed profiles, built-ins, schemas,
|
||||||
|
and framework defaults.
|
||||||
|
|
||||||
|
For programmatic profiles,
|
||||||
|
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
||||||
|
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := inspection.EffectiveModelParams
|
||||||
|
if target.APIKeyEnv != "" {
|
||||||
|
// Apply application policy for the named environment variable.
|
||||||
|
} else if inspection.APIKeyRequired {
|
||||||
|
// Arrange a direct credential before later execution.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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. Inspection reports credential requirements but leaves the
|
||||||
|
timing of credential enforcement to the application. The method's
|
||||||
|
[GoDoc](../../engine.go) owns its exact result and error contract.
|
||||||
|
|
||||||
|
### Set A Per-Run Session And Reasoning
|
||||||
|
|
||||||
|
Supply a direct session ID when one prompt should be correlated with a
|
||||||
|
consumer-managed conversation or workflow without changing prompt variables:
|
||||||
|
|
||||||
|
```go
|
||||||
|
reasoning := "high"
|
||||||
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
SessionID: "conversation-42",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Synthetic meeting notes"),
|
||||||
|
},
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{
|
||||||
|
ReasoningEffort: &reasoning,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
A nil reasoning pointer inherits the selected profile, a pointer to a
|
||||||
|
nonblank string replaces it, and a pointer to a blank string disables
|
||||||
|
reasoning for that run. Session IDs are correlation metadata, not credentials;
|
||||||
|
use stable, non-secret values that are safe to expose to collaborators and
|
||||||
|
providers. The
|
||||||
|
[`RunRequest` and `ExecutionTargetOverride` GoDoc](../../types.go) owns the
|
||||||
|
exact normalization, precedence, error, copying, and exposure contract.
|
||||||
|
|
||||||
|
### Configure A Local OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
|
Choose the smallest configuration that fits how the endpoint will be reused.
|
||||||
|
|
||||||
|
#### Use An Endpoint-Only Profile
|
||||||
|
|
||||||
|
Put the endpoint directly on an in-memory profile when only that profile needs
|
||||||
|
it and shared backend identity or capacity policy is unnecessary:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
},
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "local-summary",
|
||||||
|
Endpoint: "http://localhost:8000/v1",
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint-only profiles have an empty backend ID and remain unrestricted by
|
||||||
|
backend capacity policy.
|
||||||
|
|
||||||
|
#### Use The Conventional Local Backend
|
||||||
|
|
||||||
|
Use `LocalBackend` when profiles should share the conventional `local`
|
||||||
|
identity, endpoint, and concurrency limit:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
},
|
||||||
|
promptkit.WithBackend(
|
||||||
|
promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||||
|
),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "local-summary",
|
||||||
|
BackendID: promptkit.BackendLocal,
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper is explicit: it does not pre-register a backend or read environment
|
||||||
|
variables. Supplying a positive limit leaves queue capacity omitted, so normal
|
||||||
|
backend registration selects the existing default waiting capacity of 1024.
|
||||||
|
The returned value still enters the engine through `WithBackend`.
|
||||||
|
|
||||||
|
#### Configure A Complete Backend
|
||||||
|
|
||||||
|
Use a keyed `Backend` value for authentication, extra request parameters, an
|
||||||
|
explicit queue capacity, a custom ID, or multiple local endpoints:
|
||||||
|
|
||||||
|
```go
|
||||||
|
noWaiting := 0
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
},
|
||||||
|
promptkit.WithBackend(promptkit.Backend{
|
||||||
|
ID: "local-gpu",
|
||||||
|
Endpoint: "http://gpu-host:8000/v1",
|
||||||
|
APIKeyEnv: "LOCAL_GPU_API_KEY",
|
||||||
|
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacity: &noWaiting,
|
||||||
|
}),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "gpu-summary",
|
||||||
|
BackendID: "local-gpu",
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use distinct custom IDs when registering multiple local endpoints.
|
||||||
|
Registrations belong to one engine and custom IDs cannot replace built-ins.
|
||||||
|
The [`Backend`, `LocalBackend`, and `WithBackend` GoDoc](../../backends.go)
|
||||||
|
defines exact construction, validation, copying, uniqueness, concurrency, and
|
||||||
|
request-default behavior.
|
||||||
|
|
||||||
|
Both file-backed and in-memory profiles select a registration through
|
||||||
|
`backend` or `Profile.BackendID`. Profile and request endpoint overrides retain
|
||||||
|
that routing and capacity identity. `PreparedRun.SelectedBackendID`,
|
||||||
|
`RunResult.SelectedBackendID`, and the effective `ExecutionTarget.BackendID`
|
||||||
|
expose it to consumers and injected model clients. Endpoint-only profiles
|
||||||
|
remain supported and expose an empty backend ID.
|
||||||
|
|
||||||
|
### Limit Backend Concurrency
|
||||||
|
|
||||||
|
Set `Backend.ConcurrencyLimit` when a backend needs protection from too many
|
||||||
|
simultaneous model calls. Leaving `QueueCapacity` nil, as in the local-backend
|
||||||
|
example above, selects the default waiting capacity of 1024.
|
||||||
|
|
||||||
|
To accept no waiting backlog beyond the active calls, provide an explicit
|
||||||
|
zero:
|
||||||
|
|
||||||
|
```go
|
||||||
|
noWaiting := 0
|
||||||
|
backend := promptkit.Backend{
|
||||||
|
ID: "local-gpu",
|
||||||
|
Endpoint: "http://gpu-host:8000/v1",
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacity: &noWaiting,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The pointer distinguishes an explicit zero from omission. Keep using keyed
|
||||||
|
`Backend` literals so additive configuration fields remain source-compatible.
|
||||||
|
Capacity belongs to one engine and the selected backend ID; endpoint-only
|
||||||
|
profiles and custom backends without a configured limit remain unrestricted.
|
||||||
|
Exact validation, defaulting, ownership, and concurrency semantics belong to
|
||||||
|
the [`Backend` GoDoc](../../backends.go).
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
File-backed profiles name an environment variable; in-memory profiles can
|
||||||
|
require a direct request key. Direct keys are request-scoped and are excluded
|
||||||
|
from supported JSON values and the package's `String` and `GoString`
|
||||||
|
summaries. The exact precedence and redaction guarantees belong to
|
||||||
|
[`RunRequest`, `GenerateRequest`, and the profile GoDoc](../../types.go).
|
||||||
|
|
||||||
|
## Protect Files And Generated Data
|
||||||
|
|
||||||
|
The default artifact reader opens a `File` reference as a caller-selected
|
||||||
|
operating-system path. It does not constrain paths to an application root,
|
||||||
|
impose an inbound request-size policy, or establish an untrusted-input security
|
||||||
|
boundary. Applications must validate and restrict untrusted paths and payloads
|
||||||
|
before constructing a request, or inject an artifact reader that enforces
|
||||||
|
their filesystem, authorization, and size policies.
|
||||||
|
|
||||||
|
Rendered messages, input and output artifact bodies, raw model output, and
|
||||||
|
validation diagnostics can contain sensitive data. API-key redaction does not
|
||||||
|
sanitize those values. Treat prepared values, results, collaborator requests,
|
||||||
|
errors, and logs according to the application's data-access, retention, and
|
||||||
|
secret-handling policies.
|
||||||
|
|
||||||
|
## Extension Interfaces
|
||||||
|
|
||||||
|
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
||||||
|
behavior does not fit the application. Their GoDoc defines concurrent use,
|
||||||
|
context handling, ownership of copied values, nil responses, and preservation
|
||||||
|
of collaborator errors. Implementations must honor cancellation, safely manage
|
||||||
|
copies they retain, avoid unsafe logging of content or credentials, and enforce
|
||||||
|
the application policy that motivated the injection.
|
||||||
|
|
||||||
|
## Handle Errors
|
||||||
|
|
||||||
|
Use `errors.Is` with the
|
||||||
|
[public error sentinels and operation GoDoc](../../engine.go). The declarations
|
||||||
|
distinguish invalid construction, invalid requests, absent sources,
|
||||||
|
source-loading failures, collaborator failures, and operational validation
|
||||||
|
failures. Specific request conditions may also match the broader
|
||||||
|
`ErrInvalidRequest`, and injected collaborator identities are preserved where
|
||||||
|
documented. Invalid or duplicate backend registrations match
|
||||||
|
`ErrInvalidConfig`; selecting an unknown backend matches `ErrProfileLoad`.
|
||||||
|
|
||||||
|
When a limited backend has admitted all active and waiting calls, handle
|
||||||
|
`ErrCapacityExceeded` separately from request errors and provider failures:
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityErr) {
|
||||||
|
// Record capacityErr.BackendID using application-owned diagnostics.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply application policy: shed work, report overload, or retry later.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A rejected call returns no partial result and does not invoke the model
|
||||||
|
client. Promptkit does not prescribe retries or map this error to an HTTP
|
||||||
|
status; those choices remain with the consuming application. The
|
||||||
|
[`CapacityError` GoDoc](../../capacity_error.go) owns the exact typed-error
|
||||||
|
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
||||||
|
error and cancellation identities.
|
||||||
|
|
||||||
|
## Application Boundary
|
||||||
|
|
||||||
|
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||||
|
API, process configuration, or deployment policy. Applications map the root
|
||||||
|
package's results and errors into those concerns, including inbound size and
|
||||||
|
trust policy.
|
||||||
@@ -31,11 +31,14 @@ Start with:
|
|||||||
|
|
||||||
| Task | Read before changing |
|
| Task | Read before changing |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Documentation or examples | The [documentation policy](policy/documentation.md) and the canonical owner of the affected contract. |
|
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
|
||||||
| Tests or test fixtures | The [testing policy](policy/testing.md), the owning package, and any focused internal document listed by the component overview. |
|
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
|
||||||
| Root public API, once implemented | The [architecture policy](policy/architecture.md), [root package declaration](../doc.go), [testing policy](policy/testing.md), and existing GoDoc. |
|
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
|
||||||
| Internal package implementation, once introduced | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and any focused internal document that the overview lists for that package. |
|
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
|
||||||
| Integration behavior, once introduced | The [architecture policy](policy/architecture.md), [documentation policy](policy/documentation.md), and the integration's owning contract under `docs/integrations/`. |
|
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
|
||||||
|
| Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. |
|
||||||
|
| Maintained example | The [example](../examples/go-library/prepare/main.go), [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). |
|
||||||
|
| Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. |
|
||||||
| Release preparation or publication | The [release procedure](release.md). |
|
| Release preparation or publication | The [release procedure](release.md). |
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Do not create
|
For cross-cutting changes, follow every applicable row. Do not create
|
||||||
@@ -50,8 +53,10 @@ validation from the Promptkit repository root:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./...
|
go build ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
Check formatting across every tracked Go file:
|
Check formatting across every tracked Go file:
|
||||||
@@ -75,7 +80,7 @@ behavior checked by the module.
|
|||||||
## Focused Validation
|
## Focused Validation
|
||||||
|
|
||||||
Use focused checks while iterating, then run the complete validation sequence
|
Use focused checks while iterating, then run the complete validation sequence
|
||||||
before accepting the change. The root package currently supports:
|
before accepting the change. The root package supports:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test .
|
go test .
|
||||||
@@ -83,17 +88,16 @@ go vet .
|
|||||||
go build .
|
go build .
|
||||||
```
|
```
|
||||||
|
|
||||||
Filter tests by name without assuming a future package layout:
|
Filter tests by name without assuming a fixed internal package layout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./... -run 'TestName'
|
go test ./... -run 'TestName'
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `TestName` with a useful regular expression. When internal packages are
|
Replace `TestName` with a useful regular expression. Target only paths that
|
||||||
introduced, target only paths that actually exist, such as
|
exist, and consult the internal component overview for their owning
|
||||||
`go test ./path/to/package`, and consult the internal component overview for
|
documentation. A filtered or package-specific run does not replace the
|
||||||
their owning documentation. A filtered or package-specific run does not replace
|
complete repository validation.
|
||||||
the complete repository validation.
|
|
||||||
|
|
||||||
## Coordinated Work With Scriptorium
|
## Coordinated Work With Scriptorium
|
||||||
|
|
||||||
|
|||||||
308
docs/formats.md
Normal file
308
docs/formats.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Framework Format Reference
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the canonical contract for Promptkit prompt-definition,
|
||||||
|
profile, and schema files. The [Go package consumer guide](consumers/pkg-promptkit.md)
|
||||||
|
explains how to select these sources and invoke the engine. The
|
||||||
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
|
owns the resulting outbound wire behavior.
|
||||||
|
|
||||||
|
Prompt and profile sources recursively discover files ending in `.yaml` or
|
||||||
|
`.yml`. YAML decoding is strict: unknown fields are errors for the selected
|
||||||
|
definition. Definitions are selected by their YAML `id`, not their file name
|
||||||
|
or directory.
|
||||||
|
|
||||||
|
## Prompt Definitions
|
||||||
|
|
||||||
|
A prompt definition describes inputs, Go-template messages, an optional
|
||||||
|
default profile, and an output contract.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: meeting.summary
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: local-summary
|
||||||
|
description: Summarize a synthetic meeting note.
|
||||||
|
session_id: '{{.session}}'
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
description: Meeting note to summarize.
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Return a concise summary.
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
ttl: 1h
|
||||||
|
- role: user
|
||||||
|
content_file: ./summary.user.md
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | yes | Non-empty prompt identifier used by `RunRequest.PromptID`. |
|
||||||
|
| `version` | yes | Non-empty version selected by an optional `RunRequest.PromptVersion`. |
|
||||||
|
| `default_profile` | no | Non-empty profile ID used when the request omits `ProfileID`. |
|
||||||
|
| `description` | no | Human-readable description. |
|
||||||
|
| `session_id` | no | Go template rendered from request variables and input helpers. |
|
||||||
|
| `inputs` | no | Declared input metadata. |
|
||||||
|
| `messages` | yes | One or more chat-message templates. |
|
||||||
|
| `output` | yes | Output format and validation settings. |
|
||||||
|
|
||||||
|
When a request omits a version, the selected prompt ID must identify exactly
|
||||||
|
one definition. When it supplies a version, the ID and version pair must be
|
||||||
|
unique.
|
||||||
|
|
||||||
|
Exact prompt inspection uses this same configured source, strict decoding,
|
||||||
|
referenced content-file resolution, and ID/version selection. It reports the
|
||||||
|
selected definition's declared metadata without changing the prompt format or
|
||||||
|
executing the definition.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
Each `inputs` item has these fields:
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | yes | Non-empty name used by the request input map and `input` template helper. Names must be unique. |
|
||||||
|
| `required` | no | When true, preparation fails if the request omits the input. The default is false. |
|
||||||
|
| `content_type` | no | Expected media-type metadata. |
|
||||||
|
| `description` | no | Human-readable input description. |
|
||||||
|
|
||||||
|
Requests supply inputs as inline or file-backed `ArtifactRef` values. Declared
|
||||||
|
required inputs must be present. A template reference also requires the named
|
||||||
|
input to exist, whether or not it was declared. Extra request inputs are
|
||||||
|
allowed.
|
||||||
|
|
||||||
|
### Messages And Templates
|
||||||
|
|
||||||
|
Each message has a non-empty `role` and exactly one of:
|
||||||
|
|
||||||
|
- `content`, containing an inline Go template; or
|
||||||
|
- `content_file`, naming a file whose contents are the Go template.
|
||||||
|
|
||||||
|
For directory and `fs.FS` prompt sources, `content_file` resolves relative to
|
||||||
|
the prompt file and remains within the source root. `WithPromptFile` also
|
||||||
|
resolves it relative to that file.
|
||||||
|
|
||||||
|
Request variables are the template data, so a variable named `audience` is
|
||||||
|
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body
|
||||||
|
of a named input. Missing variables and input references are errors.
|
||||||
|
|
||||||
|
The optional `session_id` uses the same template data and input helper. Its
|
||||||
|
rendered value is trimmed, omitted when empty, and limited to 256 Unicode code
|
||||||
|
points. A nonblank direct request session ID bypasses this template completely;
|
||||||
|
a blank direct value leaves the template behavior unchanged.
|
||||||
|
|
||||||
|
### Cache Control
|
||||||
|
|
||||||
|
`cache_control` is optional and has these fields:
|
||||||
|
|
||||||
|
| Field | Required | Values |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `type` | yes | `ephemeral` |
|
||||||
|
| `ttl` | no | Empty or `1h` |
|
||||||
|
|
||||||
|
Promptkit preserves cache-control metadata on the rendered message. The
|
||||||
|
outbound integration determines its wire representation.
|
||||||
|
|
||||||
|
### Output Contract
|
||||||
|
|
||||||
|
| Field | Required | Values or behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `format` | yes | `text`, `markdown`, or `json`. |
|
||||||
|
| `validation_mode` | yes | `none`, `basic`, `json`, or `json_schema`. |
|
||||||
|
| `schema_path` | for `json_schema` | Path to a schema in the configured schema source. |
|
||||||
|
| `repair_attempts` | no | Integer zero or greater; omitted means zero. |
|
||||||
|
|
||||||
|
The validation modes behave as follows:
|
||||||
|
|
||||||
|
- `none` skips content validation;
|
||||||
|
- `basic` requires non-empty generated content;
|
||||||
|
- `json` requires valid JSON; and
|
||||||
|
- `json_schema` requires valid JSON that satisfies the selected schema.
|
||||||
|
|
||||||
|
`format` controls output artifact metadata. JSON Schema mode also supplies the
|
||||||
|
schema to compatible model clients as structured-output metadata. The public
|
||||||
|
engine does not install an output repairer, so its validation is single-pass
|
||||||
|
even when a positive `repair_attempts` value is present.
|
||||||
|
|
||||||
|
A request-level `OutputContract` replaces the complete prompt output contract.
|
||||||
|
It does not merge individual fields. If its format is empty, Promptkit uses
|
||||||
|
`text`.
|
||||||
|
|
||||||
|
## Profile Definitions
|
||||||
|
|
||||||
|
A profile supplies model execution settings:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: local-summary
|
||||||
|
backend: openrouter
|
||||||
|
model: example-model
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 500
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 90
|
||||||
|
service_tier: flex
|
||||||
|
reasoning_effort: medium
|
||||||
|
extra_params:
|
||||||
|
provider_option: enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | yes | Non-empty profile identifier. IDs must be unique within one source. |
|
||||||
|
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared or inspected. |
|
||||||
|
| `endpoint` | unless `backend` is present | Non-empty OpenAI-compatible base URL, including an API version path when required. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
||||||
|
| `model` | yes | Non-empty provider model name. |
|
||||||
|
| `temperature` | no | Number from 0 through 2. |
|
||||||
|
| `max_tokens` | no | Integer zero or greater. |
|
||||||
|
| `top_p` | no | Number from 0 through 1. |
|
||||||
|
| `timeout_seconds` | no | Per-generation deadline in whole seconds; integer zero or greater. |
|
||||||
|
| `service_tier` | no | Provider-specific request tier. |
|
||||||
|
| `reasoning_effort` | no | Provider-specific reasoning setting. |
|
||||||
|
| `api_key_env` | no | Name of an environment variable containing the API key. |
|
||||||
|
| `extra_params` | no | JSON-compatible provider-specific outbound fields. |
|
||||||
|
|
||||||
|
Raw `api_key` is prohibited in profile YAML. Store only an environment
|
||||||
|
variable name in `api_key_env`.
|
||||||
|
|
||||||
|
Promptkit does not infer a backend from a model or endpoint. Endpoint-only
|
||||||
|
profiles remain supported and have no effective backend ID.
|
||||||
|
The engine always provides the built-in `openrouter` ID. Consumers can add
|
||||||
|
engine-scoped IDs with
|
||||||
|
[`WithBackend`](../backends.go); exact registration validation belongs to its
|
||||||
|
GoDoc.
|
||||||
|
|
||||||
|
`extra_params` accepts null, booleans, finite numbers, strings, arrays, and
|
||||||
|
objects with string keys. Keys must be non-empty. With the built-in client,
|
||||||
|
they also cannot collide with the standard fields listed in the
|
||||||
|
[outbound request contract](integrations/openai-compatible-chat.md#request-body).
|
||||||
|
|
||||||
|
### Defaults And Overrides
|
||||||
|
|
||||||
|
Execution settings resolve in this order:
|
||||||
|
|
||||||
|
1. framework defaults;
|
||||||
|
2. the selected backend, when the profile names one;
|
||||||
|
3. the selected profile; and
|
||||||
|
4. request `ExecutionTargetOverride` values.
|
||||||
|
|
||||||
|
The framework defaults are:
|
||||||
|
|
||||||
|
| Setting | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| `temperature` | `0` |
|
||||||
|
| `max_tokens` | `0` |
|
||||||
|
| `top_p` | `1` |
|
||||||
|
| `timeout_seconds` | `600` |
|
||||||
|
|
||||||
|
Numeric zero in a file or in-memory profile means that the profile does not
|
||||||
|
replace the framework default. Numeric request overrides use pointers, so an
|
||||||
|
explicit zero is preserved. In particular, an explicit request
|
||||||
|
`timeout_seconds` of zero disables the per-generation deadline while leaving
|
||||||
|
the caller context and transport timeout intact.
|
||||||
|
|
||||||
|
Non-empty profile strings replace backend defaults, and non-empty request
|
||||||
|
strings replace both. Request reasoning is the exception: a nil
|
||||||
|
`ReasoningEffort` pointer inherits the profile, a pointer to a nonblank string
|
||||||
|
trims and replaces it, and a pointer to a blank string clears it. Backend
|
||||||
|
identity is retained when either layer overrides the endpoint, so the override
|
||||||
|
also retains any engine-local capacity policy configured for that backend.
|
||||||
|
Capacity configuration belongs to the Go
|
||||||
|
[`Backend` API](../backends.go), not prompt or profile YAML. A non-empty
|
||||||
|
`extra_params` map at each layer replaces the entire lower-precedence map
|
||||||
|
rather than merging keys.
|
||||||
|
The [outbound integration contract](integrations/openai-compatible-chat.md)
|
||||||
|
defines how the effective settings are serialized.
|
||||||
|
|
||||||
|
### Source And Profile Precedence
|
||||||
|
|
||||||
|
An explicit request profile ID takes precedence over the prompt's
|
||||||
|
`default_profile`. If neither is present, preparation fails. Exact profile
|
||||||
|
inspection instead takes one explicit profile ID and does not use a prompt
|
||||||
|
default.
|
||||||
|
|
||||||
|
Profile sources resolve matching IDs in this order:
|
||||||
|
|
||||||
|
1. in-memory profiles supplied with `WithProfiles`;
|
||||||
|
2. a profile file, `fs.FS`, or configured profile directory; and
|
||||||
|
3. embedded built-in profiles.
|
||||||
|
|
||||||
|
A higher-precedence source falls back only when the profile is absent. An
|
||||||
|
invalid matching profile is an error and does not fall back. In-memory
|
||||||
|
`Profile` values follow the same ranges as YAML profiles. They use
|
||||||
|
`APIKeyRequired` for request-scoped credentials instead of `api_key_env`.
|
||||||
|
Preparation and exact profile inspection use this same source precedence.
|
||||||
|
|
||||||
|
## Built-In Profile Catalog
|
||||||
|
|
||||||
|
Every built-in selects the `openrouter` backend. The engine's built-in backend
|
||||||
|
registry supplies `https://openrouter.ai/api/v1` and the environment-variable
|
||||||
|
name `OPENROUTER_API_KEY`, so individual profiles contain only model and
|
||||||
|
generation settings. Built-in profile files do not repeat those connection
|
||||||
|
values. A custom or in-memory profile with the same profile ID takes
|
||||||
|
precedence.
|
||||||
|
|
||||||
|
| Provider | ID | Model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| aion-labs | `aion-2` | `aion-labs/aion-2.0` |
|
||||||
|
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
||||||
|
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
||||||
|
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
||||||
|
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
||||||
|
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` |
|
||||||
|
| deepseek | `deepseek-4-flash` | `deepseek/deepseek-v4-flash` |
|
||||||
|
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` |
|
||||||
|
| google | `gemini-2-flash` | `google/gemini-2.5-flash` |
|
||||||
|
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` |
|
||||||
|
| google | `gemini-2-pro` | `google/gemini-2.5-pro` |
|
||||||
|
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` |
|
||||||
|
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
||||||
|
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
||||||
|
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` |
|
||||||
|
| minimax | `minimax-m2` | `minimax/minimax-m2.5` |
|
||||||
|
| minimax | `minimax-m3` | `minimax/minimax-m3` |
|
||||||
|
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` |
|
||||||
|
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
|
||||||
|
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` |
|
||||||
|
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` |
|
||||||
|
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
||||||
|
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` |
|
||||||
|
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` |
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
Schemas are JSON documents selected by a prompt or request
|
||||||
|
`schema_path`. For a directory or `fs.FS` source, paths resolve within the
|
||||||
|
configured source root. Referenced nested schemas resolve relative to the
|
||||||
|
owning schema document. `WithSchemaFile` exposes one schema, addressed by its
|
||||||
|
base name.
|
||||||
|
|
||||||
|
An unreadable, invalid, or unresolvable schema produces an operational
|
||||||
|
validation error. Generated content that is valid JSON but does not satisfy the
|
||||||
|
schema produces a failed validation result.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Credential values belong at the request or environment boundary, never in
|
||||||
|
prompt, profile, schema, or example files:
|
||||||
|
|
||||||
|
- a file profile names an environment variable with `api_key_env`;
|
||||||
|
- an in-memory profile may set `APIKeyRequired`;
|
||||||
|
- a request can provide a direct `APIKey` or override `APIKeyEnv`; and
|
||||||
|
- a direct request key takes precedence over environment lookup.
|
||||||
|
|
||||||
|
After a direct request key, the credential-source precedence is request
|
||||||
|
`APIKeyEnv`, profile `api_key_env`, then the backend default. An in-memory
|
||||||
|
profile with `APIKeyRequired` clears an inherited backend environment name and
|
||||||
|
requires a direct key unless the request explicitly supplies `APIKeyEnv`.
|
||||||
|
Promptkit validates required credential availability during preparation.
|
||||||
|
Direct keys are excluded from JSON results and redacted by public string
|
||||||
|
formatters. Environment-variable names may appear in prepared metadata, but
|
||||||
|
their values do not.
|
||||||
106
docs/integrations/openai-compatible-chat.md
Normal file
106
docs/integrations/openai-compatible-chat.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# OpenAI-Compatible Chat Integration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the outbound HTTP behavior implemented by Promptkit's
|
||||||
|
internal OpenAI-compatible model client. The
|
||||||
|
[internal model-client document](../internal/llm.md) owns implementation flow,
|
||||||
|
errors, and test ownership. The root Promptkit engine uses this client by
|
||||||
|
default unless a consumer injects another implementation. The
|
||||||
|
[framework format reference](../formats.md) owns the profile and prompt values
|
||||||
|
that produce these outbound settings.
|
||||||
|
|
||||||
|
## Endpoint And Method
|
||||||
|
|
||||||
|
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
||||||
|
Before the client is called, the engine resolves framework, backend, profile,
|
||||||
|
and request values into one execution target. A non-empty endpoint from that
|
||||||
|
target overrides the client's configured base URL. After trailing slashes are
|
||||||
|
removed, `/chat/completions` is appended. Generation fails before sending when
|
||||||
|
neither source supplies an endpoint.
|
||||||
|
|
||||||
|
The target's backend ID is routing metadata for prepared values, results, and
|
||||||
|
injected clients. The built-in client does not derive the URL from that ID and
|
||||||
|
does not serialize it in the provider request.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
A non-empty API key supplied directly on the execution target takes
|
||||||
|
precedence. Otherwise, when an API-key environment-variable name is supplied,
|
||||||
|
the client reads that variable and requires a non-empty value. The selected
|
||||||
|
key is sent as `Authorization: Bearer <key>`. No authorization header is sent
|
||||||
|
when neither mechanism is configured.
|
||||||
|
|
||||||
|
The target contains the already resolved environment-variable name: an
|
||||||
|
explicit request override takes precedence over profile metadata, which takes
|
||||||
|
precedence over the backend default. Only the name reaches prepared metadata;
|
||||||
|
the environment value is read just before the provider call and is never added
|
||||||
|
to the JSON body.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
The request body always contains `model` and `messages`. The execution
|
||||||
|
target's model takes precedence over the client's configured model, and one
|
||||||
|
must be available.
|
||||||
|
|
||||||
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
|
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
||||||
|
|
||||||
|
The effective direct or prompt-rendered session ID is trimmed, limited to 256
|
||||||
|
Unicode code points, and sent when nonempty as top-level `session_id`. It is
|
||||||
|
never also sent as a session header.
|
||||||
|
|
||||||
|
The client conditionally includes:
|
||||||
|
|
||||||
|
- `temperature`, `max_tokens`, and `top_p` when non-zero or explicitly
|
||||||
|
present;
|
||||||
|
- non-empty `service_tier` and effective `reasoning_effort`; an explicitly
|
||||||
|
disabled reasoning setting is empty and therefore omitted; and
|
||||||
|
- `response_format` for JSON Schema structured output, including its name,
|
||||||
|
strict flag, and schema document.
|
||||||
|
|
||||||
|
The engine resolves backend, profile, and request extra-parameter maps by
|
||||||
|
whole-map replacement rather than key merging. The resulting effective map is
|
||||||
|
then merged directly into the top-level body after JSON serialization is
|
||||||
|
verified. Empty keys and collisions with these reserved fields are rejected
|
||||||
|
before any provider call:
|
||||||
|
|
||||||
|
- `model`
|
||||||
|
- `session_id`
|
||||||
|
- `messages`
|
||||||
|
- `temperature`
|
||||||
|
- `max_tokens`
|
||||||
|
- `top_p`
|
||||||
|
- `service_tier`
|
||||||
|
- `reasoning_effort`
|
||||||
|
- `response_format`
|
||||||
|
|
||||||
|
`backend_id`, `api_key_env`, and resolved credential values are not provider
|
||||||
|
request fields.
|
||||||
|
|
||||||
|
## Response Handling
|
||||||
|
|
||||||
|
Any 2xx response is decoded as an OpenAI-compatible chat response. The client
|
||||||
|
returns the first choice's non-empty message content and maps prompt,
|
||||||
|
completion, total, cached, and cache-write token counts.
|
||||||
|
|
||||||
|
Invalid JSON, absent choices, and empty first-choice content are malformed
|
||||||
|
responses. For a non-2xx status, the error includes the status code but never
|
||||||
|
the provider response body.
|
||||||
|
|
||||||
|
## Timeout And Cancellation
|
||||||
|
|
||||||
|
Timeouts are layered:
|
||||||
|
|
||||||
|
- the caller context remains the outer cancellation boundary;
|
||||||
|
- a positive generation timeout adds a request context deadline;
|
||||||
|
- zero adds no generation-specific deadline;
|
||||||
|
- a negative generation timeout is invalid; and
|
||||||
|
- the cloned `http.Client` supplies the whole-request transport cap, retaining
|
||||||
|
a positive supplied-client timeout or applying the configured/default
|
||||||
|
timeout when the supplied value is not positive.
|
||||||
|
|
||||||
|
The earliest applicable caller, generation, or transport deadline controls the
|
||||||
|
request. Constructing the internal client does not mutate a supplied
|
||||||
|
`http.Client`.
|
||||||
113
docs/internal/capacity.md
Normal file
113
docs/internal/capacity.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Internal Capacity Management
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes the implemented engine-local capacity coordination in
|
||||||
|
`internal/capacity`. The [architecture policy](../policy/architecture.md) owns
|
||||||
|
component boundaries, the [backend GoDoc](../../backends.go) owns exact public
|
||||||
|
configuration semantics, and the
|
||||||
|
[internal runner document](runner.md) owns orchestration around admission.
|
||||||
|
|
||||||
|
Capacity scheduling is outside the provider wire contract. It does not add
|
||||||
|
fields to execution targets, generated requests, prompt or profile YAML, or
|
||||||
|
stable JSON values.
|
||||||
|
|
||||||
|
## Construction And Pool Lifecycle
|
||||||
|
|
||||||
|
Each root `NewEngine` call obtains a normalized capacity-policy snapshot from
|
||||||
|
its immutable backend registry and constructs a new `Manager`. The manager
|
||||||
|
creates one pool for each limited backend ID. It has no package-global mutable
|
||||||
|
state, background workers, shutdown protocol, or persistence, so engines with
|
||||||
|
the same registrations still have independent capacity.
|
||||||
|
|
||||||
|
Unlimited registered backends and endpoint-only profiles have no pool. Their
|
||||||
|
admission and generation calls take the unrestricted fast path. An endpoint
|
||||||
|
override does not change the selected backend ID and therefore does not change
|
||||||
|
the pool.
|
||||||
|
|
||||||
|
One pool owns immutable active and total limits plus mutex-protected admission
|
||||||
|
count, active count, and ordered waiter list. Pool state exists only for the
|
||||||
|
lifetime of its engine.
|
||||||
|
|
||||||
|
## Bounded Execution Admission
|
||||||
|
|
||||||
|
For ordinary `Run`, the runner asks the manager to admit after resolving the
|
||||||
|
prompt, profile, selected backend, effective execution target, credentials, and
|
||||||
|
output contract, but before schema loading, artifact loading, or rendering.
|
||||||
|
`PrepareExecution` performs no admission. `RunPrepared` claims its handle,
|
||||||
|
rechecks credential availability, and then asks the manager to admit the
|
||||||
|
frozen backend before generation.
|
||||||
|
|
||||||
|
Admission is immediate: a limited pool either reserves a slot or returns only
|
||||||
|
the internal `ErrCapacityExceeded` identity. The runner attaches the selected
|
||||||
|
backend identity at its use-case boundary, and the root facade translates that
|
||||||
|
typed value without treating it as an invalid request or generation failure.
|
||||||
|
|
||||||
|
The total admitted bound is the active-generation limit plus its configured
|
||||||
|
waiting capacity. The returned release function is idempotent. The runner
|
||||||
|
defers it as soon as admission succeeds. An ordinary run holds the lease across
|
||||||
|
remaining preparation, initial generation, validation, every repair attempt,
|
||||||
|
and all failure or cancellation exits. Prepared execution holds the normal
|
||||||
|
lease across generation, validation, every internal repair attempt, and all
|
||||||
|
execution exits. A repair is part of its original admission and does not
|
||||||
|
reserve another bounded slot.
|
||||||
|
|
||||||
|
## FIFO Generation Permits
|
||||||
|
|
||||||
|
`NewClient` wraps the engine's selected internal model client after public
|
||||||
|
client adaptation or built-in client construction. Initial generation and the
|
||||||
|
default repairer receive the same wrapper.
|
||||||
|
|
||||||
|
For each `Generate` call, the wrapper selects a pool from the request's
|
||||||
|
effective backend ID. An unlimited call passes directly to the next client. A
|
||||||
|
limited call acquires an active permit, invokes the next client, and defers
|
||||||
|
permit release so ordinary returns and panic unwinding both restore capacity.
|
||||||
|
Preparation and validation never hold an active permit.
|
||||||
|
|
||||||
|
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
||||||
|
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
||||||
|
before making it generally available. Pools do not order work relative to
|
||||||
|
other backend IDs.
|
||||||
|
|
||||||
|
The wrapper passes generation requests, responses, and collaborator errors
|
||||||
|
through unchanged. It owns scheduling only; the concrete model client remains
|
||||||
|
responsible for provider transport behavior.
|
||||||
|
|
||||||
|
## Cancellation And Release
|
||||||
|
|
||||||
|
Admission checks the caller context before reserving a slot. A call canceled
|
||||||
|
while waiting for an active permit removes its waiter under the same pool lock
|
||||||
|
used to grant permits. If cancellation removes the waiter first, the wrapped
|
||||||
|
client is not invoked. If a concurrent grant wins first, the call owns the
|
||||||
|
permit and invokes the client with the original context, allowing the client
|
||||||
|
to observe cancellation normally.
|
||||||
|
|
||||||
|
This grant-or-cancel decision prevents lost and double-released permits.
|
||||||
|
Admission leases and active permits are released after success, collaborator
|
||||||
|
errors, validation failures, cancellation, and panic unwinding. Canceled
|
||||||
|
waiters are unlinked so their contexts and requests are not retained by the
|
||||||
|
pool.
|
||||||
|
|
||||||
|
## Test Ownership
|
||||||
|
|
||||||
|
The [manager tests](../../internal/capacity/manager_test.go) own policy
|
||||||
|
validation, bounded admission, idempotent release, context handling, and
|
||||||
|
unlimited admission. The
|
||||||
|
[client tests](../../internal/capacity/client_test.go) own peak enforcement,
|
||||||
|
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
||||||
|
unlimited calls, passthrough behavior, and panic release.
|
||||||
|
|
||||||
|
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
||||||
|
admission, lease lifetime, failure release, and shared initial/repair
|
||||||
|
scheduling. The
|
||||||
|
[prepared-execution use-case tests](../../internal/usecase/prepared_execution_test.go)
|
||||||
|
own deferred admission, credential ordering, and prepared-execution lease
|
||||||
|
release. The
|
||||||
|
[external package capacity tests](../../capacity_contract_test.go) own the
|
||||||
|
assembled public-engine behavior for configured limits, capacity errors,
|
||||||
|
endpoint identity, engine independence, and injected clients. The
|
||||||
|
[prepared-execution contract tests](../../prepared_execution_contract_test.go)
|
||||||
|
own the public prepared-capacity boundary. The
|
||||||
|
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
||||||
|
the public generation category and context identity when generation is
|
||||||
|
canceled.
|
||||||
81
docs/internal/llm.md
Normal file
81
docs/internal/llm.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Internal Model Client
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's internal model-client implementation. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns the library boundary,
|
||||||
|
and the
|
||||||
|
[OpenAI-compatible chat integration](../integrations/openai-compatible-chat.md)
|
||||||
|
owns the observable outbound HTTP contract. The
|
||||||
|
[framework format reference](../formats.md) owns the profile and prompt
|
||||||
|
settings consumed by the client.
|
||||||
|
|
||||||
|
The concrete client remains under `internal/llm`. The root engine assembles it
|
||||||
|
as the default implementation behind Promptkit's public client boundary.
|
||||||
|
|
||||||
|
## Components And Flow
|
||||||
|
|
||||||
|
`Client` is the provider-neutral generation boundary consumed by later
|
||||||
|
orchestration. `OpenAICompatibleClient` is the built-in implementation. It
|
||||||
|
uses internal domain values for rendered prompts, execution targets,
|
||||||
|
structured output, responses, and token usage.
|
||||||
|
|
||||||
|
The runner supplies a fully resolved target after applying backend, profile,
|
||||||
|
and request precedence. The client uses its endpoint, credential metadata,
|
||||||
|
generation fields, and extra parameters. `BackendID` remains routing metadata
|
||||||
|
for the generation boundary and is not mapped into the provider payload.
|
||||||
|
|
||||||
|
Construction validates the configured base URL and clones any supplied
|
||||||
|
`http.Client` so Promptkit can apply its timeout default without mutating the
|
||||||
|
caller's client. Generation then:
|
||||||
|
|
||||||
|
1. validates request-level timeout and endpoint requirements;
|
||||||
|
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||||
|
3. validates and merges extra parameters;
|
||||||
|
4. resolves authentication;
|
||||||
|
5. performs the outbound request under the applicable deadlines; and
|
||||||
|
6. decodes the first response choice and token usage.
|
||||||
|
|
||||||
|
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
||||||
|
when validating extra parameters. Backend registration consumes the same rule
|
||||||
|
without making the model client depend on registry configuration.
|
||||||
|
|
||||||
|
The implementation has no retry loop, tool-call support, provider catalog,
|
||||||
|
inbound HTTP behavior, or durable session store.
|
||||||
|
|
||||||
|
## Prepared Generation
|
||||||
|
|
||||||
|
For [`RunPrepared`](../../engine.go), the runner supplies the model client with
|
||||||
|
the target, rendered messages, and structured-output constraint retained by
|
||||||
|
executable preparation. Execution does not reopen or rerender consumer
|
||||||
|
sources.
|
||||||
|
|
||||||
|
Before backend admission, the runner rechecks that the frozen credential
|
||||||
|
environment-variable name is available. The handle does not retain the
|
||||||
|
environment value; the model client resolves the value visible when generation
|
||||||
|
begins. A direct request key remains in private execution state only until the
|
||||||
|
claimed execution finishes or an unclaimed handle is discarded. Exact public
|
||||||
|
ownership and redaction semantics belong to the
|
||||||
|
[`PreparedExecution` GoDoc](../../prepared_execution.go).
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
The package preserves distinct error identities for invalid client
|
||||||
|
configuration, invalid generation requests, request execution failures,
|
||||||
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
|
Caller cancellation and deadline failures during the outbound request are
|
||||||
|
reported as request execution failures. The runner classifies these identities
|
||||||
|
without depending on HTTP status mapping.
|
||||||
|
|
||||||
|
## Test Ownership
|
||||||
|
|
||||||
|
The
|
||||||
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
|
cancellation, and response-body suppression. The root transport contract test
|
||||||
|
also verifies that resolved backend settings reach this client without
|
||||||
|
serializing backend identity. All use local test servers or test transports;
|
||||||
|
the default suite makes no live or paid provider requests.
|
||||||
@@ -11,14 +11,30 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Root `promptkit` package | Establishes the public package boundary for the Go module. It does not yet provide migrated framework behavior or exported APIs. | [Package declaration](../../doc.go) |
|
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, typed capacity errors, public error mapping, and engine-local assembly. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||||
|
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
||||||
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory 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) |
|
||||||
|
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) |
|
||||||
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
|
| `internal/jsonvalue` | Validates and deeply copies JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||||
|
| `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
||||||
|
| `internal/profile` | Loads strictly decoded, validated execution profiles, including backend selection, from filesystem and `fs.FS` sources and composes repositories with error-preserving fallback. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go) |
|
||||||
|
| `internal/profile/builtin` | Embeds the built-in profile catalog, whose entries select OpenRouter, and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
||||||
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||||
|
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||||
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates frozen validation plans for prepared execution. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||||
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||||
|
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, generation, validation, capacity, and optional repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||||
|
|
||||||
The root `promptkit` package is the sole implemented Go package. No internal
|
The root package assembles these internal components without exposing their
|
||||||
framework packages exist yet.
|
representations. Consumers depend only on the root facade.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
Update this inventory as framework extraction introduces packages or changes
|
Update this inventory as implementation adds packages or changes component
|
||||||
component responsibilities. List only implemented components; proposed package
|
responsibilities. List only implemented components; proposed package
|
||||||
boundaries belong in temporary planning documents until their implementation
|
boundaries belong in temporary planning documents until their implementation
|
||||||
lands.
|
lands.
|
||||||
|
|||||||
189
docs/internal/runner.md
Normal file
189
docs/internal/runner.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Internal Runner
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's implemented internal orchestration. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns dependency and consumer
|
||||||
|
boundaries. The [source and validation document](sources.md) owns repository,
|
||||||
|
artifact, rendering, and validation behavior, while the
|
||||||
|
[model-client document](llm.md) owns generation behavior and failure
|
||||||
|
categories.
|
||||||
|
|
||||||
|
The runner remains under `internal/usecase` and is assembled by the root
|
||||||
|
Promptkit engine. Its concrete type is not part of the public API.
|
||||||
|
The [framework format reference](../formats.md) owns prompt, profile, schema,
|
||||||
|
and override semantics consumed by the runner.
|
||||||
|
|
||||||
|
## Collaborators
|
||||||
|
|
||||||
|
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
||||||
|
profiles, backend resolution, artifacts, rendering, model generation, and
|
||||||
|
validation. The root engine supplies one immutable registry containing the
|
||||||
|
built-in backend and validated consumer additions, one engine-local run
|
||||||
|
admitter, and a model client wrapped by the same capacity manager. Schema
|
||||||
|
documents are loaded through the validator's optional schema-loader interface.
|
||||||
|
An output repairer can be injected internally, but the ordinary runner
|
||||||
|
constructor does not enable one.
|
||||||
|
|
||||||
|
Each invocation carries its state in request, prepared-run, and result values.
|
||||||
|
The runner has no durable run or session store.
|
||||||
|
|
||||||
|
## Shared Prompt Selection
|
||||||
|
|
||||||
|
The runner uses one prompt-selection and hashing boundary for ordinary
|
||||||
|
preparation and exact prompt inspection. Preparation retains its early
|
||||||
|
request-ID check before direct-session normalization; both operations then use
|
||||||
|
the configured prompt repository to select one definition, load referenced
|
||||||
|
message content, and calculate the same prompt hash.
|
||||||
|
|
||||||
|
Inspection stops after that structural lookup. It does not parse templates or
|
||||||
|
touch profile, artifact, schema, renderer, validator, admission, or model
|
||||||
|
collaborators. The root [`Engine.InspectPrompt`](../../engine.go) GoDoc owns
|
||||||
|
the public operation's exact contract.
|
||||||
|
|
||||||
|
## 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 then apply the ordinary source precedence, resolve a
|
||||||
|
named backend, and construct the effective target from framework, backend, and
|
||||||
|
profile values.
|
||||||
|
|
||||||
|
Inspection stops after the resulting endpoint and model are structurally
|
||||||
|
validated. It does not check credential availability or perform prompt,
|
||||||
|
artifact, schema, rendering, admission, or model-client work. The root
|
||||||
|
[`Engine.InspectProfile`](../../engine.go) GoDoc owns the public operation's
|
||||||
|
exact contract.
|
||||||
|
|
||||||
|
## Shared Preparation Pipeline
|
||||||
|
|
||||||
|
`Prepare` and `Run` share one private preparation pipeline split at the point
|
||||||
|
where a run can be assigned to its selected backend pool. The resolution phase
|
||||||
|
performs only the work needed to validate routing and admission:
|
||||||
|
|
||||||
|
1. validate the required prompt selection and normalize any direct session ID;
|
||||||
|
2. load the prompt definition and hash the original definition;
|
||||||
|
3. select the request profile or the prompt's default profile;
|
||||||
|
4. resolve the profile's backend ID, when present;
|
||||||
|
5. resolve application-neutral defaults, backend defaults, profile values,
|
||||||
|
and explicit request overrides in that order;
|
||||||
|
6. validate endpoint, model, numeric overrides, and credential requirements;
|
||||||
|
7. resolve the effective output contract without loading its schema; and
|
||||||
|
8. retain the definition, source identities, effective settings, output
|
||||||
|
contract, and preparation start time in invocation-local state.
|
||||||
|
|
||||||
|
The completion phase consumes that state without reloading the prompt,
|
||||||
|
profile, or backend:
|
||||||
|
|
||||||
|
1. load structured-output schema metadata when required;
|
||||||
|
2. load and hash input artifacts;
|
||||||
|
3. render messages and the prompt-defined session;
|
||||||
|
4. apply any direct session ID;
|
||||||
|
5. hash the effective rendered prompt; and
|
||||||
|
6. construct the prepared value and preparation timing.
|
||||||
|
|
||||||
|
`Prepare` runs both phases consecutively and never performs capacity admission.
|
||||||
|
`Run` performs backend admission between the phases. This structure preserves
|
||||||
|
one execution-precedence and error-ordering implementation while allowing a
|
||||||
|
full backend pool to reject work before expensive schema, artifact, and
|
||||||
|
rendering operations.
|
||||||
|
|
||||||
|
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
|
||||||
|
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
||||||
|
the selected backend identity. Non-empty extra-parameter maps replace whole
|
||||||
|
lower-precedence maps. A direct API key takes precedence over environment
|
||||||
|
lookup; otherwise request, profile, and backend environment-variable names
|
||||||
|
apply in that order. A profile requiring a direct key clears an inherited
|
||||||
|
backend environment name unless the request supplies its own name. Secret
|
||||||
|
values remain excluded from serialized metadata.
|
||||||
|
|
||||||
|
Reasoning overrides are tri-state: nil inherits the profile, a pointer to a
|
||||||
|
nonblank string trims and replaces it, and a pointer to a blank string clears
|
||||||
|
it. A nonblank direct session is normalized before source loading, bypasses
|
||||||
|
the prompt session template, and is applied after ordinary message rendering.
|
||||||
|
A blank direct value retains prompt-template behavior. The runner clears the
|
||||||
|
template only on a value copy of the definition, so the definition hash always
|
||||||
|
describes the original source while the rendered-prompt hash includes the
|
||||||
|
effective direct or rendered session.
|
||||||
|
|
||||||
|
The registry is read-only after engine construction. Concurrent `Prepare` and
|
||||||
|
`Run` calls resolve independent defensive backend values and keep all
|
||||||
|
invocation state local.
|
||||||
|
|
||||||
|
## Run Flow
|
||||||
|
|
||||||
|
`Run` records its start time, performs the shared resolution phase, and asks
|
||||||
|
its `RunAdmitter` to reserve capacity for the effective backend ID. A nil
|
||||||
|
admitter is an internal unlimited fallback. After successful admission, `Run`
|
||||||
|
immediately defers the returned release function, performs the completion
|
||||||
|
phase, makes one initial generation call, builds the named output artifact,
|
||||||
|
and validates that artifact. Invalid generated content remains a validation
|
||||||
|
result; an inability to perform validation is an operational error.
|
||||||
|
|
||||||
|
The admission lease covers completion-phase preparation, initial generation,
|
||||||
|
validation, every repair, and every exit. It bounds accepted work without
|
||||||
|
serializing preparation or validation behind the active-generation limit.
|
||||||
|
The wrapped model client separately acquires a FIFO active permit only around
|
||||||
|
each actual generation call.
|
||||||
|
|
||||||
|
When an internal repairer is present, a JSON or JSON Schema content failure can
|
||||||
|
trigger bounded repair attempts. Repair receives the effective execution
|
||||||
|
target and session ID, validation errors, prior output, and structured-output
|
||||||
|
specification. The default repairer uses the same wrapped client as initial
|
||||||
|
generation, so each repair reacquires the selected backend's active permit
|
||||||
|
while remaining inside its original admission lease. Repair never performs a
|
||||||
|
second bounded admission. This capability remains internal and is not a public
|
||||||
|
option.
|
||||||
|
|
||||||
|
A successful result includes the output artifact and raw output, validation
|
||||||
|
state, effective session ID, prompt and rendered-prompt hashes, selected
|
||||||
|
profile and backend, effective settings, input hashes, token usage, a generated
|
||||||
|
run identifier, and UTC timing. The same effective session reaches initial
|
||||||
|
generation and any repair attempt through the rendered prompt. The same
|
||||||
|
effective target, including backend identity, reaches generation and any
|
||||||
|
repair attempt.
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
Package errors distinguish invalid requests, required profile selection,
|
||||||
|
credential failures, and prompt, profile, artifact, rendering, generation, and
|
||||||
|
validation failures. Wrapping preserves the package identities mapped by the
|
||||||
|
public facade and retains collaborator identities where they are part of the
|
||||||
|
internal contract.
|
||||||
|
|
||||||
|
Admission capacity exhaustion retains the internal capacity identity. At the
|
||||||
|
use-case boundary, the runner attaches the selected backend ID in an internal
|
||||||
|
typed error, and the root facade copies that value into the public
|
||||||
|
[`CapacityError`](../../capacity_error.go) without parsing diagnostic text. It
|
||||||
|
is not recategorized as an invalid request or generation failure, and no
|
||||||
|
partial result is returned. A context already done at admission retains its
|
||||||
|
context identity directly. Cancellation while waiting for an active generation
|
||||||
|
permit prevents client invocation when it wins the grant race; the model-client
|
||||||
|
boundary then preserves the context error through the generation-failure
|
||||||
|
category. Deferred release restores the admission lease on preparation,
|
||||||
|
generation, validation, repair, and cancellation failures.
|
||||||
|
|
||||||
|
Other context cancellation propagates through the invoked collaborator and is
|
||||||
|
classified by the owning operation.
|
||||||
|
An overlong direct session is an invalid request before source loading, while
|
||||||
|
an invalid or overlong prompt session template remains a prompt-render failure.
|
||||||
|
An unknown selected backend, or a selected backend with no configured resolver,
|
||||||
|
is classified as a profile-load failure.
|
||||||
|
|
||||||
|
## Test Ownership And Changes
|
||||||
|
|
||||||
|
The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
||||||
|
selection and override precedence, the two-phase boundary, early admission,
|
||||||
|
lease lifetime and release, direct-session resolution, schema-before-generation
|
||||||
|
behavior, hashing, generation and validation outcomes, backend propagation,
|
||||||
|
bounded repair, shared initial/repair capacity, credentials and redaction,
|
||||||
|
error categories, artifact metadata, usage, and timing. The
|
||||||
|
[capacity subsystem document](capacity.md) identifies the focused pool,
|
||||||
|
waiter, and wrapped-client tests.
|
||||||
|
|
||||||
|
Changes to orchestration should continue to use the existing package
|
||||||
|
interfaces, keep request state local to an invocation, and preserve the shared
|
||||||
|
resolution and completion pipeline. Source, renderer, validator, or
|
||||||
|
model-client contract changes belong first in their owning package and
|
||||||
|
document.
|
||||||
99
docs/internal/sources.md
Normal file
99
docs/internal/sources.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Internal Sources And Validation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's implemented internal source, artifact,
|
||||||
|
rendering, and output-validation behavior. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns the library boundary and
|
||||||
|
dependency rules. None of these internal packages is a supported consumer API,
|
||||||
|
and the root engine assembles them behind its public source options and values.
|
||||||
|
The [framework format reference](../formats.md) owns the exact file fields,
|
||||||
|
validation modes, built-in catalog, and source precedence.
|
||||||
|
|
||||||
|
## Prompt Definitions
|
||||||
|
|
||||||
|
`internal/promptdef` discovers YAML deterministically, decodes and validates
|
||||||
|
definitions, selects an ID and optional version, and resolves file-backed
|
||||||
|
message content within the selected operating-system or `fs.FS` source.
|
||||||
|
|
||||||
|
Exact prompt inspection performs one point-in-time lookup through that same
|
||||||
|
repository and validates referenced message content before returning declared
|
||||||
|
metadata. It does not parse templates or read profile, input, or schema
|
||||||
|
sources, and it does not retain the definition for a later execution.
|
||||||
|
|
||||||
|
Its package tests own prompt selection, strict decoding, definition validation,
|
||||||
|
duplicate detection, and source containment:
|
||||||
|
[prompt-definition repository tests](../../internal/promptdef/repository_test.go).
|
||||||
|
|
||||||
|
## Profiles And Built-Ins
|
||||||
|
|
||||||
|
`internal/profile` loads and validates execution profiles from an
|
||||||
|
operating-system filesystem or an `fs.FS`. It supports a primary repository
|
||||||
|
with fallback only when the primary reports that a profile is absent. Strict
|
||||||
|
YAML decoding recognizes the optional `backend` field, trims its value, and
|
||||||
|
requires a model plus at least one non-blank backend or endpoint. Loading does
|
||||||
|
not check registry membership because the available registry belongs to the
|
||||||
|
assembled engine; the runner checks membership during preparation and exact
|
||||||
|
profile inspection.
|
||||||
|
|
||||||
|
Exact profile inspection performs one point-in-time lookup through those
|
||||||
|
profile sources and checks the resolved target without reading prompt, input,
|
||||||
|
or schema sources. It does not retain that lookup for a later execution.
|
||||||
|
|
||||||
|
`internal/profile/builtin` embeds the maintained built-in profile catalog and
|
||||||
|
can place a caller-selected repository ahead of that catalog. Every embedded
|
||||||
|
profile selects `openrouter` and inherits its endpoint and credential
|
||||||
|
environment-variable name from the built-in backend registry rather than
|
||||||
|
repeating those values. Profile behavior is owned by the
|
||||||
|
[profile repository tests](../../internal/profile/repository_test.go), while
|
||||||
|
catalog completeness, the backend-selection invariant, duplicate IDs, and
|
||||||
|
overlay behavior are owned by the
|
||||||
|
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
||||||
|
|
||||||
|
## Ordinary Artifacts
|
||||||
|
|
||||||
|
`internal/artifact` resolves inline references and unrestricted,
|
||||||
|
caller-selected file paths. It copies content into an artifact, records
|
||||||
|
metadata and a content hash, applies a content-type fallback, and honors
|
||||||
|
context cancellation.
|
||||||
|
|
||||||
|
This ordinary reader does not implement an inbound HTTP security boundary. In
|
||||||
|
particular, it does not constrain files to an application root or impose an
|
||||||
|
HTTP request-size policy. Scriptorium's restricted HTTP reader remains an
|
||||||
|
application concern outside Promptkit. The
|
||||||
|
[artifact reader tests](../../internal/artifact/reader_test.go) own the
|
||||||
|
implemented reader behavior and failures.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
`internal/prompt` renders definition messages as Go templates using named
|
||||||
|
artifacts and variables. It carries message roles, session IDs, and cache
|
||||||
|
control into the rendered prompt. The
|
||||||
|
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
||||||
|
|
||||||
|
## Schemas And Output Validation
|
||||||
|
|
||||||
|
`internal/validate` provides validators backed by an operating-system
|
||||||
|
filesystem or an `fs.FS`. Invalid generated content is returned as a validation
|
||||||
|
result; inability to load, register, or compile a schema is an operational
|
||||||
|
error.
|
||||||
|
|
||||||
|
For executable preparation, the built-in validators create a frozen validation
|
||||||
|
plan. None, basic, and JSON modes retain the effective output contract without
|
||||||
|
source access. JSON Schema mode loads the root document, resolves and compiles
|
||||||
|
every transitive reference during preparation, and retains the compiled
|
||||||
|
validator. The provider-facing structured-output metadata uses that same
|
||||||
|
captured root document.
|
||||||
|
|
||||||
|
`PrepareExecution` also completes prompt and profile selection, artifact
|
||||||
|
loading and hashing, session and message rendering, and target resolution.
|
||||||
|
`RunPrepared` uses the retained source-derived state and validation plan; it
|
||||||
|
does not reopen prompt, profile, input, or schema sources and does not rerender
|
||||||
|
the request. By contrast, ordinary `Prepare` produces a preparation value only:
|
||||||
|
a later `Run` performs its own source resolution and preparation.
|
||||||
|
|
||||||
|
The [validator tests](../../internal/validate/standard_validator_test.go) own
|
||||||
|
basic, JSON, JSON Schema, source resolution, schema loading, compilation,
|
||||||
|
frozen-reference behavior, and content-failure behavior. Prepared execution
|
||||||
|
orchestration is owned by the
|
||||||
|
[use-case tests](../../internal/usecase/prepared_execution_test.go).
|
||||||
@@ -9,15 +9,65 @@ implemented packages without redefining these rules.
|
|||||||
|
|
||||||
## System Shape
|
## System Shape
|
||||||
|
|
||||||
Promptkit is an importable Go library. It does not provide a runnable command,
|
Promptkit is an importable Go library. It does not ship a command, an HTTP
|
||||||
an HTTP service, or another application process.
|
service, or another application process. Repository examples demonstrate
|
||||||
|
library use but are not Promptkit applications or release artifacts.
|
||||||
|
|
||||||
The module root contains package `promptkit`, which is the public facade and the
|
The module root contains package `promptkit`, which is the public facade. It
|
||||||
only implemented Go package in the current repository foundation. It declares
|
provides the supported engine, configuration and source options, requests,
|
||||||
the module's public package boundary but does not yet provide migrated framework
|
results, public values, extension interfaces, profiles, and error sentinels.
|
||||||
behavior or exported APIs. No internal framework packages currently exist.
|
|
||||||
|
|
||||||
Future framework extraction must follow this dependency direction:
|
The implemented internal components consist of:
|
||||||
|
|
||||||
|
- `internal/domain`, which owns framework data values shared by later internal
|
||||||
|
components;
|
||||||
|
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
||||||
|
definitions and the built-in OpenRouter definition;
|
||||||
|
- `internal/capacity`, which owns engine-local bounded run admission and
|
||||||
|
model-generation scheduling for limited backends;
|
||||||
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
|
constructs the default execution target;
|
||||||
|
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||||
|
helpers for filesystem and `fs.FS` consumers;
|
||||||
|
- `internal/jsonvalue`, which validates and defensively copies JSON-compatible
|
||||||
|
extra-parameter trees;
|
||||||
|
- `internal/promptdef`, which loads and validates prompt definitions from
|
||||||
|
filesystem and `fs.FS` sources;
|
||||||
|
- `internal/profile`, which loads, validates, and overlays execution profiles
|
||||||
|
from filesystem and `fs.FS` sources;
|
||||||
|
- `internal/profile/builtin`, which embeds the built-in execution profile
|
||||||
|
catalog;
|
||||||
|
- `internal/prompt`, which renders prompt messages from Go templates;
|
||||||
|
- `internal/artifact`, which resolves ordinary inline and unrestricted
|
||||||
|
caller-selected file references;
|
||||||
|
- `internal/validate`, which validates basic, JSON, and JSON Schema output
|
||||||
|
using filesystem and `fs.FS` schema sources;
|
||||||
|
- `internal/llm`, which defines the provider-neutral generation boundary and
|
||||||
|
implements outbound OpenAI-compatible chat requests; and
|
||||||
|
- `internal/usecase`, which coordinates preparation and execution across the
|
||||||
|
internal framework components.
|
||||||
|
|
||||||
|
The `examples/go-library/prepare` and `examples/go-library/run` packages are
|
||||||
|
maintained downstream consumers of the root facade. They do not expose library
|
||||||
|
packages or participate in internal assembly.
|
||||||
|
|
||||||
|
The root facade assembles one immutable backend registry, one capacity manager,
|
||||||
|
the internal repositories, renderer, validator, outbound client, and use-case
|
||||||
|
runner while translating public values and errors at the library boundary. The
|
||||||
|
registry contains built-ins plus validated engine-scoped consumer additions.
|
||||||
|
The facade constructs the capacity manager from the registry's immutable
|
||||||
|
policy snapshot, wraps the selected built-in or injected model client, and
|
||||||
|
supplies bounded admission to the runner. The defaults and renderer depend on
|
||||||
|
the domain model. Prompt-definition and profile repositories use the domain
|
||||||
|
model, file catalog, and YAML decoder. The built-in profile repository supplies
|
||||||
|
an embedded `fs.FS` to the profile package. Artifact reading uses the domain
|
||||||
|
model and application-neutral defaults. Validation uses the domain model, file
|
||||||
|
catalog, and JSON Schema implementation. The model client uses the domain
|
||||||
|
model, application-neutral defaults, and an injected or standard-library HTTP
|
||||||
|
client. The use-case runner depends on the narrow interfaces owned by each
|
||||||
|
internal component, including backend lookup and run admission.
|
||||||
|
|
||||||
|
The current implementation follows this dependency direction:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
downstream consumers, including Scriptorium
|
downstream consumers, including Scriptorium
|
||||||
@@ -32,14 +82,19 @@ downstream consumers, including Scriptorium
|
|||||||
narrow injected abstractions
|
narrow injected abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
The facade may coordinate internal components. Internal components must depend
|
The backend registry depends on the domain model and shared JSON-value
|
||||||
on narrow abstractions for behavior supplied from outside the library; they
|
validation, has no mutation API after construction, and consumes the
|
||||||
must not depend on consumers or on Scriptorium. This diagram constrains future
|
OpenAI-compatible reserved request-field rule owned by the model client. The
|
||||||
work and does not assert that the internal components already exist.
|
capacity component depends on the domain model and the narrow internal
|
||||||
|
model-client boundary, not on provider transport implementation. The model
|
||||||
|
client does not depend on registry or capacity configuration. The facade
|
||||||
|
coordinates internal components and adapts the supported public extension
|
||||||
|
interfaces to narrow internal abstractions. Internal components must not depend
|
||||||
|
on consumers or on Scriptorium.
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
Scriptorium is a downstream application that will consume Promptkit through
|
Scriptorium is a downstream application that consumes Promptkit through
|
||||||
the supported public facade. It is not a Promptkit package and must not become
|
the supported public facade. It is not a Promptkit package and must not become
|
||||||
an internal dependency.
|
an internal dependency.
|
||||||
|
|
||||||
@@ -112,7 +167,6 @@ state.
|
|||||||
|
|
||||||
## Current-State Maintenance
|
## Current-State Maintenance
|
||||||
|
|
||||||
This policy distinguishes present implementation from constraints on future
|
Do not list planned packages as implemented components. When implementation
|
||||||
framework extraction. Do not list planned packages as implemented components.
|
introduces a package, update the internal inventory and the owning contract or
|
||||||
When extraction introduces a package, update the internal inventory and the
|
subsystem document in the same change.
|
||||||
owning contract or subsystem document in the same change.
|
|
||||||
|
|||||||
@@ -75,18 +75,20 @@ mechanisms, not secret values.
|
|||||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
||||||
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
|
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
|
||||||
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
|
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
|
||||||
|
| Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, built-in profiles, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. |
|
||||||
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
||||||
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
||||||
|
| Supplemental release guidance | None. `docs/releases/` may be used when a release benefits from a changelog or migration guide. | No canonical content. These files may briefly summarize release-specific changes, compatibility, and consumer migration paths, and may be corrected, consolidated, archived, or removed when no longer useful. | Public API and behavior contracts, formats, integrations, architecture, release procedure, and the authoritative annotated-tag release record. |
|
||||||
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
||||||
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
||||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||||
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed or accepted scope, sequencing, gates, and implementation status. | Implemented behavior reference and durable decision rationale. |
|
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed or accepted scope, sequencing, gates, and implementation status. | Implemented behavior reference and durable decision rationale. |
|
||||||
| Complete copyable artifacts | `examples/`, when maintained examples exist | Valid inputs, Go programs, and other files intended to be copied or run. | Field-by-field reference, exact API declarations, and prose explanation. |
|
| Complete copyable artifacts | `examples/` | Valid inputs, Go programs, and other files intended to be copied or run. | Field-by-field reference, exact API declarations, and prose explanation. |
|
||||||
|
|
||||||
Conditional owners do not require placeholder files or directories. Create a
|
Conditional owners do not require placeholder files or directories. Create a
|
||||||
consumer, integration, subsystem, ADR, roadmap, or example document only when
|
consumer, integration, release, subsystem, ADR, roadmap, or example document
|
||||||
the corresponding implemented interface, decision, planned effort, or
|
only when the corresponding implemented interface, release, decision, planned
|
||||||
maintained artifact exists.
|
effort, or maintained artifact exists.
|
||||||
|
|
||||||
## Boundary Rules
|
## Boundary Rules
|
||||||
|
|
||||||
@@ -105,6 +107,26 @@ for a task. Internal documents explain how Promptkit implements them. Internal
|
|||||||
documentation may identify a public type or external format as a dependency,
|
documentation may identify a public type or external format as a dependency,
|
||||||
but must link to its canonical definition rather than restate it.
|
but must link to its canonical definition rather than restate it.
|
||||||
|
|
||||||
|
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
||||||
|
schema-file contracts. Integration documents own external wire formats.
|
||||||
|
|
||||||
|
### Supplemental Release Guidance
|
||||||
|
|
||||||
|
Files under `docs/releases/` may provide changelog-style summaries and
|
||||||
|
migration guidance for a particular release. They are navigation and
|
||||||
|
orientation aids, not canonical owners of public APIs, behavior, formats,
|
||||||
|
integrations, architecture, release procedure, or other durable facts. When a
|
||||||
|
reader needs detail beyond a short release-specific note, the release document
|
||||||
|
must link to the applicable canonical documentation rather than reproduce its
|
||||||
|
contract.
|
||||||
|
|
||||||
|
The annotated tag message required by the
|
||||||
|
[release procedure](../release.md#write-the-release-note) remains the
|
||||||
|
authoritative release record. Supplemental release documents may be corrected,
|
||||||
|
consolidated, archived, or removed at any time when they are no longer useful,
|
||||||
|
provided maintained documentation does not depend on them and the annotated
|
||||||
|
tag record remains intact.
|
||||||
|
|
||||||
### Security Topics
|
### Security Topics
|
||||||
|
|
||||||
This policy owns what documentation and examples may contain. Architecture owns
|
This policy owns what documentation and examples may contain. Architecture owns
|
||||||
@@ -156,6 +178,10 @@ durable owners, update incoming links, and archive or remove the roadmap
|
|||||||
according to repository practice. Do not preserve completed roadmaps as a
|
according to repository practice. Do not preserve completed roadmaps as a
|
||||||
second current-state reference.
|
second current-state reference.
|
||||||
|
|
||||||
|
Supplemental release documents may likewise be removed without preserving a
|
||||||
|
replacement. Before removal, update maintained incoming links so current
|
||||||
|
documentation does not depend on an optional historical guide.
|
||||||
|
|
||||||
Before completing documentation work:
|
Before completing documentation work:
|
||||||
|
|
||||||
- verify affected behavior and examples;
|
- verify affected behavior and examples;
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ documentation-link, and repository-hygiene checks before accepting changes.
|
|||||||
Introducing hosted CI later would supplement, not silently redefine, this
|
Introducing hosted CI later would supplement, not silently redefine, this
|
||||||
documented validation model.
|
documented validation model.
|
||||||
|
|
||||||
|
The complete test sequence includes ordinary and race-enabled package tests.
|
||||||
|
The maintained offline consumer workflow is also run from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
|
```
|
||||||
|
|
||||||
Tests in the default suite must be deterministic, offline, and independent of
|
Tests in the default suite must be deterministic, offline, and independent of
|
||||||
real credentials. They must not invoke paid APIs, use live network
|
real credentials. They must not invoke paid APIs, use live network
|
||||||
dependencies, or depend on mutable external services. Tests that require live
|
dependencies, or depend on mutable external services. Tests that require live
|
||||||
@@ -77,6 +86,10 @@ Use each test type where it protects a distinct risk:
|
|||||||
relied upon by consumers.
|
relied upon by consumers.
|
||||||
- Integration tests use real collaborators when correctness depends on their
|
- Integration tests use real collaborators when correctness depends on their
|
||||||
interaction, while replacing live or nondeterministic external boundaries.
|
interaction, while replacing live or nondeterministic external boundaries.
|
||||||
|
- External-package root tests exercise the public facade as a Go consumer,
|
||||||
|
while internal package tests own focused implementation behavior.
|
||||||
|
- The maintained offline preparation example protects one representative
|
||||||
|
assembled consumer workflow without contacting a model provider.
|
||||||
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
||||||
exercise, and free of credentials or private data.
|
exercise, and free of credentials or private data.
|
||||||
- Golden files are appropriate only when the complete output is intentionally
|
- Golden files are appropriate only when the complete output is intentionally
|
||||||
@@ -154,7 +167,8 @@ A test failing is not the same as a test needing to be edited. Many tests may co
|
|||||||
|
|
||||||
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
||||||
|
|
||||||
For example, do not encode an internal concurrency limit indirectly:
|
The following fragments are illustrative rather than standalone Go programs.
|
||||||
|
Do not encode an internal concurrency limit indirectly:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Production policy:
|
// Production policy:
|
||||||
|
|||||||
255
docs/release.md
255
docs/release.md
@@ -7,26 +7,91 @@ tags. It does not publish runnable binaries or binary packages and does not
|
|||||||
currently use hosted CI. The release maintainer performs and records the
|
currently use hosted CI. The release maintainer performs and records the
|
||||||
required validation.
|
required validation.
|
||||||
|
|
||||||
The first planned release is `v0.1.0`. Do not create that tag until the
|
`v0.1.0` is the initial published release. Later releases use semantic
|
||||||
framework has been extracted and the resulting public library has passed this
|
`vMAJOR.MINOR.PATCH` tags. Before `v1`, minor releases may change the public
|
||||||
procedure. Later tags use the `vMAJOR.MINOR.PATCH` form. While Promptkit remains
|
API and patch releases preserve compatibility within their minor line. Every
|
||||||
pre-`v1`, release notes must identify intentional public API changes and any
|
pre-`v1` release note must summarize compatibility, identify public API
|
||||||
consumer migration required by them.
|
changes, and state any action required of consumers.
|
||||||
|
|
||||||
## Prepare The Release
|
Promptkit releases are source-only. The annotated tag message is the release
|
||||||
|
note; there is no separate hosted release or binary packaging step.
|
||||||
|
|
||||||
Work from a clean checkout of the intended release commit, outside any Go
|
## Establish The Candidate
|
||||||
workspace and without a local module replacement. Confirm the source commit is
|
|
||||||
already published through the normal branch workflow.
|
|
||||||
|
|
||||||
From the Promptkit repository root, verify the checkout:
|
Choose a version that has not been published and export it as
|
||||||
|
`RELEASE_VERSION`. Run every command in this procedure from the Promptkit
|
||||||
|
repository root in the same POSIX shell. Do not reuse `v0.1.0` or another
|
||||||
|
existing version.
|
||||||
|
|
||||||
|
The following guard derives the release commit from `HEAD` and stops on a
|
||||||
|
missing or malformed version, a checkout other than synchronized `main`,
|
||||||
|
uncommitted changes, an active Go workspace, a module replacement, a vendor
|
||||||
|
tree, or an existing local or remote tag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
test -z "$(go env GOWORK)"
|
set -eu
|
||||||
test -z "$(git status --short)"
|
|
||||||
git fetch --tags origin
|
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
|
||||||
|
if ! printf '%s\n' "$RELEASE_VERSION" |
|
||||||
|
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||||
|
then
|
||||||
|
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
|
||||||
|
export RELEASE_COMMIT
|
||||||
|
|
||||||
|
check_release_candidate() {
|
||||||
|
test "$(git branch --show-current)" = main
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
|
||||||
|
gowork_value=$(go env GOWORK)
|
||||||
|
case "$gowork_value" in
|
||||||
|
''|off) ;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "active Go workspace: $gowork_value" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
test -z "$(git ls-files go.work go.work.sum)"
|
||||||
|
test ! -e vendor
|
||||||
|
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||||
|
then
|
||||||
|
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch origin main --tags
|
||||||
|
test "$RELEASE_COMMIT" = \
|
||||||
|
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
|
||||||
|
then
|
||||||
|
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if test -n "$(
|
||||||
|
git ls-remote --tags origin \
|
||||||
|
"refs/tags/$RELEASE_VERSION" \
|
||||||
|
"refs/tags/$RELEASE_VERSION^{}"
|
||||||
|
)"
|
||||||
|
then
|
||||||
|
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_release_candidate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Do not continue unless the guard completes successfully. In particular, push
|
||||||
|
the intended commit through the normal `main` branch workflow before release;
|
||||||
|
the tag procedure is not a substitute for publishing the source commit.
|
||||||
|
|
||||||
|
## Validate The Candidate
|
||||||
|
|
||||||
Confirm the module and root package metadata:
|
Confirm the module and root package metadata:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -41,93 +106,169 @@ gitea.maximumdirect.net/eric/promptkit 1.25.5
|
|||||||
promptkit gitea.maximumdirect.net/eric/promptkit
|
promptkit gitea.maximumdirect.net/eric/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the same default Go validation required by the
|
Run the complete maintainer validation required by the
|
||||||
[development guide](development.md):
|
[development guide](development.md):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./...
|
go build ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
Check every tracked Go file and repository whitespace:
|
Check every tracked Go file. This command must produce no output:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gofmt -l $(git ls-files '*.go')
|
unformatted=$(
|
||||||
|
git ls-files '*.go' |
|
||||||
|
while IFS= read -r go_file
|
||||||
|
do
|
||||||
|
gofmt -l "$go_file"
|
||||||
|
done
|
||||||
|
)
|
||||||
|
test -z "$unformatted"
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow every maintained Markdown link and confirm that its local or published
|
||||||
|
target exists. Review the repository for generated binaries, test or coverage
|
||||||
|
output, credentials, template residue, downloaded assets, and other files that
|
||||||
|
do not belong in source control.
|
||||||
|
|
||||||
|
Recheck module and repository hygiene, whitespace, and the clean checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
test -z "$(git ls-files go.work go.work.sum)"
|
||||||
|
test ! -e vendor
|
||||||
|
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||||
|
then
|
||||||
|
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
git diff --check
|
git diff --check
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The formatting command must produce no paths. Follow every maintained Markdown
|
## Write The Release Note
|
||||||
link and confirm its target exists. Review the repository for generated
|
|
||||||
binaries, test or coverage output, credentials, template residue, and other
|
|
||||||
files that do not belong in source control.
|
|
||||||
|
|
||||||
Confirm that no workspace override is tracked and that `go.mod` contains no
|
Prepare a plain-text annotated-tag message outside the repository and export
|
||||||
`replace` directive:
|
its path as `RELEASE_NOTES_FILE`. Use this form, replacing each summary with
|
||||||
|
release-specific text; write `None.` when there are no public API changes or
|
||||||
|
consumer actions:
|
||||||
|
|
||||||
```sh
|
```text
|
||||||
git ls-files go.work go.work.sum
|
Promptkit vMAJOR.MINOR.PATCH
|
||||||
rg -n '^replace\b' go.mod
|
|
||||||
|
Validated commit: full commit ID
|
||||||
|
Compatibility: compatibility summary
|
||||||
|
Public API changes: changes or None.
|
||||||
|
Consumer action: required action or None.
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands must produce no output. Re-run `git status --short` and require a
|
After writing it, require all release-note fields, the selected version, and
|
||||||
clean result after every validation and review check.
|
the validated commit to be present:
|
||||||
|
|
||||||
## Create And Publish The Tag
|
|
||||||
|
|
||||||
Choose the semantic version from the intended compatibility change. Record the
|
|
||||||
release commit before tagging:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
release_version=v0.1.0
|
: "${RELEASE_NOTES_FILE:?export the path to the release-note file}"
|
||||||
release_commit=$(git rev-parse HEAD)
|
test -f "$RELEASE_NOTES_FILE"
|
||||||
|
test -s "$RELEASE_NOTES_FILE"
|
||||||
|
grep -F "Promptkit $RELEASE_VERSION" "$RELEASE_NOTES_FILE"
|
||||||
|
grep -F "Validated commit: $RELEASE_COMMIT" "$RELEASE_NOTES_FILE"
|
||||||
|
grep -F 'Compatibility:' "$RELEASE_NOTES_FILE"
|
||||||
|
grep -F 'Public API changes:' "$RELEASE_NOTES_FILE"
|
||||||
|
grep -F 'Consumer action:' "$RELEASE_NOTES_FILE"
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the example version for later releases and keep both values in the same
|
Inspect the complete message and confirm that it accurately records the
|
||||||
shell for the remaining commands. Confirm the tag does not already exist
|
compatibility impact, public API changes, and required consumer action.
|
||||||
locally or remotely:
|
|
||||||
|
## Create And Inspect The Tag
|
||||||
|
|
||||||
|
Run the candidate guard again immediately before tag creation. This ensures
|
||||||
|
that validation or release-note preparation did not change the checkout and
|
||||||
|
that the commit is still published and untagged:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
test -z "$(git tag --list "$release_version")"
|
check_release_candidate
|
||||||
test -z "$(git ls-remote --tags origin "refs/tags/$release_version")"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Create an annotated tag whose message identifies the release and records that
|
Create the annotated tag from the prepared release note and bind it explicitly
|
||||||
the documented validation passed for the tagged commit:
|
to the validated commit:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git tag --annotate "$release_version" \
|
git tag --annotate "$RELEASE_VERSION" \
|
||||||
--message "Promptkit $release_version; documented validation passed for $release_commit"
|
--file "$RELEASE_NOTES_FILE" \
|
||||||
|
"$RELEASE_COMMIT"
|
||||||
```
|
```
|
||||||
|
|
||||||
Inspect the tag before publication:
|
Inspect both the tag message and its source commit before publication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git show --no-patch --decorate "$release_version"
|
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag
|
||||||
test "$(git rev-list -n 1 "$release_version")" = "$release_commit"
|
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
|
||||||
|
test "$(
|
||||||
|
git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}"
|
||||||
|
)" = "$RELEASE_COMMIT"
|
||||||
```
|
```
|
||||||
|
|
||||||
Publish the tag without relying on a hosting-provider-specific release
|
If inspection finds an error, delete the unpublished local tag, correct the
|
||||||
interface:
|
release note or candidate, and repeat the guards. Never move or recreate a tag
|
||||||
|
that has been published.
|
||||||
|
|
||||||
|
## Publish The Selected Tag
|
||||||
|
|
||||||
|
Push only the selected tag ref. Do not use `git push --tags`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git push origin "refs/tags/$release_version"
|
git push origin \
|
||||||
|
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify Publication
|
## Verify Publication
|
||||||
|
|
||||||
Confirm that the remote tag object matches the local annotated tag and still
|
Compare the remote annotated-tag object with the local object, then compare the
|
||||||
resolves to the intended source commit:
|
remote peeled source commit with the validated commit:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
remote_tag=$(git ls-remote --tags origin "refs/tags/$release_version" | awk '{print $1}')
|
remote_tag=$(
|
||||||
test "$remote_tag" = "$(git rev-parse "refs/tags/$release_version")"
|
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
|
||||||
test "$(git rev-list -n 1 "refs/tags/$release_version")" = "$release_commit"
|
awk 'NR == 1 { print $1 }'
|
||||||
|
)
|
||||||
|
remote_commit=$(
|
||||||
|
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" |
|
||||||
|
awk 'NR == 1 { print $1 }'
|
||||||
|
)
|
||||||
|
test -n "$remote_tag"
|
||||||
|
test "$remote_tag" = \
|
||||||
|
"$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")"
|
||||||
|
test "$remote_commit" = "$RELEASE_COMMIT"
|
||||||
```
|
```
|
||||||
|
|
||||||
Promptkit must publish the required tag before Scriptorium or another consumer
|
Finally, resolve the version as an ordinary Go module in a temporary module
|
||||||
publishes a release that depends on that version. Released consumer modules
|
outside this repository and without a workspace or replacement:
|
||||||
must not use a local replacement or unpublished Promptkit revision.
|
|
||||||
|
```sh
|
||||||
|
resolution_dir=$(mktemp -d)
|
||||||
|
(
|
||||||
|
trap 'rm -rf "$resolution_dir"' 0 1 2 15
|
||||||
|
cd "$resolution_dir"
|
||||||
|
GOWORK=off go mod init example.com/promptkit-release-check
|
||||||
|
GOWORK=off go mod download \
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
|
||||||
|
resolved_version=$(
|
||||||
|
GOWORK=off go list -m -f '{{.Version}}' \
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
|
||||||
|
)
|
||||||
|
test "$resolved_version" = "$RELEASE_VERSION"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Promptkit must publish and verify the required version before Scriptorium or
|
||||||
|
another consumer publishes a release that depends on it. This ordering does
|
||||||
|
not replace the consumer project's own release procedure. Released consumers
|
||||||
|
must select the published Promptkit tag through ordinary module resolution,
|
||||||
|
without a workspace, replacement, vendored Promptkit source, or unpublished
|
||||||
|
revision.
|
||||||
|
|
||||||
## Policy Changes
|
## Policy Changes
|
||||||
|
|
||||||
|
|||||||
243
docs/releases/v0.2.0.md
Normal file
243
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Promptkit v0.2.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.1.0` to `v0.2.0`. The annotated `v0.2.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.2.0` adds three major capabilities:
|
||||||
|
|
||||||
|
- an engine-scoped registry for reusable OpenAI-compatible backend
|
||||||
|
definitions;
|
||||||
|
- bounded, backend-specific run admission and model-generation concurrency;
|
||||||
|
and
|
||||||
|
- direct per-run session IDs and tri-state reasoning-effort overrides.
|
||||||
|
|
||||||
|
Existing endpoint-only profiles remain supported. Consumers can adopt backend
|
||||||
|
registration and runtime overrides incrementally rather than rewriting all
|
||||||
|
profiles during the upgrade.
|
||||||
|
|
||||||
|
## Compatibility At A Glance
|
||||||
|
|
||||||
|
Promptkit remains pre-`v1`, and this minor release includes source-level and
|
||||||
|
behavioral changes that deserve review.
|
||||||
|
|
||||||
|
| Area | `v0.1.0` consumer impact |
|
||||||
|
| --- | --- |
|
||||||
|
| Endpoint-only profiles | Continue to work without migration. |
|
||||||
|
| Built-in profiles | Continue to use OpenRouter and `OPENROUTER_API_KEY`; they now select the built-in `openrouter` backend. |
|
||||||
|
| Custom backends | Registration is optional. Existing profiles may keep their endpoint and credential configuration. |
|
||||||
|
| Reasoning overrides | String assignments must migrate to the new pointer field. |
|
||||||
|
| `RunRequest.Metadata` | Removed; delete assignments to this field. |
|
||||||
|
| OpenRouter concurrency | Now limited to 16 active generations with waiting capacity of 1024 per engine. |
|
||||||
|
| Public JSON | `v0.2.0` formalizes supported JSON representations; consumers relying on `v0.1.0` encodings should review the notes below. |
|
||||||
|
| Unkeyed public struct literals | May require updates because fields were added. Keyed literals are recommended. |
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
After the `v0.2.0` tag is published, update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.2.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary tests and race-enabled tests after the
|
||||||
|
upgrade, especially if it calls one engine concurrently or persists Promptkit
|
||||||
|
JSON values.
|
||||||
|
|
||||||
|
## Backend Registry
|
||||||
|
|
||||||
|
Consumers may now register reusable OpenAI-compatible backend definitions with
|
||||||
|
`WithBackend`, then select them by ID from file-backed or in-memory profiles.
|
||||||
|
A backend can supply its endpoint, API-key environment-variable name,
|
||||||
|
request-wide extra parameters, and optional capacity policy.
|
||||||
|
|
||||||
|
Registrations are immutable and belong to one engine. Consumer registrations
|
||||||
|
can add new IDs but cannot replace Promptkit's reserved `openrouter` backend.
|
||||||
|
Profiles that select a backend may still override its endpoint without losing
|
||||||
|
the backend's routing or capacity identity.
|
||||||
|
|
||||||
|
An existing endpoint-only in-memory profile remains valid:
|
||||||
|
|
||||||
|
```go
|
||||||
|
promptkit.Profile{
|
||||||
|
ID: "local",
|
||||||
|
Endpoint: "http://localhost:8000/v1",
|
||||||
|
Model: "example-model",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adopting the registry is optional and can be done when several profiles should
|
||||||
|
share connection or capacity settings:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: "prompts"},
|
||||||
|
promptkit.WithBackend(promptkit.Backend{
|
||||||
|
ID: "local",
|
||||||
|
Endpoint: "http://localhost:8000/v1",
|
||||||
|
APIKeyEnv: "LOCAL_LLM_API_KEY",
|
||||||
|
}),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "local-summary",
|
||||||
|
BackendID: "local",
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
See the
|
||||||
|
[local-endpoint consumer guide](../consumers/pkg-promptkit.md#configure-a-local-openai-compatible-endpoint)
|
||||||
|
for task-oriented usage. The
|
||||||
|
[`Backend` and `WithBackend` GoDoc](../../backends.go) owns exact registration,
|
||||||
|
validation, copying, defaulting, and uniqueness semantics. The
|
||||||
|
[framework format reference](../formats.md) owns the profile `backend` field
|
||||||
|
and execution precedence.
|
||||||
|
|
||||||
|
## Backend-Specific Concurrency
|
||||||
|
|
||||||
|
Each registered backend may now define:
|
||||||
|
|
||||||
|
- an active model-generation limit; and
|
||||||
|
- a bounded number of additional admitted `Run` calls.
|
||||||
|
|
||||||
|
Promptkit owns scheduling for both its built-in model client and an injected
|
||||||
|
`LLMClient`. `Run` remains synchronous: an admitted caller waits for its
|
||||||
|
ordinary result, while a call beyond the bounded admission capacity returns
|
||||||
|
`ErrCapacityExceeded`. Capacity is engine-local and keyed by backend ID.
|
||||||
|
Endpoint-only profiles and custom backends without a configured limit remain
|
||||||
|
unlimited.
|
||||||
|
|
||||||
|
The built-in OpenRouter backend now permits 16 active generations and 1024
|
||||||
|
additional admitted calls per engine. Applications that can exceed this bound
|
||||||
|
should handle capacity exhaustion separately from provider and request
|
||||||
|
failures:
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
// Apply application-specific overload or retry policy.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Promptkit does not prescribe retries or map this error to an HTTP status. See
|
||||||
|
the
|
||||||
|
[concurrency consumer guidance](../consumers/pkg-promptkit.md#limit-backend-concurrency)
|
||||||
|
and the [`Backend` GoDoc](../../backends.go) for the canonical configuration
|
||||||
|
contract. Runtime behavior and public error identities belong to the
|
||||||
|
[`Engine.Run` GoDoc](../../engine.go).
|
||||||
|
|
||||||
|
## Per-Run Session IDs
|
||||||
|
|
||||||
|
`RunRequest.SessionID` can now supply a consumer-managed correlation ID for one
|
||||||
|
`Prepare` or `Run` invocation. A nonblank direct value overrides the prompt's
|
||||||
|
session template and is exposed in prepared values, results, injected-client
|
||||||
|
requests, and provider observability. Session IDs should therefore be stable,
|
||||||
|
non-secret values.
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := engine.Run(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
SessionID: "conversation-42",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The built-in OpenAI-compatible client sends a nonempty effective session as the
|
||||||
|
top-level `session_id` request-body field, not as an `x-session-id` header. See
|
||||||
|
the
|
||||||
|
[session and reasoning consumer guide](../consumers/pkg-promptkit.md#set-a-per-run-session-and-reasoning),
|
||||||
|
the [`RunRequest` GoDoc](../../types.go), and the
|
||||||
|
[OpenAI-compatible request contract](../integrations/openai-compatible-chat.md#request-body)
|
||||||
|
for exact normalization, length, exposure, and wire behavior.
|
||||||
|
|
||||||
|
## Per-Run Reasoning Effort
|
||||||
|
|
||||||
|
`ExecutionTargetOverride.ReasoningEffort` changed from `string` to `*string` so
|
||||||
|
one request can distinguish inheritance, replacement, and explicit clearing.
|
||||||
|
|
||||||
|
Update a `v0.1.0` override like this:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// v0.1.0
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// v0.2.0
|
||||||
|
reasoning := "high"
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{
|
||||||
|
ReasoningEffort: &reasoning,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The three states are:
|
||||||
|
|
||||||
|
- `nil` inherits the selected profile's value;
|
||||||
|
- a pointer to a nonblank string replaces it for that invocation; and
|
||||||
|
- a pointer to an empty or whitespace-only string clears it for that
|
||||||
|
invocation.
|
||||||
|
|
||||||
|
This allows consumers to consolidate profiles that differed only by reasoning
|
||||||
|
effort. The [`ExecutionTargetOverride` GoDoc](../../types.go) owns the exact
|
||||||
|
override contract.
|
||||||
|
|
||||||
|
## Other Migration Notes
|
||||||
|
|
||||||
|
### Remove `RunRequest.Metadata`
|
||||||
|
|
||||||
|
`RunRequest.Metadata` is no longer part of the public request. Remove any
|
||||||
|
assignment to that field. Use application-owned state keyed by `RunResult.RunID`
|
||||||
|
or a direct `SessionID` when correlation is needed; these identifiers have
|
||||||
|
different purposes, so choose according to the application's lifecycle.
|
||||||
|
|
||||||
|
### Review Persisted JSON
|
||||||
|
|
||||||
|
`v0.2.0` defines stable JSON representations for the public result, artifact,
|
||||||
|
execution, validation, and model-client values listed in the
|
||||||
|
[package documentation](../../doc.go). Consumers that treated `v0.1.0`
|
||||||
|
reflection-derived encodings as stable should update fixtures and stored-data
|
||||||
|
adapters.
|
||||||
|
|
||||||
|
In particular:
|
||||||
|
|
||||||
|
- `RunResult` encodes elapsed time as integer milliseconds in `duration_ms`
|
||||||
|
instead of encoding `time.Duration` under `duration`;
|
||||||
|
- result JSON can include the new `session_id` and `selected_backend_id`
|
||||||
|
fields;
|
||||||
|
- execution-target JSON can include `backend_id`; and
|
||||||
|
- artifact and target-presence fields now use their documented lower-case
|
||||||
|
names.
|
||||||
|
|
||||||
|
The `v0.2.0` `RunResult` decoder reads `duration_ms`; it does not translate a
|
||||||
|
persisted `v0.1.0` `duration` field. Transform old payloads before decoding
|
||||||
|
when preserving their elapsed duration matters.
|
||||||
|
|
||||||
|
### Prefer Keyed Struct Literals
|
||||||
|
|
||||||
|
New fields were added to several public structs. Replace positional composite
|
||||||
|
literals with keyed literals so future additive fields do not cause another
|
||||||
|
source migration.
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- Update the module dependency and run the consumer's tests.
|
||||||
|
- Change reasoning overrides from strings to pointers.
|
||||||
|
- Remove uses of `RunRequest.Metadata`.
|
||||||
|
- Review unkeyed Promptkit struct literals.
|
||||||
|
- Decide whether shared endpoints should move into registered backends.
|
||||||
|
- If using built-in OpenRouter profiles at high concurrency, handle
|
||||||
|
`ErrCapacityExceeded` and review the new engine-local bound.
|
||||||
|
- Review stored JSON, fixtures, and downstream decoders.
|
||||||
|
- Optionally replace profile-specific session or reasoning variants with
|
||||||
|
per-run overrides.
|
||||||
|
|
||||||
|
For complete consumer workflows, use the
|
||||||
|
[package consumer guide](../consumers/pkg-promptkit.md) and maintained
|
||||||
|
[offline execution example](../../examples/go-library/run/main.go).
|
||||||
74
docs/releases/v0.3.0.md
Normal file
74
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Promptkit v0.3.0
|
||||||
|
|
||||||
|
This supplemental changelog summarizes the consumer-facing changes from
|
||||||
|
`v0.2.0` to `v0.3.0`. The annotated `v0.3.0` tag is the authoritative release
|
||||||
|
record. Exact current contracts belong to the linked GoDoc and durable
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.3.0` adds a concise way to register the common local OpenAI-compatible
|
||||||
|
backend configuration:
|
||||||
|
|
||||||
|
- `BackendLocal` provides the conventional, non-reserved backend ID `"local"`;
|
||||||
|
and
|
||||||
|
- `LocalBackend` constructs an ordinary `Backend` from an endpoint and
|
||||||
|
concurrency limit.
|
||||||
|
|
||||||
|
The helper is explicit and additive. It does not pre-register a backend, read
|
||||||
|
environment variables, select a model, or replace the complete `Backend`
|
||||||
|
configuration interface.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Existing `v0.2.0` consumers require no migration. Endpoint-only profiles,
|
||||||
|
complete custom `Backend` values, the built-in OpenRouter backend, and existing
|
||||||
|
registrations using the literal ID `"local"` continue to work unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.3.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary tests and race-enabled tests after the
|
||||||
|
upgrade.
|
||||||
|
|
||||||
|
## Configure A Local Backend
|
||||||
|
|
||||||
|
Register the convenience value through the existing `WithBackend` option and
|
||||||
|
select it from one or more profiles:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: "prompts"},
|
||||||
|
promptkit.WithBackend(
|
||||||
|
promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||||
|
),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "local-summary",
|
||||||
|
BackendID: promptkit.BackendLocal,
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an endpoint-only profile when shared backend identity and capacity policy
|
||||||
|
are unnecessary. Continue to use a complete keyed `Backend` value for custom
|
||||||
|
IDs, authentication, extra request parameters, explicit queue capacity, or
|
||||||
|
multiple local endpoints.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[local-endpoint consumer guide](../consumers/pkg-promptkit.md#configure-a-local-openai-compatible-endpoint)
|
||||||
|
for task-oriented configuration choices. The
|
||||||
|
[`BackendLocal`, `LocalBackend`, and `WithBackend` GoDoc](../../backends.go)
|
||||||
|
owns their exact construction, registration, validation, and concurrency
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
None. Adopt the convenience constructor when it simplifies local endpoint
|
||||||
|
configuration.
|
||||||
189
docs/releases/v0.4.0.md
Normal file
189
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Promptkit v0.4.0
|
||||||
|
|
||||||
|
This supplemental changelog and adoption guide summarizes the consumer-facing
|
||||||
|
changes from `v0.3.0` to `v0.4.0`. The annotated `v0.4.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.4.0` adds four complementary capabilities:
|
||||||
|
|
||||||
|
- opaque prepared-execution handles for preparing once, inspecting safe
|
||||||
|
details, and executing the same frozen snapshot;
|
||||||
|
- exact prompt-definition inspection without profile resolution or execution;
|
||||||
|
- exact profile inspection without selecting a prompt or checking credential
|
||||||
|
availability; and
|
||||||
|
- structured backend identity on engine admission-capacity rejection.
|
||||||
|
|
||||||
|
These APIs let consumers perform more precise preflight work and retain useful
|
||||||
|
operational context without reproducing Promptkit's internal resolution logic.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The release is additive for `v0.3.0` consumers. Existing uses of `Prepare`,
|
||||||
|
`Run`, backend registration, endpoint-only profiles, local-backend helpers,
|
||||||
|
runtime overrides, public JSON values, and error sentinels continue to work
|
||||||
|
without migration.
|
||||||
|
|
||||||
|
Capacity rejection now returns a structured error while continuing to match
|
||||||
|
`ErrCapacityExceeded` through `errors.Is`. Error-string wording and direct
|
||||||
|
sentinel equality were not public contracts.
|
||||||
|
|
||||||
|
The new inspection values, capacity error, and prepared-execution handle do not
|
||||||
|
have stable JSON representations. `PreparedExecution.Details` returns the
|
||||||
|
existing stable `PreparedRun` value.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.4.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary and race-enabled tests after upgrading.
|
||||||
|
No source migration is required.
|
||||||
|
|
||||||
|
## Prepare Once And Execute The Same Snapshot
|
||||||
|
|
||||||
|
Consumers that need to persist preparation details before generation can now
|
||||||
|
prepare an opaque, engine-bound execution:
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
// Handle preparation failure.
|
||||||
|
}
|
||||||
|
defer prepared.Discard()
|
||||||
|
|
||||||
|
details := prepared.Details()
|
||||||
|
// Persist a consumer-selected, appropriately protected preparation record.
|
||||||
|
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preparation freezes the selected sources, rendered messages, effective
|
||||||
|
settings, input content, structured-output metadata, and validation resources
|
||||||
|
needed by execution. `Details` returns a fresh, caller-owned,
|
||||||
|
credential-redacted `PreparedRun`.
|
||||||
|
|
||||||
|
A handle belongs to its creating engine and permits one execution attempt.
|
||||||
|
`RunPrepared` consumes that attempt on success and on operational failure.
|
||||||
|
`Discard` is idempotent and releases an unclaimed handle's execution-only
|
||||||
|
state. Consumers should discard handles they will not execute, particularly
|
||||||
|
when a direct request API key may be retained privately until claim or
|
||||||
|
discard.
|
||||||
|
|
||||||
|
Prepared execution does not reserve backend admission during preparation.
|
||||||
|
Credential availability and backend admission are checked when execution
|
||||||
|
begins. The execution context is independent of the preparation context.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[prepared-execution consumer guide](../consumers/pkg-promptkit.md#prepare-now-and-execute-the-same-snapshot-later),
|
||||||
|
the [`PreparedExecution` GoDoc](../../prepared_execution.go), and the
|
||||||
|
[`Engine.PrepareExecution` and `Engine.RunPrepared` GoDoc](../../engine.go)
|
||||||
|
for the exact lifecycle, ownership, cancellation, capacity, timing, and
|
||||||
|
failure contracts.
|
||||||
|
|
||||||
|
## Inspect A Prompt
|
||||||
|
|
||||||
|
`Engine.InspectPrompt` resolves one prompt ID and optional version through the
|
||||||
|
engine's configured prompt source:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectPrompt(ctx, "report.summary", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result includes prompt identity, the opaque prompt hash, declared default
|
||||||
|
profile ID, declared input metadata, and normalized output contract. It
|
||||||
|
structurally loads the selected definition and referenced message content but
|
||||||
|
does not resolve a profile, load schemas or artifacts, render templates,
|
||||||
|
reserve capacity, or contact a model.
|
||||||
|
|
||||||
|
Use inspection for exact configuration checks and metadata discovery. Use
|
||||||
|
`PrepareExecution` rather than relying on a prior inspection when later
|
||||||
|
execution must freeze one exact source state, because filesystem-backed
|
||||||
|
inspection is only a point-in-time lookup.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[prompt-inspection consumer guide](../consumers/pkg-promptkit.md#inspect-a-prompt-before-preparation)
|
||||||
|
and [`Engine.InspectPrompt` GoDoc](../../engine.go) for exact selection,
|
||||||
|
ownership, and error behavior.
|
||||||
|
|
||||||
|
## Inspect A Profile
|
||||||
|
|
||||||
|
`Engine.InspectProfile` resolves one explicit profile independently of a
|
||||||
|
prompt:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectProfile(ctx, "report-production")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result includes the resolved effective execution target and whether a
|
||||||
|
later request must provide a direct credential. Environment-variable names may
|
||||||
|
be reported, but inspection does not read credential values or require the
|
||||||
|
named variable to be populated.
|
||||||
|
|
||||||
|
Inspection applies the ordinary configured and built-in profile precedence and
|
||||||
|
resolves any selected backend. It does not load a prompt, render content,
|
||||||
|
reserve capacity, or contact a model.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[profile-inspection consumer guide](../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work)
|
||||||
|
and [`Engine.InspectProfile` GoDoc](../../engine.go) for the exact resolution,
|
||||||
|
credential, ownership, and error contracts.
|
||||||
|
|
||||||
|
## Identify Capacity-Rejected Backends
|
||||||
|
|
||||||
|
Calls rejected at Promptkit's bounded engine admission boundary continue to
|
||||||
|
match `ErrCapacityExceeded`. Consumers can additionally obtain the selected
|
||||||
|
registered backend ID without parsing diagnostic text:
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityErr) {
|
||||||
|
// Record capacityErr.BackendID using application-owned diagnostics.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply application-owned overload or retry policy.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The structured error applies to `Run` and `RunPrepared` admission rejection.
|
||||||
|
It does not represent provider throttling, quota exhaustion, cancellation
|
||||||
|
while waiting for generation capacity, or another model-client failure.
|
||||||
|
Promptkit does not prescribe retry timing or transport status mapping.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[error-handling consumer guide](../consumers/pkg-promptkit.md#handle-errors),
|
||||||
|
the [`CapacityError` GoDoc](../../capacity_error.go), and the
|
||||||
|
[`ErrCapacityExceeded` GoDoc](../../engine.go) for the canonical contracts.
|
||||||
|
|
||||||
|
## Public API Additions
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
|
||||||
|
- `Engine.PrepareExecution`;
|
||||||
|
- `Engine.RunPrepared`;
|
||||||
|
- `PreparedExecution`, including `Details`, `Discard`, `String`, and
|
||||||
|
`GoString`;
|
||||||
|
- `Engine.InspectPrompt`;
|
||||||
|
- `PromptInspection`;
|
||||||
|
- `PromptInputDefinition`;
|
||||||
|
- `Engine.InspectProfile`;
|
||||||
|
- `ProfileInspection`; and
|
||||||
|
- `CapacityError`.
|
||||||
|
|
||||||
|
No public API was removed.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
None. Existing `v0.3.0` workflows may upgrade without adopting the new APIs.
|
||||||
|
|
||||||
|
Consumers that adopt prepared execution should discard unused handles.
|
||||||
|
Consumers that need backend-specific capacity diagnostics may add an
|
||||||
|
`errors.As` check while retaining their existing `errors.Is` classification.
|
||||||
51
docs/roadmap/future.md
Normal file
51
docs/roadmap/future.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Future Feature Ideas
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document catalogs reasonably specific ideas that may be useful in future
|
||||||
|
Promptkit development. It is an idea pool, not a commitment, schedule, or
|
||||||
|
description of current behavior.
|
||||||
|
|
||||||
|
Ideas belong here while they are worth retaining but have not been selected
|
||||||
|
for active development. Keep each entry at the level of intended capability,
|
||||||
|
consumer value, and important scope boundaries. Defer API design,
|
||||||
|
implementation details, sequencing, and acceptance criteria until an idea is
|
||||||
|
selected.
|
||||||
|
|
||||||
|
## Using This Catalog
|
||||||
|
|
||||||
|
- Add an idea when its purpose and likely value can be stated clearly.
|
||||||
|
- Keep entries independent enough that maintainers can evaluate and select
|
||||||
|
them individually.
|
||||||
|
- Note significant dependencies or boundary concerns, but do not turn entries
|
||||||
|
into implementation plans.
|
||||||
|
- Treat inclusion as an invitation to evaluate, not as approval or priority.
|
||||||
|
- When an idea is selected, move its active planning to a focused roadmap or,
|
||||||
|
when it requires a durable architectural decision, an ADR. Update
|
||||||
|
current-state documentation only when implementation lands.
|
||||||
|
- Remove ideas that are no longer relevant. Retain a rejected idea only when
|
||||||
|
its rationale is likely to prevent repeated reconsideration.
|
||||||
|
|
||||||
|
Future capabilities must continue to respect the
|
||||||
|
[architecture policy](../policy/architecture.md), particularly Promptkit's
|
||||||
|
role as an application-neutral library and its boundary with downstream
|
||||||
|
consumers.
|
||||||
|
|
||||||
|
## Ideas
|
||||||
|
|
||||||
|
No ideas currently await selection.
|
||||||
|
|
||||||
|
## Entry Format
|
||||||
|
|
||||||
|
Use a short heading followed by a concise summary. Add focused bullets when
|
||||||
|
they help preserve important scope boundaries without becoming an
|
||||||
|
implementation plan:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Idea name
|
||||||
|
|
||||||
|
Describe the intended capability, who benefits, and the most important scope
|
||||||
|
boundary or dependency.
|
||||||
|
|
||||||
|
- Optionally record an important behavior or boundary.
|
||||||
|
```
|
||||||
245
docs/roadmap/notarius-promptkit-wishlist.md
Normal file
245
docs/roadmap/notarius-promptkit-wishlist.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Notarius PromptKit Wishlist
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document records features and interface changes that would be useful
|
||||||
|
additions to PromptKit from the perspective of the maintainers of Notarius, a
|
||||||
|
downstream application that consumes PromptKit.
|
||||||
|
|
||||||
|
PromptKit now provides the capabilities Notarius currently needs. The
|
||||||
|
remaining deferred ideas are optional opportunities to improve checkpointing
|
||||||
|
and operational observability.
|
||||||
|
|
||||||
|
The examples are API sketches intended to communicate the desired capability,
|
||||||
|
not prescriptive names or finalized Go contracts.
|
||||||
|
|
||||||
|
## Priority 1: Atomic Execution With Prepared Details
|
||||||
|
|
||||||
|
**Disposition:** Implemented through [`Engine.PrepareExecution` and
|
||||||
|
`Engine.RunPrepared`](../../engine.go). See the
|
||||||
|
[consumer guidance](../consumers/pkg-promptkit.md#prepare-now-and-execute-the-same-snapshot-later).
|
||||||
|
A separate `RunDetailed` method is not cataloged.
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Notarius needs both:
|
||||||
|
|
||||||
|
- the completed `RunResult`; and
|
||||||
|
- the rendered messages, effective output contract, hashes, and other
|
||||||
|
preparation details exposed by `PreparedRun`.
|
||||||
|
|
||||||
|
Notarius uses the prepared details to construct redaction-aware debug bundles
|
||||||
|
and retain enough information to diagnose model behavior.
|
||||||
|
|
||||||
|
### Implemented behavior
|
||||||
|
|
||||||
|
Notarius can prepare one frozen execution snapshot, retain a caller-owned and
|
||||||
|
credential-redacted `Details` value for its debug bundle, and execute the same
|
||||||
|
snapshot through `RunPrepared`. The opaque handle is engine-bound and
|
||||||
|
single-use; an unused handle can be released with `Discard`. The consumer
|
||||||
|
guide and exported GoDoc own the exact lifecycle and failure contracts.
|
||||||
|
|
||||||
|
### Value to Notarius
|
||||||
|
|
||||||
|
This removes duplicate work from PromptKit-backed calls and ensures that
|
||||||
|
retained debug material corresponds atomically to the actual execution.
|
||||||
|
|
||||||
|
## Priority 2: Prompt-Independent Profile Inspection
|
||||||
|
|
||||||
|
**Disposition:** Implemented as
|
||||||
|
[`Engine.InspectProfile`](../../engine.go). See the
|
||||||
|
[consumer guidance](../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work).
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Notarius validates configured pipeline profile IDs before beginning a run. It
|
||||||
|
needs to determine whether:
|
||||||
|
|
||||||
|
- a profile exists;
|
||||||
|
- its referenced backend is registered;
|
||||||
|
- its execution target can be resolved; and
|
||||||
|
- it declares a credential requirement that the application may need to
|
||||||
|
enforce.
|
||||||
|
|
||||||
|
This validation should not require model generation.
|
||||||
|
|
||||||
|
### Previous integration
|
||||||
|
|
||||||
|
Before profile inspection was available, Notarius constructed a synthetic
|
||||||
|
prompt using `testing/fstest.MapFS`, supplied a dummy transcript, and called
|
||||||
|
`Engine.Prepare` solely to exercise profile and backend resolution.
|
||||||
|
|
||||||
|
### Value to Notarius
|
||||||
|
|
||||||
|
The implemented interface eliminates a synthetic production-only prompt
|
||||||
|
fixture and establishes a direct, supported contract for configuration-time
|
||||||
|
profile and backend validation.
|
||||||
|
|
||||||
|
## Priority 3: Semantic Execution-Target Fingerprints
|
||||||
|
|
||||||
|
**Disposition:** Deferred pending a separate semantic-equality design for
|
||||||
|
resolved execution targets.
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Notarius checkpoints model-backed pipeline stages. A checkpoint must not be
|
||||||
|
reused when generation-affecting PromptKit configuration changes.
|
||||||
|
|
||||||
|
Notarius therefore needs a stable equality signal for the effective profile
|
||||||
|
and backend target used by a pipeline.
|
||||||
|
|
||||||
|
### Current integration
|
||||||
|
|
||||||
|
Notarius currently constructs this identity itself from:
|
||||||
|
|
||||||
|
- a manually maintained marker for the PromptKit release and built-in profile
|
||||||
|
catalog;
|
||||||
|
- raw hashes of configured profile files; and
|
||||||
|
- a separate hash of the configured conventional local-backend endpoint.
|
||||||
|
|
||||||
|
This is safe but conservative and coupled to PromptKit details. Raw file
|
||||||
|
hashing also invalidates checkpoints for semantically irrelevant YAML changes,
|
||||||
|
such as comments or formatting.
|
||||||
|
|
||||||
|
### Requested capability
|
||||||
|
|
||||||
|
Expose an opaque semantic digest for a resolved profile and its effective
|
||||||
|
generation target. It could be returned by the proposed profile-resolution
|
||||||
|
API:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ResolvedProfile struct {
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
EffectiveTarget ExecutionTarget
|
||||||
|
ExecutionDigest string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, PromptKit could expose a dedicated method such as
|
||||||
|
`ProfileExecutionDigest(profileID)`.
|
||||||
|
|
||||||
|
### Desired equality semantics
|
||||||
|
|
||||||
|
The digest should change when generation-affecting state changes, including:
|
||||||
|
|
||||||
|
- resolved model and endpoint;
|
||||||
|
- backend routing identity;
|
||||||
|
- backend request defaults and extra parameters;
|
||||||
|
- profile generation parameters; and
|
||||||
|
- the semantic identity of any selected built-in profile.
|
||||||
|
|
||||||
|
The digest should not incorporate:
|
||||||
|
|
||||||
|
- credential values;
|
||||||
|
- concurrency or queue capacity;
|
||||||
|
- filesystem source paths;
|
||||||
|
- YAML comments or formatting; or
|
||||||
|
- other settings that affect scheduling or source representation without
|
||||||
|
changing the generation target.
|
||||||
|
|
||||||
|
The credential environment-variable name may need to participate if changing
|
||||||
|
it can select a materially different provider account or target. PromptKit
|
||||||
|
should define this deliberately while continuing to exclude the resolved
|
||||||
|
secret value.
|
||||||
|
|
||||||
|
### Design considerations
|
||||||
|
|
||||||
|
- Treat the digest as an opaque equality value rather than a public encoding
|
||||||
|
of internal structures.
|
||||||
|
- Document which categories of change affect equality.
|
||||||
|
- Include a versioned semantic marker internally so PromptKit can deliberately
|
||||||
|
invalidate old digests when its resolution semantics change.
|
||||||
|
- Prefer a per-profile digest over a digest of every profile known to an
|
||||||
|
engine. Notarius generally knows which profiles a resolved pipeline uses.
|
||||||
|
- Do not require consumers to know PromptKit's built-in catalog version.
|
||||||
|
|
||||||
|
### Value to Notarius
|
||||||
|
|
||||||
|
This would let Notarius remove its PromptKit release marker and raw
|
||||||
|
profile-source fingerprinting, reduce unnecessary checkpoint invalidation, and
|
||||||
|
delegate execution-target equality to the component that owns target
|
||||||
|
resolution.
|
||||||
|
|
||||||
|
## Priority 4: Structured Capacity Errors
|
||||||
|
|
||||||
|
**Disposition:** Implemented behavior. See the consumer guide's
|
||||||
|
[Handle Errors](../consumers/pkg-promptkit.md#handle-errors) section.
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Notarius translates PromptKit backend-capacity rejection into a
|
||||||
|
provider-neutral application error. When multiple backends are active,
|
||||||
|
operators would benefit from knowing which backend rejected admission without
|
||||||
|
parsing an error string or exposing endpoint details.
|
||||||
|
|
||||||
|
### Implemented behavior
|
||||||
|
|
||||||
|
PromptKit now retains the broad capacity classification while allowing
|
||||||
|
Notarius to obtain the selected backend ID without parsing diagnostic text.
|
||||||
|
The consumer guide owns the application workflow, including retry and backoff
|
||||||
|
policy.
|
||||||
|
|
||||||
|
### Value to Notarius
|
||||||
|
|
||||||
|
This improves operational diagnostics and future metrics while preserving
|
||||||
|
the provider-neutral error boundary used by Notarius.
|
||||||
|
|
||||||
|
## Capabilities PromptKit Already Provides Well
|
||||||
|
|
||||||
|
The current PromptKit boundary is sufficient for Notarius's implemented
|
||||||
|
behavior. In particular, PromptKit already provides:
|
||||||
|
|
||||||
|
- filesystem, `fs.FS`, and programmatic prompt, profile, and schema sources;
|
||||||
|
- offline preparation without model execution;
|
||||||
|
- structured output and content validation;
|
||||||
|
- direct session propagation;
|
||||||
|
- tri-state per-run reasoning overrides;
|
||||||
|
- selected profile, backend, model, endpoint, effective parameters, hashes, and
|
||||||
|
token-usage provenance;
|
||||||
|
- endpoint-only profiles;
|
||||||
|
- the conventional `local` backend helper;
|
||||||
|
- arbitrary engine-scoped `Backend` registrations;
|
||||||
|
- backend authentication environment names, extra parameters, concurrency
|
||||||
|
limits, and queue-capacity policies;
|
||||||
|
- provider-client and artifact-reader extension interfaces;
|
||||||
|
- context cancellation; and
|
||||||
|
- useful public error sentinels, including profile absence and capacity
|
||||||
|
exhaustion.
|
||||||
|
|
||||||
|
The wishlist does not imply that Notarius needs PromptKit to broaden its core
|
||||||
|
responsibilities. It primarily asks for more direct access to information and
|
||||||
|
operations that PromptKit already computes internally.
|
||||||
|
|
||||||
|
## Responsibilities That Should Remain In Notarius
|
||||||
|
|
||||||
|
The following concerns belong to the downstream application and should not
|
||||||
|
move into PromptKit for the sake of Notarius:
|
||||||
|
|
||||||
|
- pipeline staging, dependencies, and generated references;
|
||||||
|
- application-wide scheduling across providers and backends;
|
||||||
|
- module and validation retry policy;
|
||||||
|
- checkpoints, resume, and recomputation;
|
||||||
|
- durable run artifacts and manifests;
|
||||||
|
- D&D prompts, schemas, extractors, validators, and normalizers;
|
||||||
|
- Notarius configuration-file parsing and precedence;
|
||||||
|
- domain-specific prompt-cache prefix policy; and
|
||||||
|
- application-specific redaction, retention, and debug-bundle policy.
|
||||||
|
|
||||||
|
PromptKit's complete `Backend` API already supports custom IDs, multiple local
|
||||||
|
endpoints, authentication, extra parameters, and explicit queue policies.
|
||||||
|
Whether Notarius exposes those capabilities in its own configuration is an
|
||||||
|
application-policy decision, not an upstream PromptKit gap.
|
||||||
|
|
||||||
|
## Suggested Upstream Sequence
|
||||||
|
|
||||||
|
For downstream adoption and any remaining upstream work, the useful order is:
|
||||||
|
|
||||||
|
1. Adopt prepared execution for atomic details and results.
|
||||||
|
2. Add a semantic execution-target digest, preferably alongside profile
|
||||||
|
inspection.
|
||||||
|
3. Use the implemented typed capacity error where backend admission diagnostics
|
||||||
|
are needed.
|
||||||
|
|
||||||
|
The first removes the concrete execution workaround. The second would improve
|
||||||
|
checkpoint correctness and reduce coupling. The third is operational polish.
|
||||||
331
docs/roadmap/weatherreporter-promptkit-wishlist.md
Normal file
331
docs/roadmap/weatherreporter-promptkit-wishlist.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Weatherreporter PromptKit Wishlist
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document records features and interface changes that would be useful
|
||||||
|
additions to PromptKit from the perspective of the maintainers of
|
||||||
|
Weatherreporter, a downstream application planning to replace its Scriptorium
|
||||||
|
CLI integration with PromptKit.
|
||||||
|
|
||||||
|
PromptKit now provides the capabilities Weatherreporter needs for the
|
||||||
|
migration. The remaining deferred ideas are optional opportunities to validate
|
||||||
|
configuration earlier and improve durable failure diagnostics.
|
||||||
|
|
||||||
|
The examples are API sketches intended to communicate the desired capability,
|
||||||
|
not prescriptive names or finalized Go contracts. The related
|
||||||
|
[Notarius PromptKit wishlist](notarius-promptkit-wishlist.md) proposes several
|
||||||
|
overlapping features from another downstream consumer's perspective.
|
||||||
|
|
||||||
|
## Priority 1: Executable Preparation Handles
|
||||||
|
|
||||||
|
**Disposition:** Implemented as [`Engine.PrepareExecution` and
|
||||||
|
`Engine.RunPrepared`](../../engine.go). See the
|
||||||
|
[consumer guidance](../consumers/pkg-promptkit.md#prepare-now-and-execute-the-same-snapshot-later).
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Weatherreporter treats prompt preparation as a durable preflight boundary. It
|
||||||
|
needs to:
|
||||||
|
|
||||||
|
1. prepare the exact request that will be executed;
|
||||||
|
2. persist a safe preparation record before starting the provider call; and
|
||||||
|
3. execute without reloading or rerendering prompt, profile, schema, or input
|
||||||
|
sources.
|
||||||
|
|
||||||
|
Persisting preflight before generation leaves useful evidence when a provider
|
||||||
|
call fails or the process is interrupted during generation.
|
||||||
|
|
||||||
|
### Implemented behavior
|
||||||
|
|
||||||
|
Weatherreporter can prepare one frozen execution snapshot, persist a
|
||||||
|
caller-owned and credential-redacted `Details` value, and execute that same
|
||||||
|
snapshot through `RunPrepared`. The opaque handle is engine-bound and
|
||||||
|
single-use; an unused handle can be released with `Discard`. The consumer
|
||||||
|
guide and exported GoDoc own the exact lifecycle, credential, cancellation,
|
||||||
|
and capacity contracts.
|
||||||
|
|
||||||
|
### Value to Weatherreporter
|
||||||
|
|
||||||
|
This preserves Weatherreporter's durable preflight behavior, removes duplicate
|
||||||
|
work, eliminates the source-consistency window, and ensures that persisted
|
||||||
|
provenance describes the actual execution.
|
||||||
|
|
||||||
|
## Priority 2: Prompt-Definition Inspection
|
||||||
|
|
||||||
|
**Disposition:** Implemented as
|
||||||
|
[`Engine.InspectPrompt`](../../engine.go). See the
|
||||||
|
[consumer guidance](../consumers/pkg-promptkit.md#inspect-a-prompt-before-preparation).
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Weatherreporter has a fixed registry of seven report definitions. Each report
|
||||||
|
selects a prompt ID and one of two output workflows:
|
||||||
|
|
||||||
|
- direct Markdown; or
|
||||||
|
- structured generated text followed by application-owned domain validation
|
||||||
|
and Markdown template rendering.
|
||||||
|
|
||||||
|
Weatherreporter will embed the PromptKit prompt definitions and private
|
||||||
|
response schemas that implement those reports. It needs to validate that the
|
||||||
|
report registry and embedded prompt corpus agree before weather collection or
|
||||||
|
provider execution.
|
||||||
|
|
||||||
|
### Previous integration option
|
||||||
|
|
||||||
|
Before prompt inspection was available, Weatherreporter could maintain
|
||||||
|
synthetic data-package fixtures and call `Engine.Prepare` for every report
|
||||||
|
prompt during tests. Runtime validation could also occur through the ordinary
|
||||||
|
per-report preparation stage.
|
||||||
|
|
||||||
|
This required complete placeholder inputs and profile resolution when the
|
||||||
|
application primarily wanted to inspect prompt identity and declared contracts.
|
||||||
|
|
||||||
|
### Value to Weatherreporter
|
||||||
|
|
||||||
|
The implemented interface lets Weatherreporter directly verify that every
|
||||||
|
report prompt exists, requires the curated `data_package` input, and declares
|
||||||
|
the expected Markdown or JSON Schema output contract. It reduces synthetic
|
||||||
|
test setup and moves failures ahead of weather collection.
|
||||||
|
|
||||||
|
## Priority 3: Prompt-Independent Profile Inspection
|
||||||
|
|
||||||
|
**Disposition:** Implemented as
|
||||||
|
[`Engine.InspectProfile`](../../engine.go). See the
|
||||||
|
[consumer guidance](../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work).
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Weatherreporter will allow operators to select an external PromptKit profile
|
||||||
|
source and may allow an explicit profile override. It needs to reject a missing
|
||||||
|
profile, unknown backend, or malformed execution target before collecting
|
||||||
|
weather data or writing report artifacts, and to apply its own policy to a
|
||||||
|
reported credential requirement.
|
||||||
|
|
||||||
|
### Value to Weatherreporter
|
||||||
|
|
||||||
|
The implemented interface improves fail-fast configuration validation and gives
|
||||||
|
operator-facing errors direct profile and backend context. It remains optional
|
||||||
|
for the initial migration.
|
||||||
|
|
||||||
|
## Priority 4: Eager Source Validation
|
||||||
|
|
||||||
|
**Disposition:** Deferred until prompt and profile inspection have been used
|
||||||
|
to determine whether a broader engine-wide validation operation is still
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
PromptKit deliberately defers reading and validating filesystem and `fs.FS`
|
||||||
|
prompt, profile, and schema content until a request needs it. Weatherreporter
|
||||||
|
has a small fixed embedded prompt corpus and one optional external profile
|
||||||
|
source. It would benefit from an explicit offline validation operation for
|
||||||
|
tests, startup diagnostics, and configuration checks.
|
||||||
|
|
||||||
|
### Current integration option
|
||||||
|
|
||||||
|
Weatherreporter can prepare every report prompt with fixture inputs and inspect
|
||||||
|
any explicit profiles individually. That provides strong coverage but requires
|
||||||
|
consumer-maintained traversal and synthetic material.
|
||||||
|
|
||||||
|
### Requested capability
|
||||||
|
|
||||||
|
Consider an opt-in source-validation operation:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SourceValidationOptions struct {
|
||||||
|
RequireCredentials bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ValidateSources(
|
||||||
|
ctx context.Context,
|
||||||
|
opts SourceValidationOptions,
|
||||||
|
) error
|
||||||
|
```
|
||||||
|
|
||||||
|
The operation should eagerly discover and structurally validate the configured
|
||||||
|
prompt, profile, and schema sources without model generation.
|
||||||
|
|
||||||
|
### Design considerations
|
||||||
|
|
||||||
|
- Keep deferred validation as the normal `NewEngine` behavior.
|
||||||
|
- Make eager validation an explicit consumer choice.
|
||||||
|
- Validate duplicate IDs and versions, strict YAML decoding, referenced content
|
||||||
|
files, profile/backend membership, schema syntax, and schema references.
|
||||||
|
- Distinguish structural credential declarations from current environment
|
||||||
|
availability.
|
||||||
|
- Do not read or expose credential values when credential availability is not
|
||||||
|
requested.
|
||||||
|
- Preserve source-specific public error identities and useful path context.
|
||||||
|
- Respect context cancellation during filesystem discovery and schema work.
|
||||||
|
- Consider whether exact prompt and profile inspection APIs already provide a
|
||||||
|
smaller sufficient surface before adding an engine-wide operation.
|
||||||
|
|
||||||
|
### Value to Weatherreporter
|
||||||
|
|
||||||
|
This would simplify offline corpus checks and catch malformed operator profile
|
||||||
|
sources before report work begins. It is helpful but lower priority than exact
|
||||||
|
prompt and profile inspection.
|
||||||
|
|
||||||
|
## Priority 5: Structured Generation Errors
|
||||||
|
|
||||||
|
**Disposition:** Deferred pending stronger downstream demand and a narrower
|
||||||
|
design that does not duplicate prepared provenance or impose HTTP-specific
|
||||||
|
fields on injected model clients.
|
||||||
|
|
||||||
|
### Downstream need
|
||||||
|
|
||||||
|
Weatherreporter preserves redacted, inspectable failure receipts for report
|
||||||
|
runs. When model generation fails operationally, it needs to classify the
|
||||||
|
failure and retain safe execution context without parsing error prose.
|
||||||
|
|
||||||
|
Prompt preparation already supplies selected profile, backend, and model
|
||||||
|
identity. Provider status classification would add useful operator context,
|
||||||
|
especially when the built-in OpenAI-compatible client receives a non-success
|
||||||
|
HTTP status.
|
||||||
|
|
||||||
|
### Current integration option
|
||||||
|
|
||||||
|
PromptKit exposes `ErrLLMGenerate` and preserves injected client errors through
|
||||||
|
`errors.Is`. Weatherreporter can reliably classify generation failure and use
|
||||||
|
its preparation record for profile, backend, and model provenance. Any further
|
||||||
|
diagnostic detail remains a redacted error string.
|
||||||
|
|
||||||
|
### Requested capability
|
||||||
|
|
||||||
|
Consider a typed generation error that continues to match `ErrLLMGenerate`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type GenerationError struct {
|
||||||
|
BackendID string
|
||||||
|
Model string
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact fields may differ. The useful contract is safe structured context
|
||||||
|
available through `errors.As`, while `errors.Is(err, ErrLLMGenerate)` remains
|
||||||
|
compatible.
|
||||||
|
|
||||||
|
### Design considerations
|
||||||
|
|
||||||
|
- Include only fields that PromptKit knows reliably and can expose safely.
|
||||||
|
- Treat an HTTP status as optional because injected model clients may not use
|
||||||
|
HTTP.
|
||||||
|
- Do not expose provider response bodies, endpoints, credential environment
|
||||||
|
names, credential values, request content, or generated content.
|
||||||
|
- Do not make a structured error a second source of prompt/profile provenance
|
||||||
|
already present in a prepared execution.
|
||||||
|
- Preserve injected client error identity.
|
||||||
|
- Keep retry and backoff policy with the consuming application.
|
||||||
|
|
||||||
|
### Value to Weatherreporter
|
||||||
|
|
||||||
|
This would improve durable failure receipts and troubleshooting, particularly
|
||||||
|
for built-in transport failures. It is not required if preparation details and
|
||||||
|
the existing sentinel remain available.
|
||||||
|
|
||||||
|
## Lower-Priority Shared Wishlist Items
|
||||||
|
|
||||||
|
### Structured Capacity Errors
|
||||||
|
|
||||||
|
**Disposition:** Implemented behavior. See the consumer guide's
|
||||||
|
[Handle Errors](../consumers/pkg-promptkit.md#handle-errors) section.
|
||||||
|
|
||||||
|
PromptKit now exposes the stable backend ID for capacity rejection without
|
||||||
|
requiring Weatherreporter to parse error text.
|
||||||
|
|
||||||
|
Weatherreporter currently generates batch reports sequentially and constructs
|
||||||
|
one engine per invocation, so engine-local capacity exhaustion is unlikely in
|
||||||
|
the initial design. The typed error becomes more valuable if report generation
|
||||||
|
later becomes concurrent or PromptKit engines become longer-lived.
|
||||||
|
It should not block adoption.
|
||||||
|
|
||||||
|
### Semantic Execution-Target Fingerprints
|
||||||
|
|
||||||
|
**Disposition:** Deferred pending a separate semantic-equality design for
|
||||||
|
resolved execution targets.
|
||||||
|
|
||||||
|
The semantic target digest proposed by the
|
||||||
|
[Notarius wishlist](notarius-promptkit-wishlist.md#priority-3-semantic-execution-target-fingerprints)
|
||||||
|
would provide a compact equality signal for audit metadata.
|
||||||
|
|
||||||
|
Weatherreporter does not currently reuse LLM-dependent checkpoints. Its Recent
|
||||||
|
Changes behavior compares deterministic module snapshots rather than generated
|
||||||
|
reports, so the digest has no immediate cache-correctness role. Existing
|
||||||
|
PromptKit result metadata is sufficient for the initial integration. A digest
|
||||||
|
would still be useful provenance and future-proofing, but it is not a
|
||||||
|
migration priority.
|
||||||
|
|
||||||
|
## Capabilities PromptKit Already Provides Well
|
||||||
|
|
||||||
|
PromptKit already provides the essential Weatherreporter integration surface:
|
||||||
|
|
||||||
|
- importable in-process engine construction;
|
||||||
|
- filesystem, `fs.FS`, and programmatic prompt, profile, and schema sources;
|
||||||
|
- offline preparation without model execution;
|
||||||
|
- prepared execution handles for a durable preflight boundary;
|
||||||
|
- exact prompt and profile inspection;
|
||||||
|
- versioned prompt selection;
|
||||||
|
- text, Markdown, JSON, and JSON Schema output contracts;
|
||||||
|
- single-pass output validation with raw output retained after completed
|
||||||
|
validation failure;
|
||||||
|
- inline input artifacts with provenance URIs and input hashes;
|
||||||
|
- selected profile, backend, model, effective target, prompt hashes, timing,
|
||||||
|
and token-usage provenance;
|
||||||
|
- endpoint-only profiles and engine-scoped backend registration;
|
||||||
|
- injected model-client and artifact-reader interfaces;
|
||||||
|
- caller cancellation, generation timeout, and transport timeout behavior; and
|
||||||
|
- public error sentinels for configuration, prompt, profile, artifact,
|
||||||
|
validation, capacity, and generation failures; and
|
||||||
|
- structured backend identity for capacity rejection.
|
||||||
|
|
||||||
|
These capabilities are sufficient for Weatherreporter to adopt PromptKit
|
||||||
|
without waiting for new upstream work.
|
||||||
|
|
||||||
|
## Responsibilities That Should Remain In Weatherreporter
|
||||||
|
|
||||||
|
The following concerns belong to Weatherreporter and should not move into
|
||||||
|
PromptKit:
|
||||||
|
|
||||||
|
- report definitions, valid periods, batches, and output naming;
|
||||||
|
- application prompt content and private report response schemas;
|
||||||
|
- deterministic weather facts, modules, and Recent Changes;
|
||||||
|
- curated `data_package` construction and persistence;
|
||||||
|
- generated-text domain validation and Markdown template rendering;
|
||||||
|
- managed artifact paths, atomic writes, metadata, and inspection commands;
|
||||||
|
- preparation, execution, raw-output, and failure-receipt schemas;
|
||||||
|
- CLI configuration loading and precedence;
|
||||||
|
- debug enablement, redaction, placement, sensitivity, and retention;
|
||||||
|
- distributor notification;
|
||||||
|
- batch continuation and any future retry policy; and
|
||||||
|
- application-level compatibility and migration policy.
|
||||||
|
|
||||||
|
## Suggested Upstream Sequence
|
||||||
|
|
||||||
|
For downstream adoption and any remaining upstream work, the useful order is:
|
||||||
|
|
||||||
|
1. Adopt the implemented executable preparation handles.
|
||||||
|
2. Consider eager source validation after evaluating whether the two exact
|
||||||
|
inspection APIs are sufficient.
|
||||||
|
3. Add structured generation errors.
|
||||||
|
4. Use structured capacity errors and consider semantic execution-target
|
||||||
|
fingerprints as lower-priority operational improvements.
|
||||||
|
|
||||||
|
The first item removes the material integration workaround. Prompt and profile
|
||||||
|
inspection improve fail-fast validation. The remaining items are optional
|
||||||
|
ergonomic and diagnostic improvements.
|
||||||
|
|
||||||
|
## Adoption Sequencing
|
||||||
|
|
||||||
|
Weatherreporter should not wait for the deferred wishlist items. The current
|
||||||
|
PromptKit interface is sufficient when Weatherreporter:
|
||||||
|
|
||||||
|
- embeds immutable prompt and schema assets;
|
||||||
|
- supplies immutable inline data-package bytes;
|
||||||
|
- constructs one engine per CLI invocation;
|
||||||
|
- prepares an execution, persists selected `Details`, and calls
|
||||||
|
`RunPrepared`; and
|
||||||
|
- keeps PromptKit behind a weatherreporter-owned adapter contract.
|
||||||
|
|
||||||
|
Prompt inspection, profile inspection, source validation, structured errors,
|
||||||
|
capacity details, and semantic fingerprints should not gate adoption.
|
||||||
658
engine.go
Normal file
658
engine.go
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile/builtin"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||||
|
// required configuration, invalid options or backend registrations, and a nil
|
||||||
|
// Engine receiver.
|
||||||
|
var ErrInvalidConfig = errors.New("invalid engine configuration")
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidRequest identifies a request whose required values, overrides,
|
||||||
|
// credentials, or effective settings are invalid.
|
||||||
|
ErrInvalidRequest = errors.New("invalid run request")
|
||||||
|
// ErrPromptNotFound identifies a requested prompt ID or version that is not
|
||||||
|
// present in the selected prompt source. It does not also match
|
||||||
|
// ErrPromptLoad.
|
||||||
|
ErrPromptNotFound = errors.New("prompt not found")
|
||||||
|
// ErrProfileNotFound identifies a selected profile ID that is absent from
|
||||||
|
// every configured profile source. It does not also match ErrProfileLoad.
|
||||||
|
ErrProfileNotFound = errors.New("profile 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.
|
||||||
|
ErrProfileRequired = errors.New("profile selection is required")
|
||||||
|
// ErrPromptLoad identifies a failure to read, decode, validate, select, or
|
||||||
|
// hash a prompt definition, except for the not-found case represented by
|
||||||
|
// ErrPromptNotFound.
|
||||||
|
ErrPromptLoad = errors.New("failed to load prompt definition")
|
||||||
|
// ErrProfileLoad identifies a failure to read, decode, validate, or select
|
||||||
|
// an execution profile or resolve its backend, except for the profile
|
||||||
|
// not-found case represented by ErrProfileNotFound.
|
||||||
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
||||||
|
// ErrAPIKeyEnvMissing identifies an APIKeyEnv whose environment variable is
|
||||||
|
// unset or empty when no direct RunRequest.APIKey takes precedence. Such an
|
||||||
|
// error also matches ErrInvalidRequest.
|
||||||
|
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
||||||
|
// ErrArtifactLoad identifies a failure to resolve an input artifact. Errors
|
||||||
|
// returned by an injected ArtifactReader remain available through errors.Is.
|
||||||
|
ErrArtifactLoad = errors.New("failed to load artifact")
|
||||||
|
// ErrPromptRender identifies a failure to render prompt messages or the
|
||||||
|
// session ID from the resolved inputs and variables.
|
||||||
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
|
// ErrCapacityExceeded identifies a Run or RunPrepared rejected because the
|
||||||
|
// selected backend already admitted ConcurrencyLimit + QueueCapacity calls.
|
||||||
|
// A [CapacityError] reports the selected backend ID. It is not an invalid
|
||||||
|
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||||
|
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
|
// response. Errors returned by an injected LLMClient remain available
|
||||||
|
// through errors.Is.
|
||||||
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
|
// ErrValidation identifies an operational failure to load or compile a
|
||||||
|
// schema or validate output. A completed validation whose Status is
|
||||||
|
// ValidationFailed is returned in RunResult without this error.
|
||||||
|
ErrValidation = errors.New("failed to validate output")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine inspects prompts and profiles and prepares and runs Promptkit prompt
|
||||||
|
// requests.
|
||||||
|
//
|
||||||
|
// An Engine is safe for concurrent calls to [Engine.InspectPrompt],
|
||||||
|
// [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||||
|
// [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent
|
||||||
|
// backend-capacity pools that coordinate Run and RunPrepared admission and
|
||||||
|
// model generation. Injected collaborators may still be invoked concurrently
|
||||||
|
// across different backend pools or for unlimited backends.
|
||||||
|
type Engine struct {
|
||||||
|
runner *usecase.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the directory-backed sources and built-in model-client
|
||||||
|
// transport used by [NewEngine]. Config has no stable JSON representation.
|
||||||
|
type Config struct {
|
||||||
|
// PromptDir is the directory searched recursively for prompt definitions.
|
||||||
|
// It is required unless a WithPromptFS or WithPromptFile option supplies the
|
||||||
|
// prompt source.
|
||||||
|
PromptDir string
|
||||||
|
// ProfileDir is an optional directory whose profiles take precedence over
|
||||||
|
// embedded built-in profiles. An empty value selects only built-ins unless
|
||||||
|
// profile options are also supplied.
|
||||||
|
ProfileDir string
|
||||||
|
// SchemaDir is the root for JSON Schema files. An empty value uses the
|
||||||
|
// current directory. WithSchemaFS or WithSchemaFile replaces this source.
|
||||||
|
SchemaDir string
|
||||||
|
// Timeout is the transport-wide safety cap for the built-in LLM client
|
||||||
|
// when HTTPClient is absent or has a non-positive timeout. A zero or negative
|
||||||
|
// value selects the 10-minute default.
|
||||||
|
Timeout time.Duration
|
||||||
|
// HTTPClient is cloned for the built-in LLM client. Its positive Timeout
|
||||||
|
// takes precedence over Timeout. A zero or negative client Timeout inherits
|
||||||
|
// Timeout or the 10-minute default. The supplied client is not mutated. This
|
||||||
|
// field is ignored when WithLLMClient is used.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option customizes engine construction.
|
||||||
|
//
|
||||||
|
// NewEngine applies options in argument order and ignores nil options. Within
|
||||||
|
// each prompt-source, profile-source, in-memory-profile, schema-source,
|
||||||
|
// model-client, and artifact-reader category, the last non-nil valid option
|
||||||
|
// replaces earlier options in that category. WithBackend is the additive
|
||||||
|
// exception: unique registrations accumulate, and a repeated backend ID is an
|
||||||
|
// error rather than a replacement. An invalid option fails construction even
|
||||||
|
// if a later option would replace it.
|
||||||
|
type Option interface {
|
||||||
|
apply(*engineOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionFunc func(*engineOptions) error
|
||||||
|
|
||||||
|
func (f optionFunc) apply(options *engineOptions) error {
|
||||||
|
return f(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
type engineOptions struct {
|
||||||
|
llmClient llm.Client
|
||||||
|
artifactReader artifactadapter.Reader
|
||||||
|
promptDefs promptdef.Repository
|
||||||
|
profiles profile.Repository
|
||||||
|
memoryProfiles profile.Repository
|
||||||
|
backends []domain.Backend
|
||||||
|
validator validate.Validator
|
||||||
|
promptSource bool
|
||||||
|
profileSource bool
|
||||||
|
memorySource bool
|
||||||
|
validatorSource bool
|
||||||
|
artifactSource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
|
// [Engine.RunPrepared].
|
||||||
|
//
|
||||||
|
// A nil client makes NewEngine fail with ErrInvalidConfig. The Engine schedules
|
||||||
|
// Generate calls according to the selected backend's capacity policy, but the
|
||||||
|
// client may still be called concurrently across different backend pools or for
|
||||||
|
// unlimited backends. The client is not used by [Engine.Prepare] or
|
||||||
|
// [Engine.PrepareExecution].
|
||||||
|
func WithLLMClient(client LLMClient) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if client == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.llmClient = publicLLMClientAdapter{client: client}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithArtifactReader replaces the default reader for every input artifact
|
||||||
|
// reference, regardless of its ArtifactRef.Type.
|
||||||
|
//
|
||||||
|
// A nil reader makes NewEngine fail with ErrInvalidConfig. The reader may be
|
||||||
|
// called concurrently.
|
||||||
|
func WithArtifactReader(reader ArtifactReader) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if reader == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
||||||
|
options.artifactSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFS loads prompt definitions from fsys under root.
|
||||||
|
//
|
||||||
|
// The source uses the same strict prompt YAML rules as configured prompt
|
||||||
|
// directories, and prompt content_file paths resolve within this source.
|
||||||
|
// fsys must be non-nil and root must be non-empty; otherwise NewEngine fails
|
||||||
|
// with ErrInvalidConfig. This option replaces Config.PromptDir and earlier
|
||||||
|
// prompt-source options.
|
||||||
|
func WithPromptFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFile loads prompt definitions from the single prompt file at path.
|
||||||
|
//
|
||||||
|
// Relative prompt content_file paths resolve from the file's directory. path
|
||||||
|
// must name an existing non-directory file when NewEngine applies the option.
|
||||||
|
// This option replaces Config.PromptDir and earlier prompt-source options.
|
||||||
|
func WithPromptFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFS loads execution profiles from fsys under root.
|
||||||
|
//
|
||||||
|
// Profiles from this source overlay built-in profiles. Profile YAML must use
|
||||||
|
// api_key_env for environment-based credentials; raw API keys are rejected.
|
||||||
|
// fsys must be non-nil and root must be non-empty; otherwise NewEngine fails
|
||||||
|
// with ErrInvalidConfig. This option replaces Config.ProfileDir and earlier
|
||||||
|
// file or FS profile-source options, but remains below WithProfiles in
|
||||||
|
// precedence.
|
||||||
|
func WithProfileFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFile loads execution profiles from the single profile file at path.
|
||||||
|
//
|
||||||
|
// The profile overlays built-in profiles. Profile YAML must use api_key_env for
|
||||||
|
// environment-based credentials; raw API keys are rejected. path must name an
|
||||||
|
// existing non-directory file when NewEngine applies the option. This option
|
||||||
|
// replaces Config.ProfileDir and earlier file or FS profile-source options,
|
||||||
|
// but remains below WithProfiles in precedence.
|
||||||
|
func WithProfileFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfiles configures in-memory profiles that take precedence over
|
||||||
|
// configured profile files and built-in profiles.
|
||||||
|
//
|
||||||
|
// NewEngine validates and copies every profile. IDs must be unique within one
|
||||||
|
// call. An invalid profile, duplicate ID, or unsupported ExtraParams value
|
||||||
|
// makes construction fail with ErrInvalidConfig. Repeating WithProfiles
|
||||||
|
// replaces the complete earlier in-memory set rather than merging it.
|
||||||
|
func WithProfiles(profiles ...Profile) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
repo, err := newMemoryProfileRepository(profiles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.memoryProfiles = repo
|
||||||
|
options.memorySource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFS loads JSON Schema documents from fsys under root.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values resolve within this source when schema validation
|
||||||
|
// or structured output is requested. fsys must be non-nil and root must be
|
||||||
|
// non-empty; otherwise NewEngine fails with ErrInvalidConfig. This option
|
||||||
|
// replaces Config.SchemaDir and earlier schema-source options.
|
||||||
|
func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFile loads JSON Schema documents from the single schema file at path.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values refer to the file's base name. path must name an
|
||||||
|
// existing non-directory file when NewEngine applies the option. This option
|
||||||
|
// replaces Config.SchemaDir and earlier schema-source options.
|
||||||
|
func WithSchemaFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
|
//
|
||||||
|
// Options are applied in order according to [Option]. PromptDir is required
|
||||||
|
// unless a prompt-source option is present. Construction validates option
|
||||||
|
// arguments, in-memory profiles, and backend registrations but defers reading
|
||||||
|
// and validating prompt, file-backed profile, and schema contents until Prepare
|
||||||
|
// or Run needs them.
|
||||||
|
//
|
||||||
|
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
||||||
|
// configuration, options, or backend-capacity policies. Each constructed
|
||||||
|
// Engine has independent backend-capacity pools. Construction does not perform
|
||||||
|
// model requests or require credentials.
|
||||||
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||||
|
var options engineOptions
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := opt.apply(&options); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptDefs := options.promptDefs
|
||||||
|
if !options.promptSource {
|
||||||
|
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
|
||||||
|
if options.profileSource {
|
||||||
|
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
|
||||||
|
}
|
||||||
|
if options.memorySource {
|
||||||
|
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
backendRegistry, err := backend.NewRegistry(options.backends)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to construct backend registry: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
capacityManager, err := capacity.NewManager(backendRegistry.CapacityPolicies())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to construct backend capacity manager: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := options.validator
|
||||||
|
if !options.validatorSource {
|
||||||
|
schemaDir := cfg.SchemaDir
|
||||||
|
if strings.TrimSpace(schemaDir) == "" {
|
||||||
|
schemaDir = defaults.SchemaDirDefault
|
||||||
|
}
|
||||||
|
validator = validate.NewStandardValidator(schemaDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
llmClient := options.llmClient
|
||||||
|
if llmClient == nil {
|
||||||
|
var err error
|
||||||
|
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
HTTPClient: cfg.HTTPClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
llmClient = capacity.NewClient(capacityManager, llmClient)
|
||||||
|
|
||||||
|
artifacts := options.artifactReader
|
||||||
|
if !options.artifactSource {
|
||||||
|
artifacts = artifactadapter.NewCompositeReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Engine{
|
||||||
|
runner: usecase.NewRunner(
|
||||||
|
promptDefs,
|
||||||
|
profiles,
|
||||||
|
backendRegistry,
|
||||||
|
artifacts,
|
||||||
|
prompt.NewGoRenderer(),
|
||||||
|
llmClient,
|
||||||
|
validator,
|
||||||
|
capacityManager,
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSource(name string) (fs.FS, string, error) {
|
||||||
|
cleanName := strings.TrimSpace(name)
|
||||||
|
if cleanName == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(cleanName)
|
||||||
|
base := filepath.Base(cleanName)
|
||||||
|
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
info, err := os.Stat(cleanName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
|
||||||
|
}
|
||||||
|
return os.DirFS(dir), filepath.ToSlash(base), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectPrompt resolves one explicit prompt definition without selecting a
|
||||||
|
// profile or starting execution work.
|
||||||
|
//
|
||||||
|
// InspectPrompt requires a nonblank promptID. It passes nonblank promptID and
|
||||||
|
// promptVersion values unchanged to the engine's ordinary, case-sensitive
|
||||||
|
// prompt selection. An empty version succeeds only when that source has one
|
||||||
|
// selected ID; a nonempty version selects one exact ID/version pair. The
|
||||||
|
// configured prompt source is used without merging, fallback, or enumeration.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// 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 definition and observed source state; its spelling, length,
|
||||||
|
// encoding, algorithm, and security properties are not contracts.
|
||||||
|
//
|
||||||
|
// This method does not return prompt bodies, 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
|
||||||
|
// and its input slice are caller-owned. Filesystem-backed inspection is a
|
||||||
|
// point-in-time lookup and does not freeze a definition for later execution.
|
||||||
|
//
|
||||||
|
// A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID
|
||||||
|
// matches ErrInvalidRequest. An absent exact ID or version matches
|
||||||
|
// ErrPromptNotFound and not ErrPromptLoad. Malformed, unreadable, duplicate,
|
||||||
|
// ambiguous, referenced-content, or hashing failures match ErrPromptLoad.
|
||||||
|
// Cancellation during lookup matches ErrPromptLoad while preserving the
|
||||||
|
// context error. InspectPrompt returns no partial result on error.
|
||||||
|
func (e *Engine) InspectPrompt(
|
||||||
|
ctx context.Context,
|
||||||
|
promptID string,
|
||||||
|
promptVersion string,
|
||||||
|
) (*PromptInspection, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspection, err := e.runner.InspectPrompt(ctx, promptID, promptVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainPromptInspection(inspection), 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 selects the prompt and profile, resolves any selected backend and
|
||||||
|
// effective execution settings, resolves the output contract, loads and hashes
|
||||||
|
// inputs, loads structured-output schema metadata when required, and renders
|
||||||
|
// the session ID and messages. The returned PreparedRun is owned by the caller
|
||||||
|
// and never contains a resolved API-key value, model output, or validation
|
||||||
|
// result.
|
||||||
|
//
|
||||||
|
// A nil Engine returns an error matching ErrInvalidConfig. Request and
|
||||||
|
// preparation failures may match ErrInvalidRequest, ErrPromptNotFound,
|
||||||
|
// ErrPromptLoad, ErrProfileNotFound, ErrProfileLoad, ErrProfileRequired,
|
||||||
|
// ErrAPIKeyEnvMissing, ErrArtifactLoad, ErrPromptRender, or ErrValidation as
|
||||||
|
// applicable. Cancellation is passed to the active collaborator and is
|
||||||
|
// reported in the applicable operation category; no general errors.Is
|
||||||
|
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
||||||
|
// error.
|
||||||
|
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := e.runner.Prepare(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainPreparedRun(prepared), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareExecution completely prepares a prompt request without calling the
|
||||||
|
// configured LLMClient or reserving backend admission capacity.
|
||||||
|
//
|
||||||
|
// The returned opaque handle is bound to this Engine and permits one
|
||||||
|
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
||||||
|
// rendered messages, effective settings, inputs, provider structured-output
|
||||||
|
// metadata, and validation resources needed by that invocation. The handle
|
||||||
|
// retains a direct RunRequest.APIKey only in private execution state;
|
||||||
|
// [PreparedExecution.Details] is credential-redacted.
|
||||||
|
//
|
||||||
|
// The context governs preparation only. Cancellation after this method
|
||||||
|
// returns does not invalidate the handle or propagate to RunPrepared.
|
||||||
|
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
||||||
|
// returns no handle on error. A nil Engine returns an error matching
|
||||||
|
// ErrInvalidConfig.
|
||||||
|
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := e.runner.PrepareExecution(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return &PreparedExecution{internal: prepared}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run prepares a request, invokes the configured LLMClient, and validates the
|
||||||
|
// generated output.
|
||||||
|
//
|
||||||
|
// A content-validation failure is a successful run whose
|
||||||
|
// RunResult.Validation has Status ValidationFailed. An inability to perform
|
||||||
|
// validation returns an error matching ErrValidation and no partial result.
|
||||||
|
// The public Engine does not perform output repair, so validation is
|
||||||
|
// single-pass even when OutputContract.RepairAttempts is positive.
|
||||||
|
//
|
||||||
|
// Run can return every error category documented by [Engine.Prepare], plus
|
||||||
|
// ErrCapacityExceeded and ErrLLMGenerate. An engine admission rejection is
|
||||||
|
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
||||||
|
// occurs before artifacts, schemas, rendering, or model generation because the
|
||||||
|
// selected backend's admission capacity is full; it does not match
|
||||||
|
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
||||||
|
// available through errors.Is. Cancellation while waiting for model-generation
|
||||||
|
// capacity matches both ErrLLMGenerate and the context error. Cancellation
|
||||||
|
// otherwise follows the active collaborator's documented behavior. A nil
|
||||||
|
// Engine returns ErrInvalidConfig. Run returns no partial result on error.
|
||||||
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.runner.Run(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainRunResult(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPrepared atomically claims and executes a handle created by
|
||||||
|
// [Engine.PrepareExecution].
|
||||||
|
//
|
||||||
|
// A valid owning-Engine invocation consumes the handle's one attempt before
|
||||||
|
// credential revalidation, backend admission, generation, or validation.
|
||||||
|
// Cancellation, capacity rejection, generation failure, operational
|
||||||
|
// validation failure, and success all leave the handle unusable. A nil,
|
||||||
|
// zero-value, foreign-Engine, discarded, claimed, or used handle returns an
|
||||||
|
// error matching ErrInvalidRequest; a nil Engine returns ErrInvalidConfig and
|
||||||
|
// does not claim the handle.
|
||||||
|
//
|
||||||
|
// The supplied context governs this execution attempt independently of the
|
||||||
|
// preparation context. It covers credential revalidation, admission,
|
||||||
|
// generation, validation, and any internal repair. Result timing begins after
|
||||||
|
// the claim and excludes preparation and consumer-held delay.
|
||||||
|
//
|
||||||
|
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
||||||
|
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||||
|
// preserving documented collaborator and context identities. An engine
|
||||||
|
// admission rejection is discoverable as [CapacityError] and still matches
|
||||||
|
// ErrCapacityExceeded. A completed content-validation rejection is returned
|
||||||
|
// in RunResult, not as an operational error. An operational error returns no
|
||||||
|
// partial RunResult.
|
||||||
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
var internal *usecase.PreparedExecution
|
||||||
|
if prepared != nil {
|
||||||
|
internal = prepared.internal
|
||||||
|
}
|
||||||
|
result, err := e.runner.RunPrepared(ctx, internal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainRunResult(result), nil
|
||||||
|
}
|
||||||
2706
engine_test.go
Normal file
2706
engine_test.go
Normal file
File diff suppressed because it is too large
Load Diff
69
errors.go
Normal file
69
errors.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapPublicError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var internalCapacityError *usecase.CapacityError
|
||||||
|
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
||||||
|
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
||||||
|
return &CapacityError{BackendID: internalCapacityError.BackendID}
|
||||||
|
}
|
||||||
|
publicErr := publicErrorFor(err)
|
||||||
|
if publicErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicErrorFor(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
||||||
|
return ErrPromptNotFound
|
||||||
|
case errors.Is(err, profile.ErrProfileNotFound):
|
||||||
|
return ErrProfileNotFound
|
||||||
|
case errors.Is(err, usecase.ErrProfileRequired):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrProfileRequired)
|
||||||
|
case errors.Is(err, usecase.ErrPromptLoad):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case errors.Is(err, usecase.ErrProfileLoad):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case isProfileLoadCause(err):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
||||||
|
case errors.Is(err, capacity.ErrCapacityExceeded):
|
||||||
|
return ErrCapacityExceeded
|
||||||
|
case errors.Is(err, usecase.ErrArtifactLoad):
|
||||||
|
return ErrArtifactLoad
|
||||||
|
case errors.Is(err, usecase.ErrPromptRender):
|
||||||
|
return ErrPromptRender
|
||||||
|
case errors.Is(err, usecase.ErrLLMGenerate):
|
||||||
|
return ErrLLMGenerate
|
||||||
|
case errors.Is(err, usecase.ErrValidation):
|
||||||
|
return ErrValidation
|
||||||
|
case errors.Is(err, usecase.ErrInvalidRequest):
|
||||||
|
return ErrInvalidRequest
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProfileLoadCause(err error) bool {
|
||||||
|
return errors.Is(err, profile.ErrInvalidYAML) ||
|
||||||
|
errors.Is(err, profile.ErrInvalidProfile) ||
|
||||||
|
errors.Is(err, profile.ErrRawAPIKeyNotAllowed)
|
||||||
|
}
|
||||||
50
errors_internal_test.go
Normal file
50
errors_internal_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
||||||
|
internalErr := fmt.Errorf("%w: %w", usecase.ErrLLMGenerate, context.Canceled)
|
||||||
|
|
||||||
|
err := mapPublicError(internalErr)
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("mapped error=%v, want ErrLLMGenerate", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("mapped error=%v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorTranslatesCapacityError(t *testing.T) {
|
||||||
|
internalErr := &usecase.CapacityError{BackendID: "limited"}
|
||||||
|
|
||||||
|
err := mapPublicError(internalErr)
|
||||||
|
var publicErr *CapacityError
|
||||||
|
if !errors.As(err, &publicErr) || publicErr == nil {
|
||||||
|
t.Fatalf("mapped error=%v, want public CapacityError", err)
|
||||||
|
}
|
||||||
|
if publicErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("mapped backend ID=%q, want limited", publicErr.BackendID)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("mapped error=%v, want ErrCapacityExceeded", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("mapped capacity error has an unrelated category: %v", err)
|
||||||
|
}
|
||||||
|
var leakedInternalErr *usecase.CapacityError
|
||||||
|
if errors.As(err, &leakedInternalErr) {
|
||||||
|
t.Fatalf("mapped error exposes internal CapacityError: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
internalErr.BackendID = "changed"
|
||||||
|
if publicErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("mapped backend ID changed with source error: %q", publicErr.BackendID)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
examples/go-library/prepare/main.go
Normal file
60
examples/go-library/prepare/main.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
PromptVersion string `json:"prompt_version"`
|
||||||
|
SelectedProfile string `json:"selected_profile"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
promptkit.WithPromptFile("examples/go-library/prepare/prompt.yaml"),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "offline-example",
|
||||||
|
Endpoint: "https://example.invalid/v1",
|
||||||
|
Model: "offline-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "example.prepare",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(summary{
|
||||||
|
PromptID: prepared.PromptID,
|
||||||
|
PromptVersion: prepared.PromptVersion,
|
||||||
|
SelectedProfile: prepared.SelectedProfileID,
|
||||||
|
Model: prepared.EffectiveModelParams.Model,
|
||||||
|
MessageCount: len(prepared.Messages),
|
||||||
|
}); err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exit(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
16
examples/go-library/prepare/prompt.yaml
Normal file
16
examples/go-library/prepare/prompt.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
id: example.prepare
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: offline-example
|
||||||
|
description: Prepare a prompt without contacting a model provider.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content: '{{input "note"}}'
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
81
examples/go-library/run/main.go
Normal file
81
examples/go-library/run/main.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deterministicClient struct{}
|
||||||
|
|
||||||
|
func (deterministicClient) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
_ promptkit.GenerateRequest,
|
||||||
|
) (*promptkit.GenerateResponse, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &promptkit.GenerateResponse{
|
||||||
|
Content: "Ada finished the migration review.",
|
||||||
|
Usage: promptkit.TokenUsage{
|
||||||
|
PromptTokens: 12,
|
||||||
|
CompletionTokens: 6,
|
||||||
|
TotalTokens: 18,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
ValidationStatus promptkit.ValidationStatus `json:"validation_status"`
|
||||||
|
IsValid bool `json:"is_valid"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
promptkit.WithPromptFile("examples/go-library/run/prompt.yaml"),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "offline-example",
|
||||||
|
Endpoint: "https://example.invalid/v1",
|
||||||
|
Model: "offline-model",
|
||||||
|
}),
|
||||||
|
promptkit.WithLLMClient(deterministicClient{}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "example.run",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(summary{
|
||||||
|
Output: result.RawOutput,
|
||||||
|
ValidationStatus: result.Validation.Status,
|
||||||
|
IsValid: result.Validation.IsValid,
|
||||||
|
Model: result.ModelName,
|
||||||
|
TotalTokens: result.Usage.TotalTokens,
|
||||||
|
}); err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exit(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
16
examples/go-library/run/prompt.yaml
Normal file
16
examples/go-library/run/prompt.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
id: example.run
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: offline-example
|
||||||
|
description: Run a prompt with a deterministic injected model client.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content: '{{input "note"}}'
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
56
formatting.go
Normal file
56
formatting.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing the direct API key
|
||||||
|
// or input and variable contents. Reflection-based formatting does not carry
|
||||||
|
// this guarantee.
|
||||||
|
func (r RunRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing the direct API
|
||||||
|
// key or input and variable contents. Reflection-based formatting does not
|
||||||
|
// carry this guarantee.
|
||||||
|
func (r RunRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RunRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t}",
|
||||||
|
r.PromptID,
|
||||||
|
r.PromptVersion,
|
||||||
|
r.ProfileID,
|
||||||
|
r.APIKey != "",
|
||||||
|
len(r.Inputs),
|
||||||
|
len(r.Vars),
|
||||||
|
r.Execution != nil,
|
||||||
|
r.Validation != nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content. Reflection-based formatting does not carry this
|
||||||
|
// guarantee.
|
||||||
|
func (r GenerateRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content. Reflection-based formatting does not carry this
|
||||||
|
// guarantee.
|
||||||
|
func (r GenerateRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r GenerateRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.GenerateRequest{Messages:%d Model:%q APIKeySet:%t StructuredOutputSet:%t ExtraParams:%d}",
|
||||||
|
len(r.Prompt.Messages),
|
||||||
|
r.Target.Model,
|
||||||
|
r.APIKey != "",
|
||||||
|
r.StructuredOutput != nil,
|
||||||
|
len(r.Target.ExtraParams),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
go.mod
7
go.mod
@@ -1,3 +1,10 @@
|
|||||||
module gitea.maximumdirect.net/eric/promptkit
|
module gitea.maximumdirect.net/eric/promptkit
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/text v0.14.0 // indirect
|
||||||
|
|||||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
122
internal/artifact/reader.go
Normal file
122
internal/artifact/reader.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
||||||
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader resolves artifact references into actual artifacts.
|
||||||
|
type Reader interface {
|
||||||
|
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeReader routes artifact resolution based on the reference type.
|
||||||
|
type CompositeReader struct {
|
||||||
|
inlineReader *inlineReader
|
||||||
|
fileReader Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCompositeReader() Reader {
|
||||||
|
return &CompositeReader{
|
||||||
|
inlineReader: &inlineReader{},
|
||||||
|
fileReader: &fileReader{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ref.Type {
|
||||||
|
case domain.ArtifactRefInline:
|
||||||
|
return c.inlineReader.Read(ctx, ref)
|
||||||
|
case domain.ArtifactRefFile:
|
||||||
|
return c.fileReader.Read(ctx, ref)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedRefType, ref.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineReader struct{}
|
||||||
|
|
||||||
|
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.Body == "" {
|
||||||
|
return nil, ErrMissingInlineBody
|
||||||
|
}
|
||||||
|
|
||||||
|
body := []byte(ref.Body)
|
||||||
|
return &domain.Artifact{
|
||||||
|
ContentType: defaults.ContentTypeTextPlain,
|
||||||
|
Body: body,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
|
||||||
|
URI: ref.URI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileReader struct{}
|
||||||
|
|
||||||
|
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.URI == "" {
|
||||||
|
return nil, ErrMissingFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileArtifact(ref.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileArtifact(path string) (*domain.Artifact, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = defaults.ContentTypeTextPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: filepath.Base(path),
|
||||||
|
ContentType: contentType,
|
||||||
|
Body: data,
|
||||||
|
URI: path,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
180
internal/artifact/reader_test.go
Normal file
180
internal/artifact/reader_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompositeReader_Read(t *testing.T) {
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("inline artifact", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "hello world",
|
||||||
|
}
|
||||||
|
art, err := reader.Read(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != "hello world" {
|
||||||
|
t.Errorf("expected 'hello world', got %s", string(art.Body))
|
||||||
|
}
|
||||||
|
if art.ContentType != "text/plain" {
|
||||||
|
t.Errorf("expected text/plain content type, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
if art.Hash != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" {
|
||||||
|
t.Errorf("unexpected hash: %s", art.Hash)
|
||||||
|
}
|
||||||
|
if art.Size != int64(len(ref.Body)) {
|
||||||
|
t.Errorf("expected size %d, got %d", len(ref.Body), art.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline artifact missing body", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrMissingInlineBody) {
|
||||||
|
t.Errorf("expected ErrMissingInlineBody, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported ref type", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefType("unsupported"),
|
||||||
|
URI: "unsupported://bucket/key",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrUnsupportedRefType) {
|
||||||
|
t.Error("expected error for unsupported type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeReaderCopiesInlineData(t *testing.T) {
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "hello",
|
||||||
|
URI: "inline:greeting",
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := reader.Read(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first artifact: %v", err)
|
||||||
|
}
|
||||||
|
first.Body[0] = 'j'
|
||||||
|
|
||||||
|
second, err := reader.Read(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read second artifact: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(second.Body); got != ref.Body {
|
||||||
|
t.Fatalf("expected an independent body %q, got %q", ref.Body, got)
|
||||||
|
}
|
||||||
|
if second.URI != ref.URI {
|
||||||
|
t.Fatalf("expected URI %q, got %q", ref.URI, second.URI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeReaderHonorsCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := NewCompositeReader().Read(ctx, domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "ignored",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileReader_Read(t *testing.T) {
|
||||||
|
content := []byte("test file content")
|
||||||
|
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
|
if err := os.WriteFile(filePath, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("file artifact loading", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: filePath,
|
||||||
|
}
|
||||||
|
art, err := reader.Read(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != string(content) {
|
||||||
|
t.Errorf("expected %s, got %s", string(content), string(art.Body))
|
||||||
|
}
|
||||||
|
if art.Name != filepath.Base(filePath) {
|
||||||
|
t.Errorf("expected name %q, got %q", filepath.Base(filePath), art.Name)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(art.ContentType, "text/plain") {
|
||||||
|
t.Errorf("expected text content type, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
if art.URI != filePath {
|
||||||
|
t.Errorf("expected URI %q, got %q", filePath, art.URI)
|
||||||
|
}
|
||||||
|
if art.Size != int64(len(content)) {
|
||||||
|
t.Errorf("expected size %d, got %d", len(content), art.Size)
|
||||||
|
}
|
||||||
|
if art.Hash != "60f5237ed4049f0382661ef009d2bc42e48c3ceb3edb6600f7024e7ab3b838f3" {
|
||||||
|
t.Errorf("unexpected hash: %s", art.Hash)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file path", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: "",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrMissingFilePath) {
|
||||||
|
t.Errorf("expected ErrMissingFilePath, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: filepath.Join(t.TempDir(), "missing.txt"),
|
||||||
|
}
|
||||||
|
if _, err := reader.Read(ctx, ref); err == nil {
|
||||||
|
t.Fatal("expected missing file error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown extension uses text fallback", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "artifact.unknownextension")
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
art, err := reader.Read(ctx, domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: path,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if art.ContentType != "text/plain" {
|
||||||
|
t.Errorf("expected text/plain fallback, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
212
internal/backend/registry.go
Normal file
212
internal/backend/registry.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Package backend owns validated, immutable OpenAI-compatible backend
|
||||||
|
// definitions.
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter
|
||||||
|
// backend.
|
||||||
|
OpenRouterID = "openrouter"
|
||||||
|
|
||||||
|
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
||||||
|
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
||||||
|
|
||||||
|
openRouterConcurrencyLimit = 16
|
||||||
|
defaultQueueCapacity = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
||||||
|
var ErrBackendNotFound = errors.New("backend not found")
|
||||||
|
|
||||||
|
var environmentVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
// Registry is an immutable collection of validated backend definitions.
|
||||||
|
type Registry struct {
|
||||||
|
backends map[string]domain.Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry constructs a registry containing the built-in OpenRouter
|
||||||
|
// definition followed by the supplied additions. Every ID must be unique.
|
||||||
|
func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
||||||
|
registry := &Registry{
|
||||||
|
backends: make(map[string]domain.Backend, len(additions)+1),
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions := make([]domain.Backend, 0, len(additions)+1)
|
||||||
|
definitions = append(definitions, domain.Backend{
|
||||||
|
ID: OpenRouterID,
|
||||||
|
Endpoint: openRouterEndpoint,
|
||||||
|
APIKeyEnv: openRouterAPIKeyEnv,
|
||||||
|
ConcurrencyLimit: openRouterConcurrencyLimit,
|
||||||
|
})
|
||||||
|
definitions = append(definitions, additions...)
|
||||||
|
|
||||||
|
for _, definition := range definitions {
|
||||||
|
definition.ID = strings.TrimSpace(definition.ID)
|
||||||
|
if definition.ID == "" {
|
||||||
|
return nil, errors.New("backend ID must not be blank")
|
||||||
|
}
|
||||||
|
if _, exists := registry.backends[definition.ID]; exists {
|
||||||
|
return nil, fmt.Errorf("backend ID %q is already registered", definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeBackend(definition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registry.backends[normalized.ID] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackend returns a defensive copy of the backend registered with id.
|
||||||
|
func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
||||||
|
if r == nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||||
|
}
|
||||||
|
definition, ok := r.backends[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id)
|
||||||
|
}
|
||||||
|
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err)
|
||||||
|
}
|
||||||
|
definition.ExtraParams = extraParams
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapacityPolicies returns a copy of the normalized policies for limited
|
||||||
|
// backends.
|
||||||
|
func (r *Registry) CapacityPolicies() map[string]domain.BackendCapacityPolicy {
|
||||||
|
policies := make(map[string]domain.BackendCapacityPolicy)
|
||||||
|
if r == nil {
|
||||||
|
return policies
|
||||||
|
}
|
||||||
|
for id, definition := range r.backends {
|
||||||
|
if definition.ConcurrencyLimit == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
policies[id] = domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: definition.ConcurrencyLimit,
|
||||||
|
QueueCapacity: definition.QueueCapacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policies
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
||||||
|
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
||||||
|
if err := validateEndpoint(definition.Endpoint); err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv)
|
||||||
|
if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q api key environment variable %q is invalid",
|
||||||
|
definition.ID,
|
||||||
|
definition.APIKeyEnv,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if definition.ConcurrencyLimit < 0 {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q concurrency limit must not be negative",
|
||||||
|
definition.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if definition.QueueCapacity < 0 {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q queue capacity must not be negative",
|
||||||
|
definition.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if definition.ConcurrencyLimit == 0 {
|
||||||
|
if definition.QueueCapacitySet {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q queue capacity requires a positive concurrency limit",
|
||||||
|
definition.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
definition.QueueCapacity = 0
|
||||||
|
} else {
|
||||||
|
if !definition.QueueCapacitySet {
|
||||||
|
definition.QueueCapacity = defaultQueueCapacity
|
||||||
|
definition.QueueCapacitySet = true
|
||||||
|
}
|
||||||
|
maxInt := int(^uint(0) >> 1)
|
||||||
|
if definition.QueueCapacity > maxInt-definition.ConcurrencyLimit {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q total capacity overflows int",
|
||||||
|
definition.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(definition.ExtraParams))
|
||||||
|
for key := range definition.ExtraParams {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
if key == "" {
|
||||||
|
return domain.Backend{}, fmt.Errorf("backend %q extra parameter key must not be empty", definition.ID)
|
||||||
|
}
|
||||||
|
if llm.IsReservedOpenAIChatRequestField(key) {
|
||||||
|
return domain.Backend{}, fmt.Errorf(
|
||||||
|
"backend %q extra parameter %q collides with a reserved request field",
|
||||||
|
definition.ID,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("backend %q extra parameters: %w", definition.ID, err)
|
||||||
|
}
|
||||||
|
definition.ExtraParams = extraParams
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEndpoint(endpoint string) error {
|
||||||
|
if endpoint == "" {
|
||||||
|
return errors.New("must not be blank")
|
||||||
|
}
|
||||||
|
if strings.Contains(endpoint, "#") {
|
||||||
|
return errors.New("must not contain a fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a valid URL: %w", err)
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(parsed.Scheme)
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return errors.New("must use http or https")
|
||||||
|
}
|
||||||
|
if !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||||
|
return errors.New("must be absolute and include a host")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return errors.New("must not contain user information")
|
||||||
|
}
|
||||||
|
if parsed.RawQuery != "" || parsed.ForceQuery {
|
||||||
|
return errors.New("must not contain a query string")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
368
internal/backend/registry_test.go
Normal file
368
internal/backend/registry_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
package backend_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const validEndpoint = "https://backend.example/v1"
|
||||||
|
|
||||||
|
func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) {
|
||||||
|
registry, err := backend.NewRegistry(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct registry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition, err := registry.GetBackend(backend.OpenRouterID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up OpenRouter: %v", err)
|
||||||
|
}
|
||||||
|
if definition.ID != "openrouter" ||
|
||||||
|
definition.Endpoint != "https://openrouter.ai/api/v1" ||
|
||||||
|
definition.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
||||||
|
definition.ConcurrencyLimit != 16 ||
|
||||||
|
definition.QueueCapacity != 1024 ||
|
||||||
|
!definition.QueueCapacitySet ||
|
||||||
|
definition.ExtraParams != nil {
|
||||||
|
t.Fatalf("unexpected OpenRouter definition: %#v", definition)
|
||||||
|
}
|
||||||
|
policies := registry.CapacityPolicies()
|
||||||
|
if len(policies) != 1 ||
|
||||||
|
policies["openrouter"] != (domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 16,
|
||||||
|
QueueCapacity: 1024,
|
||||||
|
}) {
|
||||||
|
t.Fatalf("unexpected OpenRouter capacity policies: %#v", policies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
||||||
|
nested := map[string]int{"limit": 2}
|
||||||
|
extraParams := map[string]any{
|
||||||
|
"count": int64(7),
|
||||||
|
"nested": nested,
|
||||||
|
}
|
||||||
|
registry, err := backend.NewRegistry([]domain.Backend{
|
||||||
|
{
|
||||||
|
ID: " custom ",
|
||||||
|
Endpoint: " https://custom.example/openai/v1 ",
|
||||||
|
APIKeyEnv: " CUSTOM_API_KEY ",
|
||||||
|
ExtraParams: extraParams,
|
||||||
|
ConcurrencyLimit: 3,
|
||||||
|
QueueCapacity: 2,
|
||||||
|
QueueCapacitySet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "Custom",
|
||||||
|
Endpoint: validEndpoint,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct registry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nested["limit"] = 99
|
||||||
|
extraParams["added"] = true
|
||||||
|
|
||||||
|
got, err := registry.GetBackend("custom")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up custom backend: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "custom" ||
|
||||||
|
got.Endpoint != "https://custom.example/openai/v1" ||
|
||||||
|
got.APIKeyEnv != "CUSTOM_API_KEY" ||
|
||||||
|
got.ConcurrencyLimit != 3 ||
|
||||||
|
got.QueueCapacity != 2 ||
|
||||||
|
!got.QueueCapacitySet {
|
||||||
|
t.Fatalf("unexpected normalized definition: %#v", got)
|
||||||
|
}
|
||||||
|
if count, ok := got.ExtraParams["count"].(int64); !ok || count != 7 {
|
||||||
|
t.Fatalf("integer type or value changed: %#v", got.ExtraParams["count"])
|
||||||
|
}
|
||||||
|
gotNested, ok := got.ExtraParams["nested"].(map[string]int)
|
||||||
|
if !ok || gotNested["limit"] != 2 {
|
||||||
|
t.Fatalf("container type or value changed: %#v", got.ExtraParams["nested"])
|
||||||
|
}
|
||||||
|
if _, exists := got.ExtraParams["added"]; exists {
|
||||||
|
t.Fatalf("registry retained caller map: %#v", got.ExtraParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotNested["limit"] = 100
|
||||||
|
got.ExtraParams["added"] = true
|
||||||
|
again, err := registry.GetBackend("custom")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up custom backend again: %v", err)
|
||||||
|
}
|
||||||
|
if again.ExtraParams["nested"].(map[string]int)["limit"] != 2 {
|
||||||
|
t.Fatalf("lookup exposed registry nested map: %#v", again.ExtraParams)
|
||||||
|
}
|
||||||
|
if _, exists := again.ExtraParams["added"]; exists {
|
||||||
|
t.Fatalf("lookup exposed registry map: %#v", again.ExtraParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.GetBackend("Custom"); err != nil {
|
||||||
|
t.Fatalf("backend IDs should be case-sensitive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policies := registry.CapacityPolicies()
|
||||||
|
if len(policies) != 2 {
|
||||||
|
t.Fatalf("unexpected capacity policy count: %#v", policies)
|
||||||
|
}
|
||||||
|
policies["custom"] = domain.BackendCapacityPolicy{}
|
||||||
|
delete(policies, backend.OpenRouterID)
|
||||||
|
againPolicies := registry.CapacityPolicies()
|
||||||
|
if againPolicies["custom"] != (domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 3,
|
||||||
|
QueueCapacity: 2,
|
||||||
|
}) {
|
||||||
|
t.Fatalf("capacity policy map mutated registry state: %#v", againPolicies)
|
||||||
|
}
|
||||||
|
if _, ok := againPolicies[backend.OpenRouterID]; !ok {
|
||||||
|
t.Fatalf("capacity policy deletion mutated registry state: %#v", againPolicies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryNormalizesCapacityPolicy(t *testing.T) {
|
||||||
|
maxInt := int(^uint(0) >> 1)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
definition domain.Backend
|
||||||
|
want domain.BackendCapacityPolicy
|
||||||
|
wantSet bool
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unlimited when omitted",
|
||||||
|
definition: domain.Backend{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default queue",
|
||||||
|
definition: domain.Backend{
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
},
|
||||||
|
want: domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacity: 1024,
|
||||||
|
},
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit zero queue",
|
||||||
|
definition: domain.Backend{
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacitySet: true,
|
||||||
|
},
|
||||||
|
want: domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
},
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative concurrency limit",
|
||||||
|
definition: domain.Backend{
|
||||||
|
ConcurrencyLimit: -1,
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative queue capacity",
|
||||||
|
definition: domain.Backend{
|
||||||
|
ConcurrencyLimit: 1,
|
||||||
|
QueueCapacity: -1,
|
||||||
|
QueueCapacitySet: true,
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue without limit",
|
||||||
|
definition: domain.Backend{
|
||||||
|
QueueCapacitySet: true,
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "total overflow",
|
||||||
|
definition: domain.Backend{
|
||||||
|
ConcurrencyLimit: maxInt,
|
||||||
|
QueueCapacity: 1,
|
||||||
|
QueueCapacitySet: true,
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.definition.ID = "custom"
|
||||||
|
tc.definition.Endpoint = validEndpoint
|
||||||
|
registry, err := backend.NewRegistry([]domain.Backend{tc.definition})
|
||||||
|
if tc.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid capacity policy error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct registry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition, err := registry.GetBackend("custom")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("look up custom backend: %v", err)
|
||||||
|
}
|
||||||
|
if definition.ConcurrencyLimit != tc.want.ConcurrencyLimit ||
|
||||||
|
definition.QueueCapacity != tc.want.QueueCapacity ||
|
||||||
|
definition.QueueCapacitySet != tc.wantSet {
|
||||||
|
t.Fatalf("normalized capacity=(%d, %d, %t), want (%d, %d, %t)",
|
||||||
|
definition.ConcurrencyLimit,
|
||||||
|
definition.QueueCapacity,
|
||||||
|
definition.QueueCapacitySet,
|
||||||
|
tc.want.ConcurrencyLimit,
|
||||||
|
tc.want.QueueCapacity,
|
||||||
|
tc.wantSet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
policies := registry.CapacityPolicies()
|
||||||
|
got, ok := policies["custom"]
|
||||||
|
if ok != tc.wantSet || got != tc.want {
|
||||||
|
t.Fatalf("capacity policy=(%#v, %t), want (%#v, %t)", got, ok, tc.want, tc.wantSet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryRejectsDuplicateIDs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
additions []domain.Backend
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "built-in collision after normalization",
|
||||||
|
additions: []domain.Backend{{
|
||||||
|
ID: " openrouter ",
|
||||||
|
}},
|
||||||
|
wantID: "openrouter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consumer collision after normalization",
|
||||||
|
additions: []domain.Backend{
|
||||||
|
{ID: "custom", Endpoint: validEndpoint},
|
||||||
|
{ID: " custom ", Endpoint: "https://other.example/v1"},
|
||||||
|
},
|
||||||
|
wantID: "custom",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := backend.NewRegistry(tc.additions)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate ID error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantID) {
|
||||||
|
t.Fatalf("expected error to identify %q, got %v", tc.wantID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryValidatesIDs(t *testing.T) {
|
||||||
|
for _, id := range []string{"", " \t\n "} {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
_, err := backend.NewRegistry([]domain.Backend{{
|
||||||
|
ID: id,
|
||||||
|
Endpoint: validEndpoint,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected blank ID error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryValidatesEndpoints(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
}{
|
||||||
|
{name: "blank", endpoint: ""},
|
||||||
|
{name: "relative", endpoint: "/v1"},
|
||||||
|
{name: "missing host", endpoint: "https:///v1"},
|
||||||
|
{name: "unsupported scheme", endpoint: "ftp://backend.example/v1"},
|
||||||
|
{name: "user information", endpoint: "https://user@backend.example/v1"},
|
||||||
|
{name: "query", endpoint: "https://backend.example/v1?mode=chat"},
|
||||||
|
{name: "empty query", endpoint: "https://backend.example/v1?"},
|
||||||
|
{name: "fragment", endpoint: "https://backend.example/v1#chat"},
|
||||||
|
{name: "empty fragment", endpoint: "https://backend.example/v1#"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := backend.NewRegistry([]domain.Backend{{
|
||||||
|
ID: "custom",
|
||||||
|
Endpoint: tc.endpoint,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid endpoint error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryValidatesEnvironmentVariableNames(t *testing.T) {
|
||||||
|
for _, name := range []string{"1API_KEY", "API-KEY", "API KEY", "ÅPI_KEY"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := backend.NewRegistry([]domain.Backend{{
|
||||||
|
ID: "custom",
|
||||||
|
Endpoint: validEndpoint,
|
||||||
|
APIKeyEnv: name,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid environment-variable name error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryRejectsInvalidAndReservedExtraParameters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
extraParams map[string]any
|
||||||
|
}{
|
||||||
|
{name: "unsupported value", extraParams: map[string]any{"value": make(chan int)}},
|
||||||
|
{name: "reserved key", extraParams: map[string]any{"model": "override"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := backend.NewRegistry([]domain.Backend{{
|
||||||
|
ID: "custom",
|
||||||
|
Endpoint: validEndpoint,
|
||||||
|
ExtraParams: tc.extraParams,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid extra parameters error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryLookupReportsNotFound(t *testing.T) {
|
||||||
|
registry, err := backend.NewRegistry(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct registry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.GetBackend("missing")
|
||||||
|
if !errors.Is(err, backend.ErrBackendNotFound) {
|
||||||
|
t.Fatalf("expected ErrBackendNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing") {
|
||||||
|
t.Fatalf("expected error to identify backend, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
internal/capacity/client.go
Normal file
40
internal/capacity/client.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
manager *Manager
|
||||||
|
next llm.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps next with configured active-generation limits. A nil manager
|
||||||
|
// leaves next unchanged.
|
||||||
|
func NewClient(manager *Manager, next llm.Client) llm.Client {
|
||||||
|
if manager == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return &client{
|
||||||
|
manager: manager,
|
||||||
|
next: next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
req domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
pool := c.manager.getPool(req.Target.BackendID)
|
||||||
|
if pool == nil {
|
||||||
|
return c.next.Generate(ctx, req)
|
||||||
|
}
|
||||||
|
if err := pool.acquire(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer pool.releaseActive()
|
||||||
|
return c.next.Generate(ctx, req)
|
||||||
|
}
|
||||||
517
internal/capacity/client_test.go
Normal file
517
internal/capacity/client_test.go
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generateResult struct {
|
||||||
|
response *domain.GenerateResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientFunc func(
|
||||||
|
context.Context,
|
||||||
|
domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error)
|
||||||
|
|
||||||
|
func (f clientFunc) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
req domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
return f(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
active int
|
||||||
|
peak int
|
||||||
|
calls map[string]int
|
||||||
|
started chan string
|
||||||
|
releases map[string]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBlockingClient(releases map[string]chan struct{}) *blockingClient {
|
||||||
|
return &blockingClient{
|
||||||
|
calls: make(map[string]int),
|
||||||
|
started: make(chan string, 64),
|
||||||
|
releases: releases,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *blockingClient) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
req domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
id := req.Prompt.SessionID
|
||||||
|
c.mu.Lock()
|
||||||
|
c.active++
|
||||||
|
if c.active > c.peak {
|
||||||
|
c.peak = c.active
|
||||||
|
}
|
||||||
|
c.calls[id]++
|
||||||
|
c.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.active--
|
||||||
|
c.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.started <- id
|
||||||
|
if release := c.releases[id]; release != nil {
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{Content: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *blockingClient) callCount(id string) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.calls[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *blockingClient) peakConcurrency() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.peak
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAsync(
|
||||||
|
client llm.Client,
|
||||||
|
ctx context.Context,
|
||||||
|
backendID string,
|
||||||
|
id string,
|
||||||
|
) <-chan generateResult {
|
||||||
|
result := make(chan generateResult, 1)
|
||||||
|
go func() {
|
||||||
|
response, err := client.Generate(ctx, domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{SessionID: id},
|
||||||
|
Target: domain.ExecutionTarget{BackendID: backendID},
|
||||||
|
})
|
||||||
|
result <- generateResult{response: response, err: err}
|
||||||
|
}()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForWaiterCount(t *testing.T, manager *Manager, backendID string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
pool := manager.pools[backendID]
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
pool.mu.Lock()
|
||||||
|
got := pool.waiters.Len()
|
||||||
|
pool.mu.Unlock()
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("waiter count=%d, want %d", got, want)
|
||||||
|
}
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveStarted(t *testing.T, started <-chan string) string {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case id := <-started:
|
||||||
|
return id
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for wrapped client invocation")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveResult(t *testing.T, result <-chan generateResult) generateResult {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case got := <-result:
|
||||||
|
return got
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for generation result")
|
||||||
|
return generateResult{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestManager(t *testing.T, policies map[string]domain.BackendCapacityPolicy) *Manager {
|
||||||
|
t.Helper()
|
||||||
|
manager, err := NewManager(policies)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct manager: %v", err)
|
||||||
|
}
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientLimitsPeakConcurrencyAndServesWaitersFIFO(t *testing.T) {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
firstRelease := make(chan struct{})
|
||||||
|
secondRelease := make(chan struct{})
|
||||||
|
thirdRelease := make(chan struct{})
|
||||||
|
next := newBlockingClient(map[string]chan struct{}{
|
||||||
|
"first": firstRelease,
|
||||||
|
"second": secondRelease,
|
||||||
|
"third": thirdRelease,
|
||||||
|
})
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
|
||||||
|
first := generateAsync(client, context.Background(), "limited", "first")
|
||||||
|
if got := receiveStarted(t, next.started); got != "first" {
|
||||||
|
t.Fatalf("first invocation=%q, want first", got)
|
||||||
|
}
|
||||||
|
second := generateAsync(client, context.Background(), "limited", "second")
|
||||||
|
waitForWaiterCount(t, manager, "limited", 1)
|
||||||
|
third := generateAsync(client, context.Background(), "limited", "third")
|
||||||
|
waitForWaiterCount(t, manager, "limited", 2)
|
||||||
|
|
||||||
|
close(firstRelease)
|
||||||
|
if got := receiveResult(t, first); got.err != nil {
|
||||||
|
t.Fatalf("first generation: %v", got.err)
|
||||||
|
}
|
||||||
|
if got := receiveStarted(t, next.started); got != "second" {
|
||||||
|
t.Fatalf("second invocation=%q, want second", got)
|
||||||
|
}
|
||||||
|
close(secondRelease)
|
||||||
|
if got := receiveResult(t, second); got.err != nil {
|
||||||
|
t.Fatalf("second generation: %v", got.err)
|
||||||
|
}
|
||||||
|
if got := receiveStarted(t, next.started); got != "third" {
|
||||||
|
t.Fatalf("third invocation=%q, want third", got)
|
||||||
|
}
|
||||||
|
close(thirdRelease)
|
||||||
|
if got := receiveResult(t, third); got.err != nil {
|
||||||
|
t.Fatalf("third generation: %v", got.err)
|
||||||
|
}
|
||||||
|
if peak := next.peakConcurrency(); peak != 1 {
|
||||||
|
t.Fatalf("peak concurrency=%d, want 1", peak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientPeakConcurrencyDoesNotExceedConfiguredLimit(t *testing.T) {
|
||||||
|
const limit = 2
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: limit},
|
||||||
|
})
|
||||||
|
gate := make(chan struct{})
|
||||||
|
releases := make(map[string]chan struct{})
|
||||||
|
for i := range 5 {
|
||||||
|
releases[string(rune('a'+i))] = gate
|
||||||
|
}
|
||||||
|
next := newBlockingClient(releases)
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
|
||||||
|
results := make([]<-chan generateResult, 0, len(releases))
|
||||||
|
for id := range releases {
|
||||||
|
results = append(results, generateAsync(client, context.Background(), "limited", id))
|
||||||
|
}
|
||||||
|
for range limit {
|
||||||
|
receiveStarted(t, next.started)
|
||||||
|
}
|
||||||
|
waitForWaiterCount(t, manager, "limited", len(releases)-limit)
|
||||||
|
|
||||||
|
close(gate)
|
||||||
|
for _, result := range results {
|
||||||
|
if got := receiveResult(t, result); got.err != nil {
|
||||||
|
t.Fatalf("generation: %v", got.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peak := next.peakConcurrency(); peak != limit {
|
||||||
|
t.Fatalf("peak concurrency=%d, want %d", peak, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRemovesCanceledWaiters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cancelID string
|
||||||
|
wantOrder []string
|
||||||
|
}{
|
||||||
|
{name: "first waiter", cancelID: "one", wantOrder: []string{"two", "three"}},
|
||||||
|
{name: "middle waiter", cancelID: "two", wantOrder: []string{"one", "three"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
holderRelease := make(chan struct{})
|
||||||
|
releases := map[string]chan struct{}{
|
||||||
|
"holder": holderRelease,
|
||||||
|
"one": make(chan struct{}),
|
||||||
|
"two": make(chan struct{}),
|
||||||
|
"three": make(chan struct{}),
|
||||||
|
}
|
||||||
|
next := newBlockingClient(releases)
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
|
||||||
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
|
if got := receiveStarted(t, next.started); got != "holder" {
|
||||||
|
t.Fatalf("initial invocation=%q, want holder", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts := make(map[string]context.Context)
|
||||||
|
cancels := make(map[string]context.CancelFunc)
|
||||||
|
results := make(map[string]<-chan generateResult)
|
||||||
|
for _, id := range []string{"one", "two", "three"} {
|
||||||
|
contexts[id], cancels[id] = context.WithCancel(context.Background())
|
||||||
|
results[id] = generateAsync(client, contexts[id], "limited", id)
|
||||||
|
waitForWaiterCount(t, manager, "limited", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
cancels[tc.cancelID]()
|
||||||
|
if got := receiveResult(t, results[tc.cancelID]); !errors.Is(got.err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled waiter error=%v, want context.Canceled", got.err)
|
||||||
|
}
|
||||||
|
waitForWaiterCount(t, manager, "limited", 2)
|
||||||
|
|
||||||
|
close(holderRelease)
|
||||||
|
if got := receiveResult(t, holder); got.err != nil {
|
||||||
|
t.Fatalf("holder generation: %v", got.err)
|
||||||
|
}
|
||||||
|
for _, id := range tc.wantOrder {
|
||||||
|
if got := receiveStarted(t, next.started); got != id {
|
||||||
|
t.Fatalf("next invocation=%q, want %q", got, id)
|
||||||
|
}
|
||||||
|
close(releases[id])
|
||||||
|
if got := receiveResult(t, results[id]); got.err != nil {
|
||||||
|
t.Fatalf("%s generation: %v", id, got.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls := next.callCount(tc.cancelID); calls != 0 {
|
||||||
|
t.Fatalf("canceled waiter invoked wrapped client %d times", calls)
|
||||||
|
}
|
||||||
|
for _, cancel := range cancels {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientGrantCancellationRaceDoesNotLeakPermit(t *testing.T) {
|
||||||
|
const iterations = 200
|
||||||
|
for i := range iterations {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
holderRelease := make(chan struct{})
|
||||||
|
var waiterCalls atomic.Int64
|
||||||
|
next := clientFunc(func(
|
||||||
|
_ context.Context,
|
||||||
|
req domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
if req.Prompt.SessionID == "holder" {
|
||||||
|
<-holderRelease
|
||||||
|
} else if req.Prompt.SessionID == "waiter" {
|
||||||
|
waiterCalls.Add(1)
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{Content: req.Prompt.SessionID}, nil
|
||||||
|
})
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
|
||||||
|
holder := generateAsync(client, context.Background(), "limited", "holder")
|
||||||
|
waitForActiveCount(t, manager, "limited", 1)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
waiterResult := generateAsync(client, ctx, "limited", "waiter")
|
||||||
|
waitForWaiterCount(t, manager, "limited", 1)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
var race sync.WaitGroup
|
||||||
|
race.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer race.Done()
|
||||||
|
<-start
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer race.Done()
|
||||||
|
<-start
|
||||||
|
close(holderRelease)
|
||||||
|
}()
|
||||||
|
close(start)
|
||||||
|
race.Wait()
|
||||||
|
|
||||||
|
if got := receiveResult(t, holder); got.err != nil {
|
||||||
|
t.Fatalf("iteration %d holder generation: %v", i, got.err)
|
||||||
|
}
|
||||||
|
got := receiveResult(t, waiterResult)
|
||||||
|
switch calls := waiterCalls.Load(); {
|
||||||
|
case calls == 0 && errors.Is(got.err, context.Canceled):
|
||||||
|
case calls == 1 && got.err == nil:
|
||||||
|
default:
|
||||||
|
t.Fatalf("iteration %d waiter calls=%d error=%v", i, calls, got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probe := generateAsync(client, context.Background(), "limited", "probe")
|
||||||
|
if got := receiveResult(t, probe); got.err != nil {
|
||||||
|
t.Fatalf("iteration %d probe generation: %v", i, got.err)
|
||||||
|
}
|
||||||
|
waitForActiveCount(t, manager, "limited", 0)
|
||||||
|
waitForWaiterCount(t, manager, "limited", 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForActiveCount(t *testing.T, manager *Manager, backendID string, want int) {
|
||||||
|
t.Helper()
|
||||||
|
pool := manager.pools[backendID]
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
pool.mu.Lock()
|
||||||
|
got := pool.active
|
||||||
|
pool.mu.Unlock()
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("active count=%d, want %d", got, want)
|
||||||
|
}
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUsesIndependentPoolsAndUnlimitedFastPaths(t *testing.T) {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"alpha": {ConcurrencyLimit: 1},
|
||||||
|
"beta": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
alphaRelease := make(chan struct{})
|
||||||
|
betaRelease := make(chan struct{})
|
||||||
|
next := newBlockingClient(map[string]chan struct{}{
|
||||||
|
"alpha": alphaRelease,
|
||||||
|
"beta": betaRelease,
|
||||||
|
})
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
|
||||||
|
alpha := generateAsync(client, context.Background(), "alpha", "alpha")
|
||||||
|
beta := generateAsync(client, context.Background(), "beta", "beta")
|
||||||
|
started := map[string]bool{
|
||||||
|
receiveStarted(t, next.started): true,
|
||||||
|
receiveStarted(t, next.started): true,
|
||||||
|
}
|
||||||
|
if !started["alpha"] || !started["beta"] {
|
||||||
|
t.Fatalf("independent pools did not both start: %#v", started)
|
||||||
|
}
|
||||||
|
close(alphaRelease)
|
||||||
|
close(betaRelease)
|
||||||
|
if got := receiveResult(t, alpha); got.err != nil {
|
||||||
|
t.Fatalf("alpha generation: %v", got.err)
|
||||||
|
}
|
||||||
|
if got := receiveResult(t, beta); got.err != nil {
|
||||||
|
t.Fatalf("beta generation: %v", got.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, backendID := range []string{"", "unknown"} {
|
||||||
|
response, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{SessionID: backendID},
|
||||||
|
Target: domain.ExecutionTarget{BackendID: backendID},
|
||||||
|
})
|
||||||
|
if err != nil || response == nil {
|
||||||
|
t.Fatalf("unlimited backend %q response=(%#v, %v)", backendID, response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := NewClient(nil, next); got != next {
|
||||||
|
t.Fatal("nil manager did not return the wrapped client unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientPreservesRequestsResponsesAndErrors(t *testing.T) {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
request := domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{
|
||||||
|
SessionID: "session",
|
||||||
|
Messages: []domain.RenderedMessage{
|
||||||
|
{Role: "user", Content: "content"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Target: domain.ExecutionTarget{
|
||||||
|
BackendID: "limited",
|
||||||
|
Model: "model",
|
||||||
|
ExtraParams: map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response := &domain.GenerateResponse{
|
||||||
|
Content: "output",
|
||||||
|
Usage: domain.TokenUsage{TotalTokens: 7},
|
||||||
|
}
|
||||||
|
collaboratorErr := errors.New("collaborator failure")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response *domain.GenerateResponse
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "successful response", response: response},
|
||||||
|
{name: "nil response"},
|
||||||
|
{name: "collaborator error", response: response, err: collaboratorErr},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var captured domain.GenerateRequest
|
||||||
|
next := clientFunc(func(
|
||||||
|
_ context.Context,
|
||||||
|
req domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
captured = req
|
||||||
|
return tc.response, tc.err
|
||||||
|
})
|
||||||
|
gotResponse, gotErr := NewClient(manager, next).Generate(context.Background(), request)
|
||||||
|
if !reflect.DeepEqual(captured, request) {
|
||||||
|
t.Fatalf("request changed: %#v", captured)
|
||||||
|
}
|
||||||
|
if gotResponse != tc.response || gotErr != tc.err {
|
||||||
|
t.Fatalf("response=(%p, %v), want (%p, %v)",
|
||||||
|
gotResponse, gotErr, tc.response, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientReleasesPermitDuringPanicUnwinding(t *testing.T) {
|
||||||
|
manager := newTestManager(t, map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
var calls atomic.Int64
|
||||||
|
next := clientFunc(func(
|
||||||
|
_ context.Context,
|
||||||
|
_ domain.GenerateRequest,
|
||||||
|
) (*domain.GenerateResponse, error) {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
panic("test panic")
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{Content: "recovered"}, nil
|
||||||
|
})
|
||||||
|
client := NewClient(manager, next)
|
||||||
|
request := domain.GenerateRequest{
|
||||||
|
Target: domain.ExecutionTarget{BackendID: "limited"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("expected wrapped client panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = client.Generate(context.Background(), request)
|
||||||
|
}()
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), request)
|
||||||
|
if err != nil || response == nil || response.Content != "recovered" {
|
||||||
|
t.Fatalf("generation after panic=(%#v, %v)", response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
160
internal/capacity/manager.go
Normal file
160
internal/capacity/manager.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// Package capacity coordinates engine-local run admission and model-generation
|
||||||
|
// concurrency for configured backends.
|
||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrCapacityExceeded identifies an admission rejected because a backend's
|
||||||
|
// configured run capacity is full.
|
||||||
|
var ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
|
|
||||||
|
// Manager owns independent backend capacity pools with immutable limits.
|
||||||
|
type Manager struct {
|
||||||
|
pools map[string]*pool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pool struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
concurrencyLimit int
|
||||||
|
totalCapacity int
|
||||||
|
admitted int
|
||||||
|
active int
|
||||||
|
waiters list.List
|
||||||
|
}
|
||||||
|
|
||||||
|
type waiter struct {
|
||||||
|
ready chan struct{}
|
||||||
|
element *list.Element
|
||||||
|
granted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager constructs independent pools from normalized backend policies.
|
||||||
|
func NewManager(policies map[string]domain.BackendCapacityPolicy) (*Manager, error) {
|
||||||
|
manager := &Manager{
|
||||||
|
pools: make(map[string]*pool, len(policies)),
|
||||||
|
}
|
||||||
|
maxInt := int(^uint(0) >> 1)
|
||||||
|
for id, policy := range policies {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, errors.New("backend capacity policy ID must not be blank")
|
||||||
|
}
|
||||||
|
if policy.ConcurrencyLimit <= 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"backend %q concurrency limit must be positive",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if policy.QueueCapacity < 0 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"backend %q queue capacity must not be negative",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if policy.QueueCapacity > maxInt-policy.ConcurrencyLimit {
|
||||||
|
return nil, fmt.Errorf("backend %q total capacity overflows int", id)
|
||||||
|
}
|
||||||
|
manager.pools[id] = &pool{
|
||||||
|
concurrencyLimit: policy.ConcurrencyLimit,
|
||||||
|
totalCapacity: policy.ConcurrencyLimit + policy.QueueCapacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admit immediately reserves one configured backend run slot. Backends without
|
||||||
|
// a configured pool are unlimited.
|
||||||
|
func (m *Manager) Admit(ctx context.Context, backendID string) (func(), error) {
|
||||||
|
pool := m.getPool(backendID)
|
||||||
|
if pool == nil {
|
||||||
|
return releaseNothing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.mu.Lock()
|
||||||
|
defer pool.mu.Unlock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pool.admitted >= pool.totalCapacity {
|
||||||
|
return nil, ErrCapacityExceeded
|
||||||
|
}
|
||||||
|
pool.admitted++
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
pool.mu.Lock()
|
||||||
|
pool.admitted--
|
||||||
|
pool.mu.Unlock()
|
||||||
|
})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseNothing() {}
|
||||||
|
|
||||||
|
func (m *Manager) getPool(backendID string) *pool {
|
||||||
|
if m == nil || backendID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.pools[backendID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pool) acquire(ctx context.Context) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.active < p.concurrencyLimit && p.waiters.Len() == 0 {
|
||||||
|
p.active++
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
waiter := &waiter{ready: make(chan struct{})}
|
||||||
|
waiter.element = p.waiters.PushBack(waiter)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-waiter.ready:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
p.mu.Lock()
|
||||||
|
if !waiter.granted {
|
||||||
|
p.waiters.Remove(waiter.element)
|
||||||
|
waiter.element = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pool) releaseActive() {
|
||||||
|
var ready chan struct{}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if element := p.waiters.Front(); element != nil {
|
||||||
|
waiter := element.Value.(*waiter)
|
||||||
|
p.waiters.Remove(element)
|
||||||
|
waiter.element = nil
|
||||||
|
waiter.granted = true
|
||||||
|
ready = waiter.ready
|
||||||
|
} else {
|
||||||
|
p.active--
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if ready != nil {
|
||||||
|
close(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
internal/capacity/manager_test.go
Normal file
163
internal/capacity/manager_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package capacity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
||||||
|
maxInt := int(^uint(0) >> 1)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
policy domain.BackendCapacityPolicy
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "blank ID",
|
||||||
|
id: " \t ",
|
||||||
|
policy: domain.BackendCapacityPolicy{ConcurrencyLimit: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero concurrency",
|
||||||
|
id: "backend",
|
||||||
|
policy: domain.BackendCapacityPolicy{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative concurrency",
|
||||||
|
id: "backend",
|
||||||
|
policy: domain.BackendCapacityPolicy{ConcurrencyLimit: -1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative queue",
|
||||||
|
id: "backend",
|
||||||
|
policy: domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 1,
|
||||||
|
QueueCapacity: -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "total overflow",
|
||||||
|
id: "backend",
|
||||||
|
policy: domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: maxInt,
|
||||||
|
QueueCapacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := NewManager(map[string]domain.BackendCapacityPolicy{
|
||||||
|
tc.id: tc.policy,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid policy error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerAdmissionIsBoundedAndReleaseIsIdempotent(t *testing.T) {
|
||||||
|
policies := map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacity: 1,
|
||||||
|
},
|
||||||
|
"independent": {
|
||||||
|
ConcurrencyLimit: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manager, err := NewManager(policies)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct manager: %v", err)
|
||||||
|
}
|
||||||
|
policies["limited"] = domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 100,
|
||||||
|
QueueCapacity: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
releases := make([]func(), 0, 3)
|
||||||
|
for range 3 {
|
||||||
|
release, err := manager.Admit(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admit within configured capacity: %v", err)
|
||||||
|
}
|
||||||
|
releases = append(releases, release)
|
||||||
|
}
|
||||||
|
if release, err := manager.Admit(context.Background(), "limited"); release != nil ||
|
||||||
|
!errors.Is(err, ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("admission beyond capacity=(release=%t, err=%v), want ErrCapacityExceeded",
|
||||||
|
release != nil, err)
|
||||||
|
}
|
||||||
|
independentRelease, err := manager.Admit(context.Background(), "independent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admit independent backend while first is full: %v", err)
|
||||||
|
}
|
||||||
|
independentRelease()
|
||||||
|
|
||||||
|
releases[0]()
|
||||||
|
releases[0]()
|
||||||
|
replacement, err := manager.Admit(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admit after release: %v", err)
|
||||||
|
}
|
||||||
|
replacement()
|
||||||
|
releases[1]()
|
||||||
|
releases[2]()
|
||||||
|
|
||||||
|
pool := manager.pools["limited"]
|
||||||
|
pool.mu.Lock()
|
||||||
|
admitted := pool.admitted
|
||||||
|
pool.mu.Unlock()
|
||||||
|
if admitted != 0 {
|
||||||
|
t.Fatalf("admitted runs after releases=%d, want 0", admitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerAdmissionHonorsContextAndUnlimitedBackends(t *testing.T) {
|
||||||
|
manager, err := NewManager(map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct manager: %v", err)
|
||||||
|
}
|
||||||
|
release, err := manager.Admit(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fill limited pool: %v", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if release, err := manager.Admit(ctx, "limited"); release != nil ||
|
||||||
|
!errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled limited admission=(release=%t, err=%v), want context cancellation",
|
||||||
|
release != nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilManager *Manager
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
manager *Manager
|
||||||
|
backendID string
|
||||||
|
}{
|
||||||
|
{name: "nil manager", manager: nilManager, backendID: "limited"},
|
||||||
|
{name: "blank ID", manager: manager},
|
||||||
|
{name: "unknown ID", manager: manager, backendID: "unknown"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
release, err := tc.manager.Admit(ctx, tc.backendID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unlimited admission: %v", err)
|
||||||
|
}
|
||||||
|
if release == nil {
|
||||||
|
t.Fatal("unlimited admission returned nil release")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
release()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/defaults/defaults.go
Normal file
34
internal/defaults/defaults.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package defaults
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaDirDefault = "."
|
||||||
|
OutputArtifactName = "output"
|
||||||
|
ContentTypeTextPlain = "text/plain"
|
||||||
|
ContentTypeTextMarkdown = "text/markdown"
|
||||||
|
ContentTypeApplicationJSON = "application/json"
|
||||||
|
OpenAIChatCompletionsPath = "/chat/completions"
|
||||||
|
|
||||||
|
ExecutionDefaultTemperature = 0.0
|
||||||
|
ExecutionDefaultMaxTokens = 0
|
||||||
|
ExecutionDefaultTopP = 1.0
|
||||||
|
ExecutionDefaultTimeoutSeconds = 600
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
LLMRequestTimeoutDefault = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExecutionTargetDefault() domain.ExecutionTarget {
|
||||||
|
return domain.ExecutionTarget{
|
||||||
|
Temperature: ExecutionDefaultTemperature,
|
||||||
|
MaxTokens: ExecutionDefaultMaxTokens,
|
||||||
|
TopP: ExecutionDefaultTopP,
|
||||||
|
TimeoutSeconds: ExecutionDefaultTimeoutSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
328
internal/domain/domain.go
Normal file
328
internal/domain/domain.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactRefType defines how an artifact is referenced.
|
||||||
|
type ArtifactRefType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactRefInline ArtifactRefType = "inline"
|
||||||
|
ArtifactRefFile ArtifactRefType = "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputFormat defines the desired format of the generated artifact.
|
||||||
|
type OutputFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatText OutputFormat = "text"
|
||||||
|
FormatMarkdown OutputFormat = "markdown"
|
||||||
|
FormatJSON OutputFormat = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationMode defines how the output should be validated.
|
||||||
|
type ValidationMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationNone ValidationMode = "none"
|
||||||
|
ValidationBasic ValidationMode = "basic"
|
||||||
|
ValidationJSON ValidationMode = "json"
|
||||||
|
ValidationJSONSchema ValidationMode = "json_schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationStatus defines the result of a validation check.
|
||||||
|
type ValidationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationPassed ValidationStatus = "passed"
|
||||||
|
ValidationFailed ValidationStatus = "failed"
|
||||||
|
ValidationSkipped ValidationStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheControlType defines provider cache behavior for prompt content.
|
||||||
|
type CacheControlType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CacheControlEphemeral CacheControlType = "ephemeral"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionIDMaxLength is OpenRouter's documented maximum session_id length.
|
||||||
|
SessionIDMaxLength = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheControl describes provider cache metadata attached to prompt content.
|
||||||
|
type CacheControl struct {
|
||||||
|
Type CacheControlType `yaml:"type" json:"type"`
|
||||||
|
TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRequest represents a request to generate a single artifact.
|
||||||
|
type RunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
SessionID string
|
||||||
|
APIKey string `json:"-" yaml:"-"`
|
||||||
|
Inputs map[string]ArtifactRef
|
||||||
|
Vars map[string]string
|
||||||
|
Execution *ExecutionTargetOverride
|
||||||
|
Validation *OutputContract
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult represents the complete result of a prompt execution run.
|
||||||
|
type RunResult struct {
|
||||||
|
RunID string
|
||||||
|
Artifact Artifact
|
||||||
|
RawOutput string
|
||||||
|
Validation ValidationResult
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
SessionID string
|
||||||
|
RenderedPromptHash string
|
||||||
|
SelectedProfileID string
|
||||||
|
SelectedBackendID string
|
||||||
|
ModelName string
|
||||||
|
Endpoint string
|
||||||
|
EffectiveModelParams ExecutionTarget
|
||||||
|
InputHashes map[string]string
|
||||||
|
Usage TokenUsage
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
||||||
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
|
type PreparedRun struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
PromptVersion string `json:"prompt_version,omitempty"`
|
||||||
|
PromptHash string `json:"prompt_hash,omitempty"`
|
||||||
|
SelectedProfileID string `json:"selected_profile_id"`
|
||||||
|
SelectedBackendID string `json:"selected_backend_id,omitempty"`
|
||||||
|
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
||||||
|
TargetPresence ExecutionTargetPresence `json:"-"`
|
||||||
|
OutputContract OutputContract `json:"output_contract"`
|
||||||
|
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
||||||
|
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime time.Time `json:"end_time,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactRef represents a reference to an input artifact.
|
||||||
|
type ArtifactRef struct {
|
||||||
|
Type ArtifactRefType
|
||||||
|
URI string
|
||||||
|
Body string // Used for inline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artifact represents the actual loaded content of a reference.
|
||||||
|
type Artifact struct {
|
||||||
|
Name string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
URI string
|
||||||
|
Size int64
|
||||||
|
Hash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptDefinition represents a configured prompt execution definition.
|
||||||
|
type PromptDefinition struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
|
Inputs []PromptInput `yaml:"inputs"`
|
||||||
|
Templates []PromptMessageTemplate `yaml:"templates"`
|
||||||
|
OutputFormat OutputFormat `yaml:"output_format"`
|
||||||
|
Validation OutputContract `yaml:"validation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInspection is the resolved result of exact prompt inspection.
|
||||||
|
type PromptInspection struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
DefaultProfileID string
|
||||||
|
Inputs []PromptInput
|
||||||
|
OutputContract OutputContract
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInput describes one named input expected by a prompt definition.
|
||||||
|
type PromptInput struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType string `yaml:"content_type"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptMessageTemplate defines a template for a chat message.
|
||||||
|
type PromptMessageTemplate struct {
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
ContentFile string `yaml:"content_file"`
|
||||||
|
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend describes reusable OpenAI-compatible connection defaults.
|
||||||
|
type Backend struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
APIKeyEnv string
|
||||||
|
ExtraParams map[string]any
|
||||||
|
ConcurrencyLimit int
|
||||||
|
QueueCapacity int
|
||||||
|
QueueCapacitySet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendCapacityPolicy describes normalized run and generation capacity for
|
||||||
|
// one limited backend.
|
||||||
|
type BackendCapacityPolicy struct {
|
||||||
|
ConcurrencyLimit int
|
||||||
|
QueueCapacity int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
|
type ExecutionProfile struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
BackendID string `yaml:"backend"`
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
Temperature float64 `yaml:"temperature"`
|
||||||
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
|
TopP float64 `yaml:"top_p"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
|
ServiceTier string `yaml:"service_tier"`
|
||||||
|
ReasoningEffort string `yaml:"reasoning_effort"`
|
||||||
|
APIKeyEnv string `yaml:"api_key_env"`
|
||||||
|
APIKeyRequired bool `yaml:"-" json:"-"`
|
||||||
|
ExtraParams map[string]any `yaml:"extra_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetOverride represents per-request runtime setting overrides.
|
||||||
|
type ExecutionTargetOverride struct {
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
|
||||||
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"extra_params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetPresence tracks which effective runtime fields came from an
|
||||||
|
// explicit request override even when the resolved value is a zero value.
|
||||||
|
type ExecutionTargetPresence struct {
|
||||||
|
Temperature bool
|
||||||
|
MaxTokens bool
|
||||||
|
TopP bool
|
||||||
|
TimeoutSeconds bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTarget represents effective model runtime settings for a run.
|
||||||
|
type ExecutionTarget struct {
|
||||||
|
BackendID string `yaml:"backend" json:"backend_id,omitempty"`
|
||||||
|
Endpoint string `yaml:"endpoint" json:"endpoint"`
|
||||||
|
Model string `yaml:"model" json:"model"`
|
||||||
|
Temperature float64 `yaml:"temperature" json:"temperature"`
|
||||||
|
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
||||||
|
TopP float64 `yaml:"top_p" json:"top_p"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
||||||
|
ServiceTier string `yaml:"service_tier" json:"service_tier"`
|
||||||
|
ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"`
|
||||||
|
APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"`
|
||||||
|
APIKey string `yaml:"-" json:"-"`
|
||||||
|
APIKeyRequired bool `yaml:"-" json:"-"`
|
||||||
|
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"`
|
||||||
|
ValidationMode ValidationMode `yaml:"validation_mode"`
|
||||||
|
SchemaPath string `yaml:"schema_path"`
|
||||||
|
RepairAttempts int `yaml:"repair_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedPrompt represents the prompt after template application.
|
||||||
|
type RenderedPrompt struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedMessage is a single message in a rendered prompt.
|
||||||
|
type RenderedMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRequest is the internal request passed to the LLM client.
|
||||||
|
type GenerateRequest struct {
|
||||||
|
Prompt RenderedPrompt
|
||||||
|
Target ExecutionTarget
|
||||||
|
TargetPresence ExecutionTargetPresence
|
||||||
|
StructuredOutput *StructuredOutputSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredOutputType indicates which provider-level output mode is requested.
|
||||||
|
type StructuredOutputType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StructuredOutputJSONSchema StructuredOutputType = "json_schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StructuredOutputSpec describes provider-level structured output requirements.
|
||||||
|
type StructuredOutputSpec struct {
|
||||||
|
Type StructuredOutputType `json:"type"`
|
||||||
|
JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredOutputJSONSpec contains json_schema output constraints.
|
||||||
|
type StructuredOutputJSONSpec struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateResponse is the response received from the LLM client.
|
||||||
|
type GenerateResponse struct {
|
||||||
|
Content string
|
||||||
|
Usage TokenUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsage tracks token consumption.
|
||||||
|
type TokenUsage struct {
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
CachedTokens int
|
||||||
|
CacheWriteTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationResult represents the outcome of an output validation.
|
||||||
|
type ValidationResult struct {
|
||||||
|
Status ValidationStatus
|
||||||
|
Mode ValidationMode
|
||||||
|
Errors []string
|
||||||
|
SchemaPath string
|
||||||
|
RepairAttempts int
|
||||||
|
IsValid bool
|
||||||
|
}
|
||||||
141
internal/domain/prepared_run_test.go
Normal file
141
internal/domain/prepared_run_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
||||||
|
const envName = "PROMPTKIT_TEST_API_KEY"
|
||||||
|
const secret = "super-secret-value"
|
||||||
|
t.Setenv(envName, secret)
|
||||||
|
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
PromptVersion: "v1",
|
||||||
|
PromptHash: "prompt-hash",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
APIKeyEnv: envName,
|
||||||
|
APIKey: secret,
|
||||||
|
},
|
||||||
|
InputHashes: map[string]string{"transcript": "hash-1"},
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{
|
||||||
|
{Role: "system", Content: "You are helpful."},
|
||||||
|
{Role: "user", Content: "Summarize this."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := string(b)
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"api_key_env":"`+envName+`"`) {
|
||||||
|
t.Fatalf("prepared run JSON should include api_key_env name: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var top map[string]any
|
||||||
|
if err := json.Unmarshal(b, &top); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, forbidden := range []string{"raw_output", "validation", "artifact"} {
|
||||||
|
if _, ok := top[forbidden]; ok {
|
||||||
|
t.Fatalf("prepared run JSON should not include %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
},
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "You are helpful.",
|
||||||
|
CacheControl: &CacheControl{
|
||||||
|
Type: CacheControlEphemeral,
|
||||||
|
TTL: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "Summarize this."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Messages []map[string]any `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(decoded.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(decoded.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0])
|
||||||
|
}
|
||||||
|
if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
||||||
|
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
||||||
|
}
|
||||||
|
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
||||||
|
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
},
|
||||||
|
SessionID: "session-123",
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["session_id"] != "session-123" {
|
||||||
|
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared.SessionID = ""
|
||||||
|
b, err = json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "session_id") {
|
||||||
|
t.Fatalf("expected empty session_id to be omitted, got %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/domain/session.go
Normal file
19
internal/domain/session.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSessionID applies the shared session identifier rule.
|
||||||
|
func NormalizeSessionID(raw string) (string, error) {
|
||||||
|
normalized := strings.TrimSpace(raw)
|
||||||
|
if normalized == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength {
|
||||||
|
return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
57
internal/domain/session_test.go
Normal file
57
internal/domain/session_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeSessionID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trims surrounding Unicode whitespace",
|
||||||
|
raw: "\u2003 session-123 \u2003",
|
||||||
|
want: "session-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank input is omitted",
|
||||||
|
raw: " \t\u2003 ",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maximum Unicode length is accepted",
|
||||||
|
raw: strings.Repeat("界", SessionIDMaxLength),
|
||||||
|
want: strings.Repeat("界", SessionIDMaxLength),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one Unicode code point over maximum is rejected",
|
||||||
|
raw: strings.Repeat("界", SessionIDMaxLength+1),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeSessionID(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected normalization error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds maximum") {
|
||||||
|
t.Fatalf("expected useful length diagnostic, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize session id: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("normalized session id = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/filecatalog/catalog.go
Normal file
142
internal/filecatalog/catalog.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package filecatalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindYAMLFiles returns sorted full paths for .yaml and .yml files under root.
|
||||||
|
func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsYAMLFile(d.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
|
||||||
|
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
var files []string
|
||||||
|
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsYAMLFile(d.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, name)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativePath computes a clean relative path from root to path.
|
||||||
|
func RelativePath(root string, filePath string) string {
|
||||||
|
rel, err := filepath.Rel(root, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Clean(filePath)
|
||||||
|
}
|
||||||
|
return filepath.Clean(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanFSRoot normalizes a root path for use with fs.FS.
|
||||||
|
func CleanFSRoot(root string) string {
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
if root == "" || root == "." {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path.Clean(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayPath returns name relative to root for messages about fs.FS paths.
|
||||||
|
func DisplayPath(root string, name string) string {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
cleanName := path.Clean(name)
|
||||||
|
if cleanRoot == "." {
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
||||||
|
if strings.HasPrefix(cleanName, prefix) {
|
||||||
|
return strings.TrimPrefix(cleanName, prefix)
|
||||||
|
}
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
|
||||||
|
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
cleanBase := path.Clean(strings.TrimSpace(baseDir))
|
||||||
|
if cleanBase == "" {
|
||||||
|
cleanBase = cleanRoot
|
||||||
|
}
|
||||||
|
if !containsFSPath(cleanRoot, cleanBase) {
|
||||||
|
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUserPath := strings.TrimSpace(userPath)
|
||||||
|
if cleanUserPath == "" {
|
||||||
|
return "", "", fmt.Errorf("path is required")
|
||||||
|
}
|
||||||
|
cleanUserPath = path.Clean(cleanUserPath)
|
||||||
|
if path.IsAbs(cleanUserPath) {
|
||||||
|
return "", "", fmt.Errorf("path %q must be relative", userPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
|
||||||
|
if !containsFSPath(cleanRoot, resolved) {
|
||||||
|
return "", "", fmt.Errorf("path %q escapes source root %q", userPath, cleanRoot)
|
||||||
|
}
|
||||||
|
return resolved, DisplayPath(cleanRoot, resolved), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFSPath(root string, name string) bool {
|
||||||
|
root = CleanFSRoot(root)
|
||||||
|
name = path.Clean(name)
|
||||||
|
if root == "." {
|
||||||
|
return name == "." || (name != ".." && !strings.HasPrefix(name, "../"))
|
||||||
|
}
|
||||||
|
return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stem strips .yaml or .yml from a file name.
|
||||||
|
func Stem(name string) string {
|
||||||
|
name = strings.TrimSuffix(name, ".yaml")
|
||||||
|
name = strings.TrimSuffix(name, ".yml")
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYAMLFile(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
||||||
|
}
|
||||||
270
internal/filecatalog/catalog_test.go
Normal file
270
internal/filecatalog/catalog_test.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
package filecatalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFile(t, filepath.Join(root, "z", "prompt.yml"), "id: z")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "a", "profile.yaml"), "id: a")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "a", "ignore.txt"), "not yaml")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "b", "ignore.yaml.bak"), "not yaml")
|
||||||
|
|
||||||
|
got, err := FindYAMLFiles(context.Background(), root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
filepath.Join(root, "a", "profile.yaml"),
|
||||||
|
filepath.Join(root, "z", "prompt.yml"),
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFile(t, filepath.Join(root, "one.yaml"), "id: one")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := FindYAMLFiles(ctx, root)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")},
|
||||||
|
"prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")},
|
||||||
|
"prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"prompts/a/profile.yaml",
|
||||||
|
"prompts/z/prompt.yml",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"one.yaml": &fstest.MapFile{Data: []byte("id: one")},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := FindFSYAMLFiles(ctx, fsys, ".")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativePathNested(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "nested", "profiles", "local.yaml")
|
||||||
|
got := RelativePath(root, path)
|
||||||
|
want := filepath.Join("nested", "profiles", "local.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected relative path %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanFSRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", root: "", want: "."},
|
||||||
|
{name: "dot", root: ".", want: "."},
|
||||||
|
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := CleanFSRoot(tc.root); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"},
|
||||||
|
{name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"},
|
||||||
|
{name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DisplayPath(tc.root, tc.path); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFSPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
baseDir string
|
||||||
|
userPath string
|
||||||
|
wantPath string
|
||||||
|
wantDisplay string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sibling inside root",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "./messages/user.tmpl",
|
||||||
|
wantPath: "prompts/nested/messages/user.tmpl",
|
||||||
|
wantDisplay: "nested/messages/user.tmpl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent inside root",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "../shared/user.tmpl",
|
||||||
|
wantPath: "prompts/shared/user.tmpl",
|
||||||
|
wantDisplay: "shared/user.tmpl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escape rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "../../outside.tmpl",
|
||||||
|
wantErr: "escapes source root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "/outside.tmpl",
|
||||||
|
wantErr: "must be relative",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: " ",
|
||||||
|
wantErr: "path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot root allows normal relative path",
|
||||||
|
root: ".",
|
||||||
|
baseDir: ".",
|
||||||
|
userPath: "schemas/events.schema.json",
|
||||||
|
wantPath: "schemas/events.schema.json",
|
||||||
|
wantDisplay: "schemas/events.schema.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot root rejects parent escape",
|
||||||
|
root: ".",
|
||||||
|
baseDir: ".",
|
||||||
|
userPath: "../outside.tmpl",
|
||||||
|
wantErr: "escapes source root",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gotPath, gotDisplay, err := ResolveFSPath(tc.root, tc.baseDir, tc.userPath)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != tc.wantPath || gotDisplay != tc.wantDisplay {
|
||||||
|
t.Fatalf("expected path/display %q/%q, got %q/%q", tc.wantPath, tc.wantDisplay, gotPath, gotDisplay)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStemStripsYAMLExtensions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "yaml", in: "prompt.yaml", want: "prompt"},
|
||||||
|
{name: "yml", in: "profile.yml", want: "profile"},
|
||||||
|
{name: "other", in: "file.txt", want: "file.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Stem(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsYAMLFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "yaml", in: "prompt.yaml", want: true},
|
||||||
|
{name: "yml", in: "profile.yml", want: true},
|
||||||
|
{name: "backup", in: "profile.yaml.bak", want: false},
|
||||||
|
{name: "uppercase", in: "profile.YAML", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsYAMLFile(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWriteFile(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
258
internal/jsonvalue/jsonvalue.go
Normal file
258
internal/jsonvalue/jsonvalue.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// Package jsonvalue validates and defensively copies JSON-compatible value
|
||||||
|
// trees used by configuration, request, and prepared-state boundaries.
|
||||||
|
package jsonvalue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxSafeJSONInteger = 1<<53 - 1
|
||||||
|
|
||||||
|
type visit struct {
|
||||||
|
typ reflect.Type
|
||||||
|
ptr uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy validates and deeply copies a JSON-compatible value while preserving
|
||||||
|
// compatible concrete map, slice, array, scalar, and number types.
|
||||||
|
func Copy(src any) (any, error) {
|
||||||
|
return copyValue(reflect.ValueOf(src), "value", make(map[visit]struct{}), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyMap validates and deeply copies an extra-parameter map while preserving
|
||||||
|
// compatible concrete map, slice, array, scalar, and number types.
|
||||||
|
func CopyMap(src map[string]any) (map[string]any, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
copied, err := copyValue(reflect.ValueOf(src), "extra_params", make(map[visit]struct{}), false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, ok := copied.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("extra_params: expected object")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyValue(
|
||||||
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
seen map[visit]struct{},
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
) (any, error) {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if value.Kind() == reflect.Interface {
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return copyValue(value.Elem(), path, seen, allowEmptyMapKeys)
|
||||||
|
}
|
||||||
|
if !value.CanInterface() {
|
||||||
|
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
||||||
|
}
|
||||||
|
if number, ok := value.Interface().(json.Number); ok {
|
||||||
|
if _, err := json.Marshal(number); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(number.String(), 64)
|
||||||
|
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Bool, reflect.String:
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger {
|
||||||
|
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
if value.Uint() > maxSafeJSONInteger {
|
||||||
|
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
number := value.Convert(reflect.TypeOf(float64(0))).Float()
|
||||||
|
if math.IsNaN(number) || math.IsInf(number, 0) {
|
||||||
|
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
|
||||||
|
}
|
||||||
|
return value.Interface(), nil
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[current]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[current] = struct{}{}
|
||||||
|
defer delete(seen, current)
|
||||||
|
return copyValue(value.Elem(), path, seen, allowEmptyMapKeys)
|
||||||
|
case reflect.Map:
|
||||||
|
return copyMapValue(value, path, seen, allowEmptyMapKeys)
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return copySequenceValue(value, path, seen, allowEmptyMapKeys)
|
||||||
|
case reflect.Array:
|
||||||
|
return copySequenceValue(value, path, seen, allowEmptyMapKeys)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMapValue(
|
||||||
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
seen map[visit]struct{},
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
) (any, error) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
||||||
|
}
|
||||||
|
|
||||||
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[current]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[current] = struct{}{}
|
||||||
|
defer delete(seen, current)
|
||||||
|
|
||||||
|
keys := value.MapKeys()
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
return keys[i].String() < keys[j].String()
|
||||||
|
})
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
key reflect.Value
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}
|
||||||
|
entries := make([]entry, 0, len(keys))
|
||||||
|
preserveType := true
|
||||||
|
elementType := value.Type().Elem()
|
||||||
|
for _, key := range keys {
|
||||||
|
name := key.String()
|
||||||
|
if name == "" && !allowEmptyMapKeys {
|
||||||
|
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
||||||
|
}
|
||||||
|
copied, err := copyValue(value.MapIndex(key), path+"."+name, seen, allowEmptyMapKeys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, entry{key: key, name: name, value: copied})
|
||||||
|
if copied == nil {
|
||||||
|
if !canAssignNil(elementType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.TypeOf(copied).AssignableTo(elementType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preserveType {
|
||||||
|
out := reflect.MakeMapWithSize(value.Type(), len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.value == nil {
|
||||||
|
out.SetMapIndex(entry.key, reflect.Zero(elementType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.SetMapIndex(entry.key, reflect.ValueOf(entry.value))
|
||||||
|
}
|
||||||
|
return out.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
out[entry.name] = entry.value
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copySequenceValue(
|
||||||
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
seen map[visit]struct{},
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
) (any, error) {
|
||||||
|
var current visit
|
||||||
|
if value.Kind() == reflect.Slice {
|
||||||
|
current = visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := seen[current]; ok {
|
||||||
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
seen[current] = struct{}{}
|
||||||
|
defer delete(seen, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]any, value.Len())
|
||||||
|
preserveType := true
|
||||||
|
elementType := value.Type().Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
copied, err := copyValue(
|
||||||
|
value.Index(i),
|
||||||
|
fmt.Sprintf("%s[%d]", path, i),
|
||||||
|
seen,
|
||||||
|
allowEmptyMapKeys,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values[i] = copied
|
||||||
|
if copied == nil {
|
||||||
|
if !canAssignNil(elementType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.TypeOf(copied).AssignableTo(elementType) {
|
||||||
|
preserveType = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preserveType {
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
if value.Kind() == reflect.Slice {
|
||||||
|
out = reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
||||||
|
}
|
||||||
|
for i, copied := range values {
|
||||||
|
if copied == nil {
|
||||||
|
out.Index(i).Set(reflect.Zero(elementType))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Index(i).Set(reflect.ValueOf(copied))
|
||||||
|
}
|
||||||
|
return out.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]any, len(values))
|
||||||
|
copy(out, values)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAssignNil(typ reflect.Type) bool {
|
||||||
|
switch typ.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/jsonvalue/jsonvalue_test.go
Normal file
112
internal/jsonvalue/jsonvalue_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package jsonvalue_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyMapPreservesTypesAndIsolatesMutations(t *testing.T) {
|
||||||
|
nested := map[string]int{"limit": 2}
|
||||||
|
sequence := []string{"one", "two"}
|
||||||
|
input := map[string]any{
|
||||||
|
"count": int64(7),
|
||||||
|
"number": json.Number("-1.25e+2"),
|
||||||
|
"nested": nested,
|
||||||
|
"sequence": sequence,
|
||||||
|
}
|
||||||
|
|
||||||
|
copied, err := jsonvalue.CopyMap(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy map: %v", err)
|
||||||
|
}
|
||||||
|
nested["limit"] = 99
|
||||||
|
sequence[0] = "changed"
|
||||||
|
input["added"] = true
|
||||||
|
|
||||||
|
if got, ok := copied["count"].(int64); !ok || got != 7 {
|
||||||
|
t.Fatalf("integer type or value changed: %#v", copied["count"])
|
||||||
|
}
|
||||||
|
if got, ok := copied["number"].(json.Number); !ok || got != "-1.25e+2" {
|
||||||
|
t.Fatalf("JSON number type or value changed: %#v", copied["number"])
|
||||||
|
}
|
||||||
|
if got := copied["nested"].(map[string]int)["limit"]; got != 2 {
|
||||||
|
t.Fatalf("nested map was not isolated: %d", got)
|
||||||
|
}
|
||||||
|
if got := copied["sequence"].([]string)[0]; got != "one" {
|
||||||
|
t.Fatalf("sequence was not isolated: %q", got)
|
||||||
|
}
|
||||||
|
if _, ok := copied["added"]; ok {
|
||||||
|
t.Fatalf("top-level map was not isolated: %#v", copied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyAllowsEmptyObjectKeysAndIsolatesMutations(t *testing.T) {
|
||||||
|
nested := map[string]any{"": []any{"original"}}
|
||||||
|
|
||||||
|
copiedValue, err := jsonvalue.Copy(nested)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy value: %v", err)
|
||||||
|
}
|
||||||
|
nested[""].([]any)[0] = "changed"
|
||||||
|
|
||||||
|
copied := copiedValue.(map[string]any)
|
||||||
|
if got := copied[""].([]any)[0]; got != "original" {
|
||||||
|
t.Fatalf("copied value was not isolated: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyMapRejectsInvalidValues(t *testing.T) {
|
||||||
|
cyclicMap := map[string]any{}
|
||||||
|
cyclicMap["self"] = cyclicMap
|
||||||
|
cyclicSlice := []any{nil}
|
||||||
|
cyclicSlice[0] = cyclicSlice
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{name: "empty nested key", value: map[string]int{"": 1}},
|
||||||
|
{name: "non-string map key", value: map[int]string{1: "one"}},
|
||||||
|
{name: "unsupported value", value: make(chan int)},
|
||||||
|
{name: "cyclic map", value: cyclicMap},
|
||||||
|
{name: "cyclic slice", value: cyclicSlice},
|
||||||
|
{name: "NaN", value: math.NaN()},
|
||||||
|
{name: "positive infinity", value: math.Inf(1)},
|
||||||
|
{name: "unsafe signed integer", value: int64(1 << 53)},
|
||||||
|
{name: "unsafe unsigned integer", value: uint64(1 << 53)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := jsonvalue.CopyMap(map[string]any{"value": tc.value}); err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyMapValidatesJSONNumberSyntaxAndRange(t *testing.T) {
|
||||||
|
for _, number := range []json.Number{"0", "-1", "1.25", "-1.25e+2"} {
|
||||||
|
t.Run("valid "+number.String(), func(t *testing.T) {
|
||||||
|
got, err := jsonvalue.CopyMap(map[string]any{"value": number})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy valid JSON number: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got["value"], number) {
|
||||||
|
t.Fatalf("JSON number changed: got %#v want %#v", got["value"], number)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, number := range []json.Number{"", "01", "+1", "1.", ".1", "1e9999", "not-a-number"} {
|
||||||
|
t.Run("invalid "+number.String(), func(t *testing.T) {
|
||||||
|
if _, err := jsonvalue.CopyMap(map[string]any{"value": number}); err == nil {
|
||||||
|
t.Fatal("expected invalid JSON number error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/llm/client.go
Normal file
11
internal/llm/client.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client executes a rendered prompt against an LLM endpoint.
|
||||||
|
type Client interface {
|
||||||
|
Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error)
|
||||||
|
}
|
||||||
390
internal/llm/openai_compatible_client.go
Normal file
390
internal/llm/openai_compatible_client.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidConfig = errors.New("invalid llm client configuration")
|
||||||
|
ErrInvalidRequest = errors.New("invalid generate request")
|
||||||
|
ErrRequestFailed = errors.New("llm request failed")
|
||||||
|
ErrUnexpectedStatus = errors.New("llm returned non-success status")
|
||||||
|
ErrMalformedResponse = errors.New("malformed llm response")
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenAICompatibleConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAICompatibleClient struct {
|
||||||
|
baseURL string
|
||||||
|
defaultModel string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
||||||
|
baseURL := strings.TrimSpace(cfg.BaseURL)
|
||||||
|
if baseURL != "" {
|
||||||
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaults.LLMRequestTimeoutDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *http.Client
|
||||||
|
if cfg.HTTPClient != nil {
|
||||||
|
cloned := *cfg.HTTPClient
|
||||||
|
if cloned.Timeout <= 0 {
|
||||||
|
cloned.Timeout = timeout
|
||||||
|
}
|
||||||
|
client = &cloned
|
||||||
|
} else {
|
||||||
|
client = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OpenAICompatibleClient{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
defaultModel: cfg.Model,
|
||||||
|
httpClient: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
if req.Target.TimeoutSeconds < 0 {
|
||||||
|
return nil, fmt.Errorf("%w: timeout_seconds must be greater than or equal to 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(req.Target.Endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = c.baseURL
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
|
||||||
|
|
||||||
|
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wirePayload, err := openAIChatRequestPayload(wireReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(wirePayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestContext := ctx
|
||||||
|
if req.Target.TimeoutSeconds > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
requestContext, cancel = context.WithTimeout(
|
||||||
|
ctx,
|
||||||
|
time.Duration(req.Target.TimeoutSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if apiKey := strings.TrimSpace(req.Target.APIKey); apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
} else if envName := strings.TrimSpace(req.Target.APIKeyEnv); envName != "" {
|
||||||
|
apiKey := strings.TrimSpace(os.Getenv(envName))
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := c.httpClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wireResp openAIChatResponse
|
||||||
|
if err := json.NewDecoder(httpResp.Body).Decode(&wireResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to decode response: %v", ErrMalformedResponse, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wireResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
content := wireResp.Choices[0].Message.Content
|
||||||
|
if content == "" {
|
||||||
|
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: content,
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: wireResp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: wireResp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: wireResp.Usage.TotalTokens,
|
||||||
|
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
|
||||||
|
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
|
||||||
|
model := strings.TrimSpace(req.Target.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(defaultModel)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return openAIChatRequest{}, errors.New("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReq := openAIChatRequest{
|
||||||
|
Model: model,
|
||||||
|
}
|
||||||
|
sessionID, err := domain.NormalizeSessionID(req.Prompt.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
return openAIChatRequest{}, err
|
||||||
|
}
|
||||||
|
wireReq.SessionID = sessionID
|
||||||
|
|
||||||
|
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
|
||||||
|
for _, msg := range req.Prompt.Messages {
|
||||||
|
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Target.Temperature != 0 || req.TargetPresence.Temperature {
|
||||||
|
wireReq.Temperature = &req.Target.Temperature
|
||||||
|
}
|
||||||
|
if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens {
|
||||||
|
wireReq.MaxTokens = &req.Target.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Target.TopP != 0 || req.TargetPresence.TopP {
|
||||||
|
wireReq.TopP = &req.Target.TopP
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ServiceTier) != "" {
|
||||||
|
wireReq.ServiceTier = req.Target.ServiceTier
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ReasoningEffort) != "" {
|
||||||
|
wireReq.ReasoningEffort = req.Target.ReasoningEffort
|
||||||
|
}
|
||||||
|
if len(req.Target.ExtraParams) > 0 {
|
||||||
|
wireReq.ExtraParams = req.Target.ExtraParams
|
||||||
|
}
|
||||||
|
if req.StructuredOutput != nil {
|
||||||
|
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
||||||
|
if err != nil {
|
||||||
|
return openAIChatRequest{}, err
|
||||||
|
}
|
||||||
|
wireReq.ResponseFormat = responseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
return wireReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []openAIChatRequestMessage `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
||||||
|
out := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
}
|
||||||
|
if req.SessionID != "" {
|
||||||
|
out["session_id"] = req.SessionID
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out["max_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ServiceTier != "" {
|
||||||
|
out["service_tier"] = req.ServiceTier
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
out["reasoning_effort"] = req.ReasoningEffort
|
||||||
|
}
|
||||||
|
if req.ResponseFormat != nil {
|
||||||
|
out["response_format"] = req.ResponseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range req.ExtraParams {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("extra_params key must not be empty")
|
||||||
|
}
|
||||||
|
if IsReservedOpenAIChatRequestField(key) {
|
||||||
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
|
}
|
||||||
|
if _, err := json.Marshal(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReservedOpenAIChatRequestField reports whether name is owned by the
|
||||||
|
// standard OpenAI-compatible chat request rather than extra parameters.
|
||||||
|
func IsReservedOpenAIChatRequestField(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "model",
|
||||||
|
"session_id",
|
||||||
|
"messages",
|
||||||
|
"temperature",
|
||||||
|
"max_tokens",
|
||||||
|
"top_p",
|
||||||
|
"service_tier",
|
||||||
|
"reasoning_effort",
|
||||||
|
"response_format":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequestMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatTextContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CacheControl *openAICacheControl `json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAICacheControl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL string `json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponseMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message openAIChatResponseMessage `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
} `json:"prompt_tokens_details"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema *openAIJSONSchemaEnvelope `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIJSONSchemaEnvelope struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestMessageFromRenderedMessage(msg domain.RenderedMessage) openAIChatRequestMessage {
|
||||||
|
wireMsg := openAIChatRequestMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
}
|
||||||
|
if msg.CacheControl == nil {
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
wireMsg.Content = []openAIChatTextContentBlock{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: msg.Content,
|
||||||
|
CacheControl: &openAICacheControl{
|
||||||
|
Type: string(msg.CacheControl.Type),
|
||||||
|
TTL: msg.CacheControl.TTL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
func toOpenAIResponseFormat(spec *domain.StructuredOutputSpec) (*openAIResponseFormat, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spec.Type {
|
||||||
|
case domain.StructuredOutputJSONSchema:
|
||||||
|
if spec.JSONSchema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema payload")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.JSONSchema.Name) == "" {
|
||||||
|
return nil, errors.New("json_schema structured output requires non-empty schema name")
|
||||||
|
}
|
||||||
|
if spec.JSONSchema.Schema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema document")
|
||||||
|
}
|
||||||
|
return &openAIResponseFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
JSONSchema: &openAIJSONSchemaEnvelope{
|
||||||
|
Name: spec.JSONSchema.Name,
|
||||||
|
Strict: spec.JSONSchema.Strict,
|
||||||
|
Schema: spec.JSONSchema.Schema,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported structured output type %q", spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
1192
internal/llm/openai_compatible_client_test.go
Normal file
1192
internal/llm/openai_compatible_client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
8
internal/profile/builtin/assets/aion-labs/aion-2.yml
Normal file
8
internal/profile/builtin/assets/aion-labs/aion-2.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: aion-2
|
||||||
|
backend: openrouter
|
||||||
|
model: aion-labs/aion-2.0
|
||||||
|
temperature: 0.72
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: claude-fable-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~anthropic/claude-fable-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 600
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: claude-haiku-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~anthropic/claude-haiku-latest"
|
||||||
|
reasoning_effort: medium
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: claude-opus-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~anthropic/claude-opus-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: claude-sonnet-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~anthropic/claude-sonnet-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: deepseek-3-2
|
||||||
|
backend: openrouter
|
||||||
|
model: deepseek/deepseek-v3.2
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: deepseek-4-flash
|
||||||
|
backend: openrouter
|
||||||
|
model: deepseek/deepseek-v4-flash
|
||||||
|
#reasoning_effort: medium
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: deepseek-4-pro
|
||||||
|
backend: openrouter
|
||||||
|
model: deepseek/deepseek-v4-pro
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-2-flash-lite
|
||||||
|
backend: openrouter
|
||||||
|
model: "google/gemini-2.5-flash-lite"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-2-flash
|
||||||
|
backend: openrouter
|
||||||
|
model: "google/gemini-2.5-flash"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
8
internal/profile/builtin/assets/google/gemini-2-pro.yml
Normal file
8
internal/profile/builtin/assets/google/gemini-2-pro.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-2-pro
|
||||||
|
backend: openrouter
|
||||||
|
model: "google/gemini-2.5-pro"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-3-flash-lite
|
||||||
|
backend: openrouter
|
||||||
|
model: "google/gemini-3.1-flash-lite"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-flash-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~google/gemini-flash-latest"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemini-pro-latest
|
||||||
|
backend: openrouter
|
||||||
|
model: "~google/gemini-pro-latest"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
8
internal/profile/builtin/assets/google/gemma-4-31b.yml
Normal file
8
internal/profile/builtin/assets/google/gemma-4-31b.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: gemma-4-31b
|
||||||
|
backend: openrouter
|
||||||
|
model: google/gemma-4-31b-it:exacto
|
||||||
|
temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
8
internal/profile/builtin/assets/minimax/minimax-m2.yml
Normal file
8
internal/profile/builtin/assets/minimax/minimax-m2.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: minimax-m2
|
||||||
|
backend: openrouter
|
||||||
|
model: minimax/minimax-m2.5
|
||||||
|
temperature: 0.5
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
8
internal/profile/builtin/assets/minimax/minimax-m3.yml
Normal file
8
internal/profile/builtin/assets/minimax/minimax-m3.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: minimax-m3
|
||||||
|
backend: openrouter
|
||||||
|
model: minimax/minimax-m3
|
||||||
|
#temperature: 0.5
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: mistral-large-2512
|
||||||
|
backend: openrouter
|
||||||
|
model: mistralai/mistral-large-2512
|
||||||
|
temperature: 0.15
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: mistral-medium-3-5
|
||||||
|
backend: openrouter
|
||||||
|
model: mistralai/mistral-medium-3-5
|
||||||
|
temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: mistral-small-3
|
||||||
|
backend: openrouter
|
||||||
|
model: mistralai/mistral-small-3.2-24b-instruct
|
||||||
|
temperature: 0.05
|
||||||
|
top_p: 1.0
|
||||||
|
timeout_seconds: 180
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: mistral-small-4
|
||||||
|
backend: openrouter
|
||||||
|
model: mistralai/mistral-small-2603
|
||||||
|
temperature: 0.1
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
id: nemotron-3-ultra
|
||||||
|
backend: openrouter
|
||||||
|
model: nvidia/nemotron-3-ultra-550b-a55b
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
6
internal/profile/builtin/assets/openai/gpt-5-mini.yml
Normal file
6
internal/profile/builtin/assets/openai/gpt-5-mini.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
id: gpt-5-mini
|
||||||
|
backend: openrouter
|
||||||
|
model: "openai/gpt-5.4-mini"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
6
internal/profile/builtin/assets/openai/gpt-5-nano.yml
Normal file
6
internal/profile/builtin/assets/openai/gpt-5-nano.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
id: gpt-5-nano
|
||||||
|
backend: openrouter
|
||||||
|
model: "openai/gpt-5.4-nano"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
31
internal/profile/builtin/repository.go
Normal file
31
internal/profile/builtin/repository.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const assetRoot = "assets"
|
||||||
|
|
||||||
|
//go:embed assets/**/*.yml
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func NewRepository() profile.Repository {
|
||||||
|
return profile.NewFSRepository(assets, assetRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoryWithPrimary(primary profile.Repository) profile.Repository {
|
||||||
|
if primary == nil {
|
||||||
|
return NewRepository()
|
||||||
|
}
|
||||||
|
return profile.NewOverlayRepository(primary, NewRepository())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoryWithDirectory(dir string) profile.Repository {
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return NewRepository()
|
||||||
|
}
|
||||||
|
return NewRepositoryWithPrimary(profile.NewFilesystemRepository(dir))
|
||||||
|
}
|
||||||
143
internal/profile/builtin/repository_test.go
Normal file
143
internal/profile/builtin/repository_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltInProfilesValidateThroughRepository(t *testing.T) {
|
||||||
|
repo := NewRepository()
|
||||||
|
ids := loadBuiltInProfileIDs(t)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
t.Fatal("expected built-in profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range ids {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected built-in profile %q to load, got %v", id, err)
|
||||||
|
}
|
||||||
|
if p.ID != id {
|
||||||
|
t.Fatalf("expected profile id %q, got %q", id, p.ID)
|
||||||
|
}
|
||||||
|
if p.BackendID != backend.OpenRouterID {
|
||||||
|
t.Fatalf("expected profile %q to select %q, got %q", id, backend.OpenRouterID, p.BackendID)
|
||||||
|
}
|
||||||
|
if p.Endpoint != "" || p.APIKeyEnv != "" {
|
||||||
|
t.Fatalf("expected profile %q to inherit backend connection settings, got endpoint=%q api_key_env=%q", id, p.Endpoint, p.APIKeyEnv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInProfilesDoNotContainDuplicateIDsOrRawAPIKeys(t *testing.T) {
|
||||||
|
loadBuiltInProfileIDs(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBuiltInProfileIDs(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ids := map[string]string{}
|
||||||
|
err := fs.WalkDir(assets, assetRoot, func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(name, ".yml") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := assets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read built-in profile %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("failed to decode built-in profile %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, ok := raw["api_key"]; ok {
|
||||||
|
t.Fatalf("built-in profile %s contains raw api_key", name)
|
||||||
|
}
|
||||||
|
if raw["backend"] != backend.OpenRouterID {
|
||||||
|
t.Fatalf("built-in profile %s does not select %q", name, backend.OpenRouterID)
|
||||||
|
}
|
||||||
|
if _, ok := raw["endpoint"]; ok {
|
||||||
|
t.Fatalf("built-in profile %s repeats endpoint", name)
|
||||||
|
}
|
||||||
|
if _, ok := raw["api_key_env"]; ok {
|
||||||
|
t.Fatalf("built-in profile %s repeats api_key_env", name)
|
||||||
|
}
|
||||||
|
id, ok := raw["id"].(string)
|
||||||
|
if !ok || strings.TrimSpace(id) == "" {
|
||||||
|
t.Fatalf("built-in profile %s has missing id", name)
|
||||||
|
}
|
||||||
|
if previous, ok := ids[id]; ok {
|
||||||
|
t.Fatalf("duplicate built-in profile id %q in %s and %s", id, previous, name)
|
||||||
|
}
|
||||||
|
ids[id] = name
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to walk built-in profiles: %v", err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryUsesPrimaryBeforeBuiltIns(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{
|
||||||
|
profiles: map[string]string{"mistral-small-3": "custom-model"},
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected profile to load, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "custom-model" {
|
||||||
|
t.Fatalf("expected primary profile to override built-in, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryFallsBackToBuiltIns(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected built-in profile to load, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "mistral-small-3" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryDoesNotFallBackAfterPrimaryError(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{err: profile.ErrInvalidProfile})
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if !errors.Is(err, profile.ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected primary error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticProfileRepo struct {
|
||||||
|
profiles map[string]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
if model, ok := r.profiles[id]; ok {
|
||||||
|
return &domain.ExecutionProfile{ID: id, Endpoint: "http://primary/v1", Model: model}, nil
|
||||||
|
}
|
||||||
|
return nil, profile.ErrProfileNotFound
|
||||||
|
}
|
||||||
214
internal/profile/filesystem_repository.go
Normal file
214
internal/profile/filesystem_repository.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProfileNotFound = errors.New("execution profile not found")
|
||||||
|
ErrInvalidYAML = errors.New("invalid YAML format")
|
||||||
|
ErrInvalidProfile = errors.New("invalid execution profile configuration")
|
||||||
|
ErrRawAPIKeyNotAllowed = errors.New("raw api_key is not allowed; use api_key_env")
|
||||||
|
)
|
||||||
|
|
||||||
|
type filesystemRepository struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFilesystemRepository(dir string) Repository {
|
||||||
|
return &filesystemRepository{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
return loadProfile(ctx, os.DirFS(r.dir), ".", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsRepository struct {
|
||||||
|
fsys fs.FS
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
||||||
|
return &fsRepository{fsys: fsys, root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fsRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
return loadProfile(ctx, r.fsys, r.root, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type overlayRepository struct {
|
||||||
|
primary Repository
|
||||||
|
fallback Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOverlayRepository(primary, fallback Repository) Repository {
|
||||||
|
return &overlayRepository{primary: primary, fallback: fallback}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *overlayRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.primary != nil {
|
||||||
|
prof, err := r.primary.GetProfile(ctx, id)
|
||||||
|
if err == nil {
|
||||||
|
return prof, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.fallback == nil {
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
|
return r.fallback.GetProfile(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile)
|
||||||
|
}
|
||||||
|
if fsys == nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []profileMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
metadata := readProfileFileMetadata(data)
|
||||||
|
idMatch := fileMatch || metadata.id == id
|
||||||
|
if metadata.hasRawAPIKey {
|
||||||
|
if idMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRawAPIKeyNotAllowed, relPath)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var prof domain.ExecutionProfile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&prof); err != nil {
|
||||||
|
if idMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if prof.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prof.BackendID = strings.TrimSpace(prof.BackendID)
|
||||||
|
if err := validateProfile(&prof); err != nil {
|
||||||
|
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", err, relPath)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
|
||||||
|
}
|
||||||
|
matches = append(matches, profileMatch{
|
||||||
|
profile: &prof,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate execution profile id %q found in: %s", ErrInvalidProfile, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileMatch struct {
|
||||||
|
profile *domain.ExecutionProfile
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileFileMetadata struct {
|
||||||
|
id string
|
||||||
|
hasRawAPIKey bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProfileFileMetadata(data []byte) profileFileMetadata {
|
||||||
|
var node yaml.Node
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&node); err != nil {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
mapping := node.Content[0]
|
||||||
|
if mapping.Kind != yaml.MappingNode {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata profileFileMetadata
|
||||||
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||||
|
key := mapping.Content[i]
|
||||||
|
value := mapping.Content[i+1]
|
||||||
|
switch key.Value {
|
||||||
|
case "id":
|
||||||
|
metadata.id = strings.TrimSpace(value.Value)
|
||||||
|
case "api_key":
|
||||||
|
metadata.hasRawAPIKey = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProfile(p *domain.ExecutionProfile) error {
|
||||||
|
if strings.TrimSpace(p.ID) == "" {
|
||||||
|
return errors.New("id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.BackendID) == "" && strings.TrimSpace(p.Endpoint) == "" {
|
||||||
|
return errors.New("backend or endpoint is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Model) == "" {
|
||||||
|
return errors.New("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Temperature < 0 || p.Temperature > 2 {
|
||||||
|
return errors.New("temperature must be between 0 and 2")
|
||||||
|
}
|
||||||
|
if p.MaxTokens < 0 {
|
||||||
|
return errors.New("max_tokens must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if p.TopP < 0 || p.TopP > 1 {
|
||||||
|
return errors.New("top_p must be between 0 and 1")
|
||||||
|
}
|
||||||
|
if p.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout_seconds must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
12
internal/profile/repository.go
Normal file
12
internal/profile/repository.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository loads execution profiles.
|
||||||
|
type Repository interface {
|
||||||
|
GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error)
|
||||||
|
}
|
||||||
516
internal/profile/repository_test.go
Normal file
516
internal/profile/repository_test.go
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystemRepository_GetProfile(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "execution_profile_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
files, err := os.ReadDir("testdata")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read testdata: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
src := filepath.Join("testdata", f.Name())
|
||||||
|
dst := filepath.Join(tmpDir, f.Name())
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewFilesystemRepository(tmpDir)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("valid local profile", func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(ctx, "local-default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "local-default" {
|
||||||
|
t.Fatalf("unexpected id: %q", p.ID)
|
||||||
|
}
|
||||||
|
if p.Endpoint == "" || p.Model == "" {
|
||||||
|
t.Fatalf("expected endpoint/model to be set: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("backend and endpoint connection matrix", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
connection string
|
||||||
|
wantBackend string
|
||||||
|
wantEndpoint string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "backend only", connection: "backend: ' openrouter '", wantBackend: "openrouter"},
|
||||||
|
{name: "endpoint only", connection: "endpoint: http://localhost:8000/v1", wantEndpoint: "http://localhost:8000/v1"},
|
||||||
|
{name: "both", connection: "backend: openrouter\nendpoint: http://localhost:8000/v1", wantBackend: "openrouter", wantEndpoint: "http://localhost:8000/v1"},
|
||||||
|
{name: "neither", wantErr: true},
|
||||||
|
{name: "blank backend", connection: "backend: ' '", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
id := "connection-" + strings.ReplaceAll(tt.name, " ", "-")
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, id+".yaml"), "id: "+id+"\nmodel: model\n"+tt.connection+"\n")
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, id)
|
||||||
|
if tt.wantErr {
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected profile to load, got %v", err)
|
||||||
|
}
|
||||||
|
if p.BackendID != tt.wantBackend || p.Endpoint != tt.wantEndpoint {
|
||||||
|
t.Fatalf("unexpected connection values: backend=%q endpoint=%q", p.BackendID, p.Endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid profile with api_key_env", func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(ctx, "local-secure")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.APIKeyEnv != "PROMPTKIT_API_KEY" {
|
||||||
|
t.Fatalf("unexpected api_key_env: %q", p.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if p.ReasoningEffort != "medium" {
|
||||||
|
t.Fatalf("unexpected reasoning_effort: %q", p.ReasoningEffort)
|
||||||
|
}
|
||||||
|
if p.ServiceTier != "priority" {
|
||||||
|
t.Fatalf("unexpected service_tier: %q", p.ServiceTier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid nested profile", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "local")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "nested-local.yaml"), `
|
||||||
|
id: nested-local
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
temperature: 0.1
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "nested-local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "nested-model" {
|
||||||
|
t.Fatalf("unexpected model: %q", p.Model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid profile with JSON-compatible extra params", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "json-extra-params.yaml"), `
|
||||||
|
id: json-extra-params
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
extra_params:
|
||||||
|
string_value: enabled
|
||||||
|
number_value: 42
|
||||||
|
boolean_value: true
|
||||||
|
object_value:
|
||||||
|
nested: value
|
||||||
|
count: 2
|
||||||
|
array_value:
|
||||||
|
- first
|
||||||
|
- 3
|
||||||
|
- false
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "json-extra-params")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
encoded, err := json.Marshal(p.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected extra_params to marshal as JSON, got %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(encoded, &got); err != nil {
|
||||||
|
t.Fatalf("expected extra_params JSON to decode, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["string_value"] != "enabled" {
|
||||||
|
t.Fatalf("unexpected string extra param: %#v", got["string_value"])
|
||||||
|
}
|
||||||
|
if got["number_value"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected number extra param: %#v", got["number_value"])
|
||||||
|
}
|
||||||
|
if got["boolean_value"] != true {
|
||||||
|
t.Fatalf("unexpected boolean extra param: %#v", got["boolean_value"])
|
||||||
|
}
|
||||||
|
objectValue, ok := got["object_value"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected object extra param, got %#v", got["object_value"])
|
||||||
|
}
|
||||||
|
if objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
|
||||||
|
t.Fatalf("unexpected object extra param: %#v", objectValue)
|
||||||
|
}
|
||||||
|
arrayValue, ok := got["array_value"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected array extra param, got %#v", got["array_value"])
|
||||||
|
}
|
||||||
|
if len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
|
||||||
|
t.Fatalf("unexpected array extra param: %#v", arrayValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), `
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: first-model
|
||||||
|
`)
|
||||||
|
nestedDir := filepath.Join(tmpDir, "duplicates")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "duplicate-profile-b.yaml"), `
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: second-model
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "duplicate-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected duplicate profile to return ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate execution profile id", "duplicate-profile-a.yaml", filepath.Join("duplicates", "duplicate-profile-b.yaml")} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nested raw api_key rejected for likely target file", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "secure")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), `
|
||||||
|
id: nested_raw_api_key
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: m
|
||||||
|
api_key: secret
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "nested_raw_api_key")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), filepath.Join("secure", "not_named_like_id.yaml")) {
|
||||||
|
t.Fatalf("expected nested path in error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("raw api_key in non-target profile is ignored", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "raw-api-key-non-target.yaml"), `
|
||||||
|
id: raw-api-key-non-target
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: m
|
||||||
|
api_key: secret
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "does-not-exist-with-raw-key-nearby")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound for non-target raw api_key file, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid yaml", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "invalid_yaml")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing id", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing_id")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing endpoint", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing-endpoint")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing model", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing-model")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown field", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "unknown_field")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML for strict decode unknown field, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("raw api_key rejected", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "raw_api_key")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile not found", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "does-not-exist")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProfileTestFile(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write profile test file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("loads valid profiles from nested directories", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/provider/nested.yaml": profileMapFile(`
|
||||||
|
id: nested-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
temperature: 0.1
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "nested-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "nested-profile" || p.Model != "nested-model" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects unknown YAML fields", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/unknown.yaml": profileMapFile(`
|
||||||
|
id: unknown-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
unknown: value
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "unknown-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects raw api_key in selected profile", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/raw.yaml": profileMapFile(`
|
||||||
|
id: raw-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
api_key: secret
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "raw-profile")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores raw api_key in non-selected profiles", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/raw.yaml": profileMapFile(`
|
||||||
|
id: raw-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
api_key: secret
|
||||||
|
`),
|
||||||
|
"profiles/valid.yaml": profileMapFile(`
|
||||||
|
id: valid-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "valid-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "valid-profile" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects duplicate IDs within one source", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/a.yaml": profileMapFile(`
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: first
|
||||||
|
`),
|
||||||
|
"profiles/nested/b.yaml": profileMapFile(`
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: second
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "duplicate-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate execution profile id", "a.yaml", "nested/b.yaml"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
primaryProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://primary", Model: "primary"}
|
||||||
|
fallbackProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://fallback", Model: "fallback"}
|
||||||
|
|
||||||
|
t.Run("returns primary matches before fallback matches", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": primaryProfile}},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "primary" {
|
||||||
|
t.Fatalf("expected primary profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back on primary not found", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "fallback" {
|
||||||
|
t.Fatalf("expected fallback profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not fall back after primary load errors", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "invalid yaml", err: ErrInvalidYAML},
|
||||||
|
{name: "invalid profile", err: ErrInvalidProfile},
|
||||||
|
{name: "raw api key", err: ErrRawAPIKeyNotAllowed},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{err: tc.err},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if !errors.Is(err, tc.err) {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns not found when both sources miss", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(staticProfileRepo{}, staticProfileRepo{})
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "missing")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil primary uses fallback", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(nil, staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "fallback" {
|
||||||
|
t.Fatalf("expected fallback profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil fallback returns not found after primary miss", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(staticProfileRepo{}, nil)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "missing")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileMapFile(content string) *fstest.MapFile {
|
||||||
|
return &fstest.MapFile{Data: []byte(strings.TrimLeft(content, "\n"))}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticProfileRepo struct {
|
||||||
|
profiles map[string]*domain.ExecutionProfile
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
if p, ok := r.profiles[id]; ok {
|
||||||
|
cp := *p
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
3
internal/profile/testdata/invalid_yaml.yaml
vendored
Normal file
3
internal/profile/testdata/invalid_yaml.yaml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: invalid_yaml
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: [broken
|
||||||
2
internal/profile/testdata/missing_endpoint.yaml
vendored
Normal file
2
internal/profile/testdata/missing_endpoint.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: missing-endpoint
|
||||||
|
model: gpt-4o-mini
|
||||||
2
internal/profile/testdata/missing_id.yaml
vendored
Normal file
2
internal/profile/testdata/missing_id.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
2
internal/profile/testdata/missing_model.yaml
vendored
Normal file
2
internal/profile/testdata/missing_model.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: missing-model
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
4
internal/profile/testdata/raw_api_key.yaml
vendored
Normal file
4
internal/profile/testdata/raw_api_key.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: raw-api-key
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key: super-secret-should-not-be-here
|
||||||
4
internal/profile/testdata/unknown_field.yaml
vendored
Normal file
4
internal/profile/testdata/unknown_field.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: unknown-field
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
foo: bar
|
||||||
7
internal/profile/testdata/valid_local_profile.yaml
vendored
Normal file
7
internal/profile/testdata/valid_local_profile.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: local-default
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 700
|
||||||
|
top_p: 1.0
|
||||||
|
timeout_seconds: 120
|
||||||
8
internal/profile/testdata/valid_with_api_key_env.yaml
vendored
Normal file
8
internal/profile/testdata/valid_with_api_key_env.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: local-secure
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key_env: PROMPTKIT_API_KEY
|
||||||
|
service_tier: priority
|
||||||
|
reasoning_effort: medium
|
||||||
|
extra_params:
|
||||||
|
provider: local
|
||||||
120
internal/prompt/go_renderer.go
Normal file
120
internal/prompt/go_renderer.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingRequiredInput = errors.New("missing required input artifact")
|
||||||
|
ErrUnknownInput = errors.New("referenced unknown input artifact")
|
||||||
|
ErrInvalidTemplate = errors.New("invalid prompt template")
|
||||||
|
ErrRenderFailure = errors.New("prompt render failure")
|
||||||
|
ErrInvalidMessageRole = errors.New("invalid or empty message role")
|
||||||
|
)
|
||||||
|
|
||||||
|
type goRenderer struct{}
|
||||||
|
|
||||||
|
func NewGoRenderer() Renderer {
|
||||||
|
return &goRenderer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
||||||
|
if definition == nil {
|
||||||
|
return nil, fmt.Errorf("%w: nil prompt definition", ErrRenderFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verify required inputs
|
||||||
|
for _, in := range definition.Inputs {
|
||||||
|
if !in.Required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
art, ok := inputs[in.Name]
|
||||||
|
if !ok || art == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, in.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setup template functions
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"input": func(name string) (string, error) {
|
||||||
|
art, ok := inputs[name]
|
||||||
|
if !ok || art == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrUnknownInput, name)
|
||||||
|
}
|
||||||
|
return string(art.Body), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := renderSessionID(definition.SessionID, funcs, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderedMessages []domain.RenderedMessage
|
||||||
|
|
||||||
|
for i, tmplMsg := range definition.Templates {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmplMsg.Role == "" {
|
||||||
|
return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: message %d: %w", ErrRenderFailure, i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedMessages = append(renderedMessages, domain.RenderedMessage{
|
||||||
|
Role: tmplMsg.Role,
|
||||||
|
Content: buf.String(),
|
||||||
|
CacheControl: cloneCacheControl(tmplMsg.CacheControl),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.RenderedPrompt{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Messages: renderedMessages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) (string, error) {
|
||||||
|
tmpl, err := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %w", ErrRenderFailure, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := domain.NormalizeSessionID(buf.String())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %v", ErrRenderFailure, err)
|
||||||
|
}
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := *in
|
||||||
|
return &out
|
||||||
|
}
|
||||||
11
internal/prompt/renderer.go
Normal file
11
internal/prompt/renderer.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renderer renders prompt templates using named artifacts and variables.
|
||||||
|
type Renderer interface {
|
||||||
|
Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error)
|
||||||
|
}
|
||||||
345
internal/prompt/renderer_test.go
Normal file
345
internal/prompt/renderer_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoRenderer_Render(t *testing.T) {
|
||||||
|
renderer := NewGoRenderer()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
inputs := map[string]*domain.Artifact{
|
||||||
|
"transcript": {Body: []byte("The quick brown fox.")},
|
||||||
|
}
|
||||||
|
vars := map[string]string{
|
||||||
|
"role": "helpful assistant",
|
||||||
|
"tone": "concise",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("rendering inline message content", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "Analyze this: The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected rendered content: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering file-backed message content loaded into prompt definition", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "From file: {{input \"transcript\"}}", ContentFile: "/tmp/user.tmpl"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := res.Messages[0].Content; got != "From file: The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected file-backed render result: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering system and user messages", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "You are a {{.role}}."},
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].Role != "system" || res.Messages[1].Role != "user" {
|
||||||
|
t.Fatalf("unexpected roles: %#v", res.Messages)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("copying cache control to rendered messages", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "You are concise.",
|
||||||
|
CacheControl: &domain.CacheControl{
|
||||||
|
Type: domain.CacheControlEphemeral,
|
||||||
|
TTL: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl == nil {
|
||||||
|
t.Fatal("expected rendered cache control")
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl.Type != domain.CacheControlEphemeral {
|
||||||
|
t.Fatalf("unexpected cache control type: %q", res.Messages[0].CacheControl.Type)
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl.TTL != "1h" {
|
||||||
|
t.Fatalf("unexpected cache control ttl: %q", res.Messages[0].CacheControl.TTL)
|
||||||
|
}
|
||||||
|
if res.Messages[1].CacheControl != nil {
|
||||||
|
t.Fatalf("expected no cache control on second message, got %#v", res.Messages[1].CacheControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendered cache control does not alias source template", func(t *testing.T) {
|
||||||
|
source := &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "You are concise.", CacheControl: source},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl == source {
|
||||||
|
t.Fatal("expected rendered cache control to be cloned")
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Messages[0].CacheControl.TTL = ""
|
||||||
|
if source.TTL != "1h" {
|
||||||
|
t.Fatalf("source cache control was mutated, ttl=%q", source.TTL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accessing vars", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "Speak in a concise tone." {
|
||||||
|
t.Fatalf("unexpected vars rendering: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering session id from vars", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " {{ .session_id }} ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": "agent-session-123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "agent-session-123" {
|
||||||
|
t.Fatalf("unexpected session id: %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty rendered session id is omitted", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "" {
|
||||||
|
t.Fatalf("expected empty session id, got %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing session id var fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("too long rendered session id fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": strings.Repeat("x", domain.SessionIDMaxLength+1),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inserting required input artifact", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "{{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected required input rendering: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("optional input absent and not referenced", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{
|
||||||
|
{Name: "transcript", Required: true},
|
||||||
|
{Name: "glossary", Required: false},
|
||||||
|
},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Transcript: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 1 {
|
||||||
|
t.Fatalf("expected one rendered message, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("optional input absent but referenced, expecting failure", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{
|
||||||
|
{Name: "transcript", Required: true},
|
||||||
|
{Name: "glossary", Required: false},
|
||||||
|
},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Glossary: {{input \"glossary\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnknownInput) {
|
||||||
|
t.Fatalf("expected ErrUnknownInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("required input missing, expecting failure", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, map[string]*domain.Artifact{}, vars)
|
||||||
|
if !errors.Is(err, ErrMissingRequiredInput) {
|
||||||
|
t.Fatalf("expected ErrMissingRequiredInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid template syntax", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Hello {{.unclosed"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrInvalidTemplate) {
|
||||||
|
t.Fatalf("expected ErrInvalidTemplate, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown input reference", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Hello {{input \"ghost\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnknownInput) {
|
||||||
|
t.Fatalf("expected ErrUnknownInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty message role", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "", Content: "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrInvalidMessageRole) {
|
||||||
|
t.Fatalf("expected ErrInvalidMessageRole, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
484
internal/promptdef/filesystem_repository.go
Normal file
484
internal/promptdef/filesystem_repository.go
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
package promptdef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPromptDefinitionNotFound = errors.New("prompt definition not found")
|
||||||
|
ErrInvalidYAML = errors.New("invalid YAML format")
|
||||||
|
ErrInvalidPromptDefinition = errors.New("invalid prompt definition configuration")
|
||||||
|
)
|
||||||
|
|
||||||
|
type filesystemRepository struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsRepository struct {
|
||||||
|
fsys fs.FS
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptDefinitionFile struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id"`
|
||||||
|
Inputs []promptInputFile `yaml:"inputs"`
|
||||||
|
Messages []promptMessageFile `yaml:"messages"`
|
||||||
|
Output promptOutputContractFile `yaml:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptInputFile struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType string `yaml:"content_type"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptMessageFile struct {
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
ContentFile string `yaml:"content_file"`
|
||||||
|
CacheControl *cacheControlFile `yaml:"cache_control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheControlFile struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
TTL string `yaml:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptOutputContractFile struct {
|
||||||
|
Format domain.OutputFormat `yaml:"format"`
|
||||||
|
ValidationMode domain.ValidationMode `yaml:"validation_mode"`
|
||||||
|
SchemaPath string `yaml:"schema_path"`
|
||||||
|
RepairAttempts int `yaml:"repair_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFilesystemRepository(dir string) Repository {
|
||||||
|
return &filesystemRepository{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
||||||
|
return &fsRepository{fsys: fsys, root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindYAMLFiles(ctx, r.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []promptDefinitionMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.RelativePath(r.dir, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id
|
||||||
|
|
||||||
|
raw, err := loadPromptDefinitionFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || promptDefinitionFileHasID(fullPath, id) {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
def, err := normalizePromptDefinition(raw, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if version != "" && def.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, promptDefinitionMatch{
|
||||||
|
def: def,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrPromptDefinitionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
return loadPromptDefinition(ctx, r.fsys, r.root, id, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptDefinitionMatch struct {
|
||||||
|
def *domain.PromptDefinition
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw promptDefinitionFile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDefinitionFileHasID(path string, id string) bool {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.ID) == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
||||||
|
}
|
||||||
|
if fsys == nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
cleanRoot := filecatalog.CleanFSRoot(root)
|
||||||
|
rootInfo, err := fs.Stat(fsys, cleanRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []promptDefinitionMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s: failed to read prompt definition file: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := decodePromptDefinition(data)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || promptDefinitionDataHasID(data, id) {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
def, err := normalizePromptDefinitionFromFS(raw, fsys, root, fullPath, rootInfo.IsDir())
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if version != "" && def.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, promptDefinitionMatch{
|
||||||
|
def: def,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrPromptDefinitionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
|
||||||
|
var raw promptDefinitionFile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDefinitionDataHasID(data []byte, id string) bool {
|
||||||
|
var raw struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.ID) == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) {
|
||||||
|
promptDir := filepath.Dir(sourcePath)
|
||||||
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
||||||
|
resolvedPath := strings.TrimSpace(contentFile)
|
||||||
|
if !filepath.IsAbs(resolvedPath) {
|
||||||
|
resolvedPath = filepath.Join(promptDir, resolvedPath)
|
||||||
|
}
|
||||||
|
resolvedPath = filepath.Clean(resolvedPath)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(body), resolvedPath, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, root string, sourcePath string, rootIsDir bool) (*domain.PromptDefinition, error) {
|
||||||
|
promptDir := path.Dir(sourcePath)
|
||||||
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
||||||
|
var resolvedPath string
|
||||||
|
if rootIsDir {
|
||||||
|
var err error
|
||||||
|
resolvedPath, _, err = filecatalog.ResolveFSPath(root, promptDir, contentFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedPath = strings.TrimSpace(contentFile)
|
||||||
|
if !path.IsAbs(resolvedPath) {
|
||||||
|
resolvedPath = path.Join(promptDir, resolvedPath)
|
||||||
|
}
|
||||||
|
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := fs.ReadFile(fsys, resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(body), resolvedPath, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContentFile func(string) (string, string, error)) (*domain.PromptDefinition, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, errors.New("prompt definition is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(raw.ID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, errors.New("id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(raw.Version)
|
||||||
|
if version == "" {
|
||||||
|
return nil, errors.New("version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw.Messages) == 0 {
|
||||||
|
return nil, errors.New("at least one message is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs := make([]domain.PromptInput, 0, len(raw.Inputs))
|
||||||
|
seenInputNames := make(map[string]struct{}, len(raw.Inputs))
|
||||||
|
for i, in := range raw.Inputs {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("input %d has empty name", i)
|
||||||
|
}
|
||||||
|
if _, exists := seenInputNames[name]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate input name %q", name)
|
||||||
|
}
|
||||||
|
seenInputNames[name] = struct{}{}
|
||||||
|
|
||||||
|
inputs = append(inputs, domain.PromptInput{
|
||||||
|
Name: name,
|
||||||
|
Required: in.Required,
|
||||||
|
ContentType: strings.TrimSpace(in.ContentType),
|
||||||
|
Description: strings.TrimSpace(in.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
||||||
|
for i, msg := range raw.Messages {
|
||||||
|
role := strings.TrimSpace(msg.Role)
|
||||||
|
if role == "" {
|
||||||
|
return nil, fmt.Errorf("message %d role is required", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent := strings.TrimSpace(msg.Content) != ""
|
||||||
|
hasContentFile := strings.TrimSpace(msg.ContentFile) != ""
|
||||||
|
if hasContent == hasContentFile {
|
||||||
|
return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheControl, err := normalizeCacheControl(msg.CacheControl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateContent := msg.Content
|
||||||
|
resolvedContentFile := ""
|
||||||
|
if hasContentFile {
|
||||||
|
body, resolvedPath, err := readContentFile(msg.ContentFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prompt %q message %d (%s): failed to read content_file %q: %w", id, i, role, msg.ContentFile, err)
|
||||||
|
}
|
||||||
|
templateContent = body
|
||||||
|
resolvedContentFile = resolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, domain.PromptMessageTemplate{
|
||||||
|
Role: role,
|
||||||
|
Content: templateContent,
|
||||||
|
ContentFile: resolvedContentFile,
|
||||||
|
CacheControl: cacheControl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidOutputFormat(raw.Output.Format) {
|
||||||
|
return nil, fmt.Errorf("invalid output format: %q", raw.Output.Format)
|
||||||
|
}
|
||||||
|
if !isValidValidationMode(raw.Output.ValidationMode) {
|
||||||
|
return nil, fmt.Errorf("invalid validation mode: %q", raw.Output.ValidationMode)
|
||||||
|
}
|
||||||
|
if raw.Output.ValidationMode == domain.ValidationJSONSchema && strings.TrimSpace(raw.Output.SchemaPath) == "" {
|
||||||
|
return nil, errors.New("output.schema_path is required when output.validation_mode is json_schema")
|
||||||
|
}
|
||||||
|
if raw.Output.RepairAttempts < 0 {
|
||||||
|
return nil, errors.New("output.repair_attempts must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultProfile := ""
|
||||||
|
if raw.DefaultProfile != nil {
|
||||||
|
defaultProfile = strings.TrimSpace(*raw.DefaultProfile)
|
||||||
|
if defaultProfile == "" {
|
||||||
|
return nil, errors.New("default_profile must be a non-empty string when set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.PromptDefinition{
|
||||||
|
ID: id,
|
||||||
|
Version: version,
|
||||||
|
DefaultProfile: defaultProfile,
|
||||||
|
Description: strings.TrimSpace(raw.Description),
|
||||||
|
SessionID: strings.TrimSpace(raw.SessionID),
|
||||||
|
Inputs: inputs,
|
||||||
|
Templates: templates,
|
||||||
|
OutputFormat: raw.Output.Format,
|
||||||
|
Validation: domain.OutputContract{
|
||||||
|
Format: raw.Output.Format,
|
||||||
|
ValidationMode: raw.Output.ValidationMode,
|
||||||
|
SchemaPath: strings.TrimSpace(raw.Output.SchemaPath),
|
||||||
|
RepairAttempts: raw.Output.RepairAttempts,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheType := strings.TrimSpace(raw.Type)
|
||||||
|
if cacheType == "" {
|
||||||
|
return nil, errors.New("type is required")
|
||||||
|
}
|
||||||
|
if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral {
|
||||||
|
return nil, fmt.Errorf("unsupported type %q", cacheType)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := strings.TrimSpace(raw.TTL)
|
||||||
|
if ttl != "" && ttl != "1h" {
|
||||||
|
return nil, fmt.Errorf("unsupported ttl %q", ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.CacheControl{
|
||||||
|
Type: domain.CacheControlType(cacheType),
|
||||||
|
TTL: ttl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidOutputFormat(f domain.OutputFormat) bool {
|
||||||
|
switch f {
|
||||||
|
case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidValidationMode(m domain.ValidationMode) bool {
|
||||||
|
switch m {
|
||||||
|
case domain.ValidationNone, domain.ValidationBasic, domain.ValidationJSON, domain.ValidationJSONSchema:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user