Compare commits
7 Commits
ffe6d261a9
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e361c97bb5 | |||
| be67707582 | |||
| e61ab700c7 | |||
| d2c4051dd0 | |||
| 861da355d8 | |||
| a752f88166 | |||
| 238fa90bfa |
@@ -31,6 +31,11 @@ Contributors should start with the [development guide](docs/development.md).
|
||||
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||
boundary and constraints that framework work must preserve.
|
||||
|
||||
## Release Guidance
|
||||
|
||||
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
|
||||
|
||||
27
backends.go
27
backends.go
@@ -31,6 +31,17 @@ type Backend struct {
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// WithBackend adds one Backend registration to the constructed Engine.
|
||||
@@ -43,12 +54,20 @@ type Backend struct {
|
||||
// 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,
|
||||
ID: backend.ID,
|
||||
Endpoint: backend.Endpoint,
|
||||
APIKeyEnv: backend.APIKeyEnv,
|
||||
ExtraParams: backend.ExtraParams,
|
||||
ConcurrencyLimit: backend.ConcurrencyLimit,
|
||||
QueueCapacity: queueCapacity,
|
||||
QueueCapacitySet: queueCapacitySet,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
369
capacity_contract_test.go
Normal file
369
capacity_contract_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
for _, unrelated := range []error{
|
||||
promptkit.ErrInvalidConfig,
|
||||
promptkit.ErrInvalidRequest,
|
||||
promptkit.ErrLLMGenerate,
|
||||
promptkit.ErrValidation,
|
||||
} {
|
||||
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
||||
errors.Is(unrelated, promptkit.ErrCapacityExceeded) {
|
||||
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
|
||||
}
|
||||
8
doc.go
8
doc.go
@@ -9,9 +9,11 @@
|
||||
//
|
||||
// # Concurrency and ownership
|
||||
//
|
||||
// An Engine supports concurrent Prepare and Run calls. An injected [LLMClient]
|
||||
// or [ArtifactReader] can therefore receive concurrent calls and must be safe
|
||||
// for that use.
|
||||
// An Engine supports concurrent Prepare and Run calls. Engine-local backend
|
||||
// policies bound admitted Run 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 and Run
|
||||
// copy request maps, slices, pointer values, and JSON-compatible extra
|
||||
|
||||
@@ -127,7 +127,9 @@ exact normalization, precedence, error, copying, and exposure contract.
|
||||
### Register A Custom Backend
|
||||
|
||||
Register a reusable OpenAI-compatible connection once, then select it from a
|
||||
profile:
|
||||
profile. This local backend limits model generation to two simultaneous calls;
|
||||
because `QueueCapacity` is omitted, the engine admits up to 1024 additional
|
||||
calls waiting behind them:
|
||||
|
||||
```go
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
@@ -137,6 +139,7 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
ID: "local",
|
||||
Endpoint: "http://localhost:8000/v1",
|
||||
APIKeyEnv: "LOCAL_LLM_API_KEY",
|
||||
ConcurrencyLimit: 2,
|
||||
}),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "local-summary",
|
||||
@@ -148,15 +151,42 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
|
||||
Registrations belong to one engine and custom IDs cannot replace built-ins.
|
||||
The [`Backend` and `WithBackend` GoDoc](../../backends.go) defines validation,
|
||||
copying, uniqueness, and request-default behavior.
|
||||
copying, uniqueness, exact concurrency-field semantics, 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 identity. `PreparedRun.SelectedBackendID`,
|
||||
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",
|
||||
Endpoint: "http://localhost: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
|
||||
@@ -200,6 +230,22 @@ failures. Specific request conditions may also match the broader
|
||||
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) {
|
||||
// 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
|
||||
[`Engine.Run` and error GoDoc](../../engine.go) owns exact error and
|
||||
cancellation identities.
|
||||
|
||||
## Application Boundary
|
||||
|
||||
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||
|
||||
@@ -207,7 +207,10 @@ 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. A non-empty
|
||||
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)
|
||||
|
||||
100
docs/internal/capacity.md
Normal file
100
docs/internal/capacity.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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 Run Admission
|
||||
|
||||
The runner asks the manager to admit a run after resolving the prompt, profile,
|
||||
selected backend, effective execution target, credentials, and output contract,
|
||||
but before schema loading, artifact loading, or rendering. Admission is
|
||||
immediate: a limited pool either reserves a slot or returns the internal
|
||||
`ErrCapacityExceeded` identity. The root facade maps that identity to the
|
||||
public error 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 and holds the lease across remaining
|
||||
preparation, initial generation, validation, every repair attempt, and all
|
||||
failure or cancellation 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 early admission,
|
||||
lease lifetime, failure release, and shared initial/repair scheduling. 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
|
||||
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
||||
the public generation category and context identity when generation is
|
||||
canceled.
|
||||
@@ -11,10 +11,11 @@ contributor workflow and validation.
|
||||
|
||||
| Component | Implemented responsibility | References |
|
||||
| --- | --- | --- |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, profile construction, extension interfaces, value conversion, redacted formatting, and public error mapping. | [Package GoDoc](../../doc.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request and result values, profile construction, extension interfaces, value conversion, redacted formatting, public error mapping, and engine-local assembly. | [Package GoDoc](../../doc.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 run 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) |
|
||||
|
||||
@@ -19,17 +19,20 @@ and override semantics consumed by the runner.
|
||||
`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. 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.
|
||||
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.
|
||||
|
||||
## Preparation Flow
|
||||
## Shared Preparation Pipeline
|
||||
|
||||
`Prepare` performs the reusable pre-generation workflow:
|
||||
`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;
|
||||
@@ -38,13 +41,25 @@ The runner has no durable run or session store.
|
||||
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 output contract and load a structured-output schema when
|
||||
required;
|
||||
8. load and hash input artifacts;
|
||||
9. render messages, resolve the effective session ID, and hash the effective
|
||||
rendered prompt; and
|
||||
10. return the effective settings, source identities, messages, hashes, and
|
||||
preparation timing.
|
||||
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
|
||||
@@ -70,15 +85,28 @@ invocation state local.
|
||||
|
||||
## Run Flow
|
||||
|
||||
`Run` calls `Prepare` rather than maintaining a second preparation path. It
|
||||
performs 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.
|
||||
`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. This capability remains internal and is not a public option.
|
||||
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
|
||||
@@ -94,8 +122,20 @@ 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. Context cancellation propagates through the invoked
|
||||
collaborator and is classified by the owning operation.
|
||||
internal contract.
|
||||
|
||||
Admission capacity exhaustion retains the internal capacity identity and adds
|
||||
the selected backend ID as context. 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,
|
||||
@@ -104,12 +144,16 @@ 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, direct-session resolution,
|
||||
schema-before-generation behavior, hashing, generation and validation
|
||||
outcomes, backend propagation, bounded repair, credentials and redaction,
|
||||
error categories, artifact metadata, usage, and timing.
|
||||
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 `Run`'s use
|
||||
of `Prepare`. Source, renderer, validator, or model-client contract changes
|
||||
belong first in their owning package and document.
|
||||
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.
|
||||
|
||||
@@ -23,6 +23,8 @@ The implemented internal components consist of:
|
||||
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
|
||||
@@ -49,19 +51,21 @@ 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, 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
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -81,10 +85,12 @@ downstream consumers, including Scriptorium
|
||||
The backend registry depends on the domain model and shared JSON-value
|
||||
validation, has no mutation API after construction, and consumes the
|
||||
OpenAI-compatible reserved request-field rule owned by the model client. The
|
||||
model client does not depend on registry 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ mechanisms, not secret values.
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -85,9 +86,9 @@ mechanisms, not secret values.
|
||||
| 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
|
||||
consumer, integration, subsystem, ADR, roadmap, or example document only when
|
||||
the corresponding implemented interface, decision, planned effort, or
|
||||
maintained artifact exists.
|
||||
consumer, integration, release, subsystem, ADR, roadmap, or example document
|
||||
only when the corresponding implemented interface, release, decision, planned
|
||||
effort, or maintained artifact exists.
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
@@ -109,6 +110,23 @@ 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
|
||||
|
||||
This policy owns what documentation and examples may contain. Architecture owns
|
||||
@@ -160,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
|
||||
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:
|
||||
|
||||
- verify affected behavior and examples;
|
||||
|
||||
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
|
||||
[custom-backend consumer guide](../consumers/pkg-promptkit.md#register-a-custom-backend)
|
||||
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).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend-Specific Concurrency Management
|
||||
|
||||
**Status:** Accepted.
|
||||
**Status:** Complete.
|
||||
|
||||
## Purpose
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend-Specific Concurrency Management Implementation Plan
|
||||
|
||||
**Status:** Ready for implementation.
|
||||
**Status:** Complete.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -464,7 +464,7 @@ behavior already protects them.
|
||||
|
||||
## Stage 1 — Backend Policy And Public Configuration
|
||||
|
||||
**Status:** Pending.
|
||||
**Status:** Complete.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -523,7 +523,7 @@ unlimited by omission, and no runtime call is scheduled yet.
|
||||
|
||||
## Stage 2 — Engine-Local Capacity Manager
|
||||
|
||||
**Status:** Pending.
|
||||
**Status:** Complete.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -586,7 +586,7 @@ are independent, and the wrapper is transparent apart from waiting.
|
||||
|
||||
## Stage 3 — Shared Preparation And Early Run Admission
|
||||
|
||||
**Status:** Pending.
|
||||
**Status:** Complete.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -647,7 +647,7 @@ releases admission, and existing preparation semantics remain unchanged.
|
||||
|
||||
## Stage 4 — Engine Assembly And Public Runtime Contract
|
||||
|
||||
**Status:** Pending.
|
||||
**Status:** Complete.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -721,7 +721,7 @@ through the same wrapper.
|
||||
|
||||
## Stage 5 — Durable Documentation And Final Validation
|
||||
|
||||
**Status:** Pending.
|
||||
**Status:** Complete.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -819,6 +819,19 @@ limits and cancellation safety, durable contracts no longer depend on roadmap
|
||||
prose, and the OpenRouter compatibility change is clearly reported for the
|
||||
next minor release.
|
||||
|
||||
## Implementation Handoff
|
||||
|
||||
Backend-specific capacity management is implemented and has passed the complete
|
||||
repository validation sequence. The built-in OpenRouter backend now permits 16
|
||||
active generations and a waiting capacity of 1024. Custom backends remain
|
||||
unlimited when their limit is omitted, and endpoint-only profiles remain
|
||||
unlimited.
|
||||
|
||||
Publishing this behavior requires a pre-`v1` minor release. Its release notes
|
||||
must identify that unusually high concurrent OpenRouter use can now wait or
|
||||
return `ErrCapacityExceeded`. This implementation does not change a module
|
||||
version or create a tag.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The feature roadmap and this plan fix the public representation,
|
||||
|
||||
40
engine.go
40
engine.go
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
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"
|
||||
@@ -62,6 +63,10 @@ var (
|
||||
// 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 rejected because the selected backend
|
||||
// already admitted ConcurrencyLimit + QueueCapacity calls. 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.
|
||||
@@ -75,7 +80,9 @@ var (
|
||||
// Engine prepares and runs Promptkit prompt requests.
|
||||
//
|
||||
// An Engine is safe for concurrent calls to [Engine.Prepare] and [Engine.Run].
|
||||
// Injected collaborators may consequently be invoked concurrently.
|
||||
// Each Engine owns independent backend-capacity pools that coordinate Run
|
||||
// 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
|
||||
}
|
||||
@@ -141,8 +148,10 @@ type engineOptions struct {
|
||||
|
||||
// WithLLMClient replaces the built-in model client used by [Engine.Run].
|
||||
//
|
||||
// A nil client makes NewEngine fail with ErrInvalidConfig. The client may be
|
||||
// called concurrently and is not used by [Engine.Prepare].
|
||||
// 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].
|
||||
func WithLLMClient(client LLMClient) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if client == nil {
|
||||
@@ -313,8 +322,9 @@ func WithSchemaFile(path string) Option {
|
||||
// or Run needs them.
|
||||
//
|
||||
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
||||
// configuration or options. It does not perform model requests or require
|
||||
// credentials.
|
||||
// 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 {
|
||||
@@ -347,6 +357,11 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
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
|
||||
@@ -367,6 +382,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||
}
|
||||
}
|
||||
llmClient = capacity.NewClient(capacityManager, llmClient)
|
||||
|
||||
artifacts := options.artifactReader
|
||||
if !options.artifactSource {
|
||||
@@ -382,6 +398,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
prompt.NewGoRenderer(),
|
||||
llmClient,
|
||||
validator,
|
||||
capacityManager,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
@@ -450,11 +467,14 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
||||
// single-pass even when OutputContract.RepairAttempts is positive.
|
||||
//
|
||||
// Run can return every error category documented by [Engine.Prepare], plus
|
||||
// ErrLLMGenerate. Errors from injected clients remain available through
|
||||
// errors.Is. Cancellation is passed through the active collaborator and is
|
||||
// reported in the applicable operation category; no general errors.Is
|
||||
// relationship to ctx.Err is promised. A nil Engine returns ErrInvalidConfig.
|
||||
// Run returns no partial result on error.
|
||||
// ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies
|
||||
// rejection 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
@@ -38,6 +39,8 @@ func publicErrorFor(err error) error {
|
||||
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):
|
||||
|
||||
22
errors_internal_test.go
Normal file
22
errors_internal_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ const (
|
||||
|
||||
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
||||
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
||||
|
||||
openRouterConcurrencyLimit = 16
|
||||
defaultQueueCapacity = 1024
|
||||
)
|
||||
|
||||
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
||||
@@ -43,9 +46,10 @@ func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
||||
|
||||
definitions := make([]domain.Backend, 0, len(additions)+1)
|
||||
definitions = append(definitions, domain.Backend{
|
||||
ID: OpenRouterID,
|
||||
Endpoint: openRouterEndpoint,
|
||||
APIKeyEnv: openRouterAPIKeyEnv,
|
||||
ID: OpenRouterID,
|
||||
Endpoint: openRouterEndpoint,
|
||||
APIKeyEnv: openRouterAPIKeyEnv,
|
||||
ConcurrencyLimit: openRouterConcurrencyLimit,
|
||||
})
|
||||
definitions = append(definitions, additions...)
|
||||
|
||||
@@ -85,6 +89,25 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
||||
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 {
|
||||
@@ -100,6 +123,40 @@ func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -24,9 +24,20 @@ func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) {
|
||||
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) {
|
||||
@@ -37,10 +48,13 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
||||
}
|
||||
registry, err := backend.NewRegistry([]domain.Backend{
|
||||
{
|
||||
ID: " custom ",
|
||||
Endpoint: " https://custom.example/openai/v1 ",
|
||||
APIKeyEnv: " CUSTOM_API_KEY ",
|
||||
ExtraParams: extraParams,
|
||||
ID: " custom ",
|
||||
Endpoint: " https://custom.example/openai/v1 ",
|
||||
APIKeyEnv: " CUSTOM_API_KEY ",
|
||||
ExtraParams: extraParams,
|
||||
ConcurrencyLimit: 3,
|
||||
QueueCapacity: 2,
|
||||
QueueCapacitySet: true,
|
||||
},
|
||||
{
|
||||
ID: "Custom",
|
||||
@@ -60,7 +74,10 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
||||
}
|
||||
if got.ID != "custom" ||
|
||||
got.Endpoint != "https://custom.example/openai/v1" ||
|
||||
got.APIKeyEnv != "CUSTOM_API_KEY" {
|
||||
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 {
|
||||
@@ -90,6 +107,132 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
||||
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) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
158
internal/capacity/manager_test.go
Normal file
158
internal/capacity/manager_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -163,10 +163,20 @@ type PromptMessageTemplate struct {
|
||||
|
||||
// Backend describes reusable OpenAI-compatible connection defaults.
|
||||
type Backend struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
APIKeyEnv string
|
||||
ExtraParams map[string]any
|
||||
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.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"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"
|
||||
@@ -46,6 +47,7 @@ type Runner struct {
|
||||
llm llm.Client
|
||||
validator validate.Validator
|
||||
repairer OutputRepairer
|
||||
admitter RunAdmitter
|
||||
}
|
||||
|
||||
// BackendResolver resolves one normalized backend ID.
|
||||
@@ -53,6 +55,22 @@ type BackendResolver interface {
|
||||
GetBackend(string) (domain.Backend, error)
|
||||
}
|
||||
|
||||
// RunAdmitter reserves capacity for one resolved backend run.
|
||||
type RunAdmitter interface {
|
||||
Admit(context.Context, string) (func(), error)
|
||||
}
|
||||
|
||||
type preparationState struct {
|
||||
definition *domain.PromptDefinition
|
||||
directSessionID string
|
||||
promptDefinitionHash string
|
||||
selectedProfileID string
|
||||
effectiveModel domain.ExecutionTarget
|
||||
targetPresence domain.ExecutionTargetPresence
|
||||
effectiveContract domain.OutputContract
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func NewRunner(
|
||||
promptDefs promptdef.Repository,
|
||||
profiles profile.Repository,
|
||||
@@ -61,8 +79,19 @@ func NewRunner(
|
||||
renderer prompt.Renderer,
|
||||
llmClient llm.Client,
|
||||
validator validate.Validator,
|
||||
admitter RunAdmitter,
|
||||
) *Runner {
|
||||
return NewRunnerWithRepairer(promptDefs, profiles, backends, artifacts, renderer, llmClient, validator, nil)
|
||||
return NewRunnerWithRepairer(
|
||||
promptDefs,
|
||||
profiles,
|
||||
backends,
|
||||
artifacts,
|
||||
renderer,
|
||||
llmClient,
|
||||
validator,
|
||||
nil,
|
||||
admitter,
|
||||
)
|
||||
}
|
||||
|
||||
func NewRunnerWithRepairer(
|
||||
@@ -74,6 +103,7 @@ func NewRunnerWithRepairer(
|
||||
llmClient llm.Client,
|
||||
validator validate.Validator,
|
||||
repairer OutputRepairer,
|
||||
admitter RunAdmitter,
|
||||
) *Runner {
|
||||
return &Runner{
|
||||
promptDefs: promptDefs,
|
||||
@@ -84,6 +114,7 @@ func NewRunnerWithRepairer(
|
||||
llm: llmClient,
|
||||
validator: validator,
|
||||
repairer: repairer,
|
||||
admitter: admitter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +126,27 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
|
||||
start := time.Now().UTC()
|
||||
|
||||
prepared, err := r.Prepare(ctx, req)
|
||||
state, err := r.resolvePreparation(ctx, req, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.admitter != nil {
|
||||
release, admitErr := r.admitter.Admit(ctx, state.effectiveModel.BackendID)
|
||||
if admitErr != nil {
|
||||
if errors.Is(admitErr, capacity.ErrCapacityExceeded) {
|
||||
return nil, fmt.Errorf(
|
||||
"backend %q admission: %w",
|
||||
state.effectiveModel.BackendID,
|
||||
admitErr,
|
||||
)
|
||||
}
|
||||
return nil, admitErr
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
prepared, err := r.completePreparation(ctx, req, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,6 +228,18 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
||||
}
|
||||
|
||||
func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.PreparedRun, error) {
|
||||
state, err := r.resolvePreparation(ctx, req, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.completePreparation(ctx, req, state)
|
||||
}
|
||||
|
||||
func (r *Runner) resolvePreparation(
|
||||
ctx context.Context,
|
||||
req domain.RunRequest,
|
||||
start time.Time,
|
||||
) (*preparationState, error) {
|
||||
if strings.TrimSpace(req.PromptID) == "" {
|
||||
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest)
|
||||
}
|
||||
@@ -185,8 +248,6 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
||||
return nil, fmt.Errorf("%w: session_id: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
start := time.Now().UTC()
|
||||
|
||||
def, err := r.promptDefs.GetPromptDefinition(ctx, req.PromptID, req.PromptVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err)
|
||||
@@ -238,7 +299,28 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
||||
}
|
||||
|
||||
effectiveContract := resolveOutputContract(def, req.Validation)
|
||||
structuredOutput, err := r.resolveStructuredOutput(ctx, def, effectiveContract)
|
||||
return &preparationState{
|
||||
definition: def,
|
||||
directSessionID: directSessionID,
|
||||
promptDefinitionHash: promptDefinitionHash,
|
||||
selectedProfileID: selectedProfileID,
|
||||
effectiveModel: effectiveModel,
|
||||
targetPresence: targetPresence,
|
||||
effectiveContract: effectiveContract,
|
||||
start: start,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) completePreparation(
|
||||
ctx context.Context,
|
||||
req domain.RunRequest,
|
||||
state *preparationState,
|
||||
) (*domain.PreparedRun, error) {
|
||||
structuredOutput, err := r.resolveStructuredOutput(
|
||||
ctx,
|
||||
state.definition,
|
||||
state.effectiveContract,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,9 +339,9 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
||||
inputHashes[name] = art.Hash
|
||||
}
|
||||
|
||||
definitionToRender := def
|
||||
if directSessionID != "" {
|
||||
definitionCopy := *def
|
||||
definitionToRender := state.definition
|
||||
if state.directSessionID != "" {
|
||||
definitionCopy := *state.definition
|
||||
definitionCopy.SessionID = ""
|
||||
definitionToRender = &definitionCopy
|
||||
}
|
||||
@@ -267,28 +349,28 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrPromptRender, err)
|
||||
}
|
||||
if directSessionID != "" {
|
||||
renderedPrompt.SessionID = directSessionID
|
||||
if state.directSessionID != "" {
|
||||
renderedPrompt.SessionID = state.directSessionID
|
||||
}
|
||||
|
||||
end := time.Now().UTC()
|
||||
return &domain.PreparedRun{
|
||||
PromptID: def.ID,
|
||||
PromptVersion: def.Version,
|
||||
PromptHash: promptDefinitionHash,
|
||||
SelectedProfileID: selectedProfileID,
|
||||
SelectedBackendID: effectiveModel.BackendID,
|
||||
EffectiveModelParams: effectiveModel,
|
||||
TargetPresence: targetPresence,
|
||||
OutputContract: effectiveContract,
|
||||
PromptID: state.definition.ID,
|
||||
PromptVersion: state.definition.Version,
|
||||
PromptHash: state.promptDefinitionHash,
|
||||
SelectedProfileID: state.selectedProfileID,
|
||||
SelectedBackendID: state.effectiveModel.BackendID,
|
||||
EffectiveModelParams: state.effectiveModel,
|
||||
TargetPresence: state.targetPresence,
|
||||
OutputContract: state.effectiveContract,
|
||||
StructuredOutput: structuredOutput,
|
||||
InputHashes: inputHashes,
|
||||
SessionID: renderedPrompt.SessionID,
|
||||
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
|
||||
Messages: renderedPrompt.Messages,
|
||||
StartTime: start,
|
||||
StartTime: state.start,
|
||||
EndTime: end,
|
||||
DurationMS: end.Sub(start).Milliseconds(),
|
||||
DurationMS: end.Sub(state.start).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -38,6 +41,52 @@ type fakeBackendResolver struct {
|
||||
backends map[string]domain.Backend
|
||||
}
|
||||
|
||||
type staticPromptRepo struct {
|
||||
def *domain.PromptDefinition
|
||||
}
|
||||
|
||||
func (r staticPromptRepo) GetPromptDefinition(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (*domain.PromptDefinition, error) {
|
||||
return r.def, nil
|
||||
}
|
||||
|
||||
type staticExecutionProfileRepo struct {
|
||||
profile *domain.ExecutionProfile
|
||||
}
|
||||
|
||||
func (r staticExecutionProfileRepo) GetProfile(
|
||||
context.Context,
|
||||
string,
|
||||
) (*domain.ExecutionProfile, error) {
|
||||
value := *r.profile
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
type staticArtifactReader struct{}
|
||||
|
||||
func (staticArtifactReader) Read(
|
||||
context.Context,
|
||||
domain.ArtifactRef,
|
||||
) (*domain.Artifact, error) {
|
||||
return &domain.Artifact{Body: []byte("artifact"), Hash: hashString("artifact")}, nil
|
||||
}
|
||||
|
||||
type staticRenderer struct{}
|
||||
|
||||
func (staticRenderer) Render(
|
||||
context.Context,
|
||||
*domain.PromptDefinition,
|
||||
map[string]*domain.Artifact,
|
||||
map[string]string,
|
||||
) (*domain.RenderedPrompt, error) {
|
||||
return &domain.RenderedPrompt{
|
||||
Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f fakeBackendResolver) GetBackend(id string) (domain.Backend, error) {
|
||||
value, ok := f.backends[id]
|
||||
if !ok {
|
||||
@@ -70,9 +119,11 @@ func (f *fakePromptRepo) GetPromptDefinition(ctx context.Context, id string, ver
|
||||
type fakeArtifactReader struct {
|
||||
artifactsByURI map[string]*domain.Artifact
|
||||
errByURI map[string]error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||
f.calls++
|
||||
if err, ok := f.errByURI[ref.URI]; ok {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,9 +137,11 @@ func (f *fakeArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (
|
||||
type fakeRenderer struct {
|
||||
rendered *domain.RenderedPrompt
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeRenderer) Render(ctx context.Context, def *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@@ -125,9 +178,11 @@ type fakeValidator struct {
|
||||
schemaErr error
|
||||
schemaLoadPath string
|
||||
schemaLoads int
|
||||
validateCalls int
|
||||
}
|
||||
|
||||
func (f *fakeValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
||||
f.validateCalls++
|
||||
if f.err != nil {
|
||||
return domain.ValidationResult{}, f.err
|
||||
}
|
||||
@@ -153,6 +208,97 @@ type fakeRepairer struct {
|
||||
reqs []RepairRequest
|
||||
}
|
||||
|
||||
type fakeRunAdmitter struct {
|
||||
backendIDs []string
|
||||
err error
|
||||
releaseCalls int
|
||||
}
|
||||
|
||||
type recordingRunAdmitter struct {
|
||||
mu sync.Mutex
|
||||
next RunAdmitter
|
||||
backendIDs []string
|
||||
}
|
||||
|
||||
func (a *recordingRunAdmitter) Admit(ctx context.Context, backendID string) (func(), error) {
|
||||
a.mu.Lock()
|
||||
a.backendIDs = append(a.backendIDs, backendID)
|
||||
a.mu.Unlock()
|
||||
return a.next.Admit(ctx, backendID)
|
||||
}
|
||||
|
||||
func (a *recordingRunAdmitter) admittedBackendIDs() []string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]string(nil), a.backendIDs...)
|
||||
}
|
||||
|
||||
type controlledRepairLLM struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
peak int
|
||||
calls int
|
||||
repairCalls int
|
||||
events chan controlledGeneration
|
||||
backendIDs []string
|
||||
}
|
||||
|
||||
type controlledGeneration struct {
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (c *controlledRepairLLM) Generate(
|
||||
ctx context.Context,
|
||||
req domain.GenerateRequest,
|
||||
) (*domain.GenerateResponse, error) {
|
||||
isRepair := len(req.Prompt.Messages) > 0 &&
|
||||
strings.HasPrefix(req.Prompt.Messages[0].Content, "You repair invalid JSON")
|
||||
c.mu.Lock()
|
||||
c.calls++
|
||||
c.active++
|
||||
if c.active > c.peak {
|
||||
c.peak = c.active
|
||||
}
|
||||
if isRepair {
|
||||
c.repairCalls++
|
||||
}
|
||||
c.backendIDs = append(c.backendIDs, req.Target.BackendID)
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.active--
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
event := controlledGeneration{release: make(chan struct{})}
|
||||
c.events <- event
|
||||
select {
|
||||
case <-event.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if isRepair {
|
||||
return &domain.GenerateResponse{Content: `{}`}, nil
|
||||
}
|
||||
return &domain.GenerateResponse{Content: `{"broken":`}, nil
|
||||
}
|
||||
|
||||
func (c *controlledRepairLLM) snapshot() (peak, calls, repairCalls int, backendIDs []string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.peak, c.calls, c.repairCalls, append([]string(nil), c.backendIDs...)
|
||||
}
|
||||
|
||||
func (f *fakeRunAdmitter) Admit(_ context.Context, backendID string) (func(), error) {
|
||||
f.backendIDs = append(f.backendIDs, backendID)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return func() {
|
||||
f.releaseCalls++
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepairer) Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error) {
|
||||
f.calls++
|
||||
f.reqs = append(f.reqs, req)
|
||||
@@ -179,7 +325,7 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) {
|
||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||
llmClient := &fakeLLM{forbid: true}
|
||||
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil, nil)
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
PromptVersion: "1",
|
||||
@@ -234,8 +380,8 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
prompt.NewGoRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil,
|
||||
)
|
||||
nil, nil)
|
||||
|
||||
req := domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
@@ -281,8 +427,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
prompt.NewGoRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil,
|
||||
)
|
||||
nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -312,8 +457,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
prompt.NewGoRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil,
|
||||
)
|
||||
nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -340,8 +484,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
nil,
|
||||
)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -408,7 +551,7 @@ func TestRunnerPrepareSelectedProfileDoesNotExistFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunnerPreparePromptLoadFailure(t *testing.T) {
|
||||
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil)
|
||||
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil, nil)
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p"})
|
||||
if !errors.Is(err, ErrPromptLoad) {
|
||||
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
||||
@@ -432,7 +575,7 @@ func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
|
||||
ServiceTier: "priority",
|
||||
},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -535,7 +678,7 @@ func TestRunnerPrepareRequestNumericOverridePresence(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -582,7 +725,7 @@ func TestRunnerPrepareInvalidRequestNumericOverridesFail(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -609,7 +752,7 @@ func TestRunnerPrepareSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
||||
ServiceTier: "priority",
|
||||
},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -651,7 +794,7 @@ func TestRunnerPrepareFileBackedPromptBodiesRenderCorrectly(t *testing.T) {
|
||||
reader,
|
||||
prompt.NewGoRenderer(),
|
||||
llmClient,
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "valid-file-backed",
|
||||
@@ -682,7 +825,7 @@ func TestRunnerPrepareRequiredInputMissingFails(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
prompt.NewGoRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -709,7 +852,7 @@ func TestRunnerPrepareUnknownTemplateInputReferenceFails(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
prompt.NewGoRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -732,7 +875,7 @@ func TestRunnerPrepareAPIKeyEnvNameIncludedButNotResolvedValue(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: envName},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if err != nil {
|
||||
@@ -765,7 +908,7 @@ func TestRunnerPrepareJSONSchemaBuildsStructuredOutputSpec(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
validator)
|
||||
validator, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -809,7 +952,7 @@ func TestRunnerPrepareJSONSchemaSchemaLoadFailureReturnsValidationError(t *testi
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{forbid: true},
|
||||
validator)
|
||||
validator, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -839,7 +982,7 @@ func TestRunnerRunJSONSchemaSchemaLoadFailureFailsBeforeLLM(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validator)
|
||||
validator, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -989,7 +1132,7 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap", Usage: domain.TokenUsage{TotalTokens: 7}}}
|
||||
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil, nil)
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
PromptVersion: "1",
|
||||
@@ -1071,7 +1214,7 @@ func TestRunnerRunPassesExtraParamsToGenerateRequestTarget(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1097,7 +1240,7 @@ func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T)
|
||||
}}
|
||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, reader, renderer, llmClient, nil, nil)
|
||||
|
||||
req := domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1135,6 +1278,253 @@ func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerAdmissionUsesResolvedBackendIdentity(t *testing.T) {
|
||||
t.Run("selected backend survives endpoint override", func(t *testing.T) {
|
||||
admitter := &fakeRunAdmitter{}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {
|
||||
ID: "exec",
|
||||
BackendID: "custom",
|
||||
Model: "model",
|
||||
},
|
||||
}},
|
||||
fakeBackendResolver{backends: map[string]domain.Backend{
|
||||
"custom": {ID: "custom", Endpoint: "http://backend.example/v1"},
|
||||
}},
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
nil,
|
||||
admitter,
|
||||
)
|
||||
|
||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTargetOverride{
|
||||
Endpoint: "http://override.example/v1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(admitter.backendIDs, []string{"custom"}) {
|
||||
t.Fatalf("admitted backend IDs=%#v, want custom", admitter.backendIDs)
|
||||
}
|
||||
if result.SelectedBackendID != "custom" ||
|
||||
result.Endpoint != "http://override.example/v1" {
|
||||
t.Fatalf("unexpected routed result: %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("endpoint-only preparation remains unrestricted", func(t *testing.T) {
|
||||
admitter := &fakeRunAdmitter{}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": defaultExecutionProfile(),
|
||||
}},
|
||||
nil,
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
nil,
|
||||
admitter,
|
||||
)
|
||||
request := domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
}
|
||||
|
||||
if _, err := runner.Prepare(context.Background(), request); err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
if len(admitter.backendIDs) != 0 {
|
||||
t.Fatalf("prepare called admission with %#v", admitter.backendIDs)
|
||||
}
|
||||
if _, err := runner.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(admitter.backendIDs, []string{""}) {
|
||||
t.Fatalf("admitted backend IDs=%#v, want blank ID", admitter.backendIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
admissionError error
|
||||
wantBackendContext bool
|
||||
}{
|
||||
{
|
||||
name: "capacity exhausted",
|
||||
admissionError: capacity.ErrCapacityExceeded,
|
||||
wantBackendContext: true,
|
||||
},
|
||||
{
|
||||
name: "context canceled",
|
||||
admissionError: context.Canceled,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
def := promptDef(domain.FormatJSON, domain.ValidationJSONSchema, 1)
|
||||
def.Validation.SchemaPath = "schema.json"
|
||||
reader := defaultArtifactReader()
|
||||
renderer := defaultRenderer()
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{}`}}
|
||||
validator := &fakeValidator{}
|
||||
repairer := &fakeRepairer{
|
||||
responses: []*domain.GenerateResponse{{Content: `{}`}},
|
||||
}
|
||||
admitter := &fakeRunAdmitter{err: tc.admissionError}
|
||||
runner := NewRunnerWithRepairer(
|
||||
&fakePromptRepo{def: def},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", BackendID: "custom", Model: "model"},
|
||||
}},
|
||||
fakeBackendResolver{backends: map[string]domain.Backend{
|
||||
"custom": {ID: "custom", Endpoint: "http://backend.example/v1"},
|
||||
}},
|
||||
reader,
|
||||
renderer,
|
||||
llmClient,
|
||||
validator,
|
||||
repairer,
|
||||
admitter,
|
||||
)
|
||||
|
||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("admission failure returned partial result: %+v", result)
|
||||
}
|
||||
if !errors.Is(err, tc.admissionError) {
|
||||
t.Fatalf("admission error=%v, want identity %v", err, tc.admissionError)
|
||||
}
|
||||
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
|
||||
t.Fatalf("admission error was recategorized: %v", err)
|
||||
}
|
||||
if tc.wantBackendContext && !strings.Contains(err.Error(), "custom") {
|
||||
t.Fatalf("capacity error lacks backend context: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(admitter.backendIDs, []string{"custom"}) {
|
||||
t.Fatalf("admitted backend IDs=%#v, want custom", admitter.backendIDs)
|
||||
}
|
||||
if admitter.releaseCalls != 0 ||
|
||||
validator.schemaLoads != 0 ||
|
||||
validator.validateCalls != 0 ||
|
||||
reader.calls != 0 ||
|
||||
renderer.calls != 0 ||
|
||||
llmClient.calls != 0 ||
|
||||
repairer.calls != 0 {
|
||||
t.Fatalf(
|
||||
"later collaborators invoked: releases=%d schema=%d validate=%d artifacts=%d render=%d llm=%d repair=%d",
|
||||
admitter.releaseCalls,
|
||||
validator.schemaLoads,
|
||||
validator.validateCalls,
|
||||
reader.calls,
|
||||
renderer.calls,
|
||||
llmClient.calls,
|
||||
repairer.calls,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReleasesAdmissionAcrossRunOutcomes(t *testing.T) {
|
||||
artifactFailure := errors.New("artifact failed")
|
||||
generationFailure := errors.New("generation failed")
|
||||
validationFailure := errors.New("validation failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
artifactError error
|
||||
generationErr error
|
||||
validationErr error
|
||||
wantError error
|
||||
}{
|
||||
{name: "success"},
|
||||
{
|
||||
name: "completion failure",
|
||||
artifactError: artifactFailure,
|
||||
wantError: ErrArtifactLoad,
|
||||
},
|
||||
{
|
||||
name: "generation failure",
|
||||
generationErr: generationFailure,
|
||||
wantError: ErrLLMGenerate,
|
||||
},
|
||||
{
|
||||
name: "validation failure",
|
||||
validationErr: validationFailure,
|
||||
wantError: ErrValidation,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reader := defaultArtifactReader()
|
||||
if tc.artifactError != nil {
|
||||
reader.errByURI = map[string]error{"a://ok": tc.artifactError}
|
||||
}
|
||||
llmClient := &fakeLLM{
|
||||
resp: &domain.GenerateResponse{Content: "ok"},
|
||||
err: tc.generationErr,
|
||||
}
|
||||
validator := &fakeValidator{
|
||||
result: domain.ValidationResult{
|
||||
Status: domain.ValidationPassed,
|
||||
Mode: domain.ValidationBasic,
|
||||
IsValid: true,
|
||||
},
|
||||
err: tc.validationErr,
|
||||
}
|
||||
admitter := &fakeRunAdmitter{}
|
||||
runner := NewRunner(
|
||||
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationBasic, 0)},
|
||||
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": defaultExecutionProfile(),
|
||||
}},
|
||||
nil,
|
||||
reader,
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validator,
|
||||
admitter,
|
||||
)
|
||||
|
||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
})
|
||||
if tc.wantError == nil {
|
||||
if err != nil || result == nil {
|
||||
t.Fatalf("successful run=(%+v, %v)", result, err)
|
||||
}
|
||||
} else {
|
||||
if result != nil || !errors.Is(err, tc.wantError) {
|
||||
t.Fatalf("failed run=(%+v, %v), want %v", result, err, tc.wantError)
|
||||
}
|
||||
}
|
||||
if len(admitter.backendIDs) != 1 || admitter.releaseCalls != 1 {
|
||||
t.Fatalf("admission calls=%#v releases=%d, want one each",
|
||||
admitter.backendIDs, admitter.releaseCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunExplicitProfileIDIsUsed(t *testing.T) {
|
||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
||||
promptRepo.def.DefaultProfile = "default-prof"
|
||||
@@ -1227,7 +1617,7 @@ func TestRunnerRunExplicitRuntimeOverrideBeatsSelectedProfileValue(t *testing.T)
|
||||
},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1271,7 +1661,7 @@ func TestRunnerRunSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1298,7 +1688,7 @@ func TestRunnerRunBuiltInDefaultsUsedWhenProfileOmitsOptionalFields(t *testing.T
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1328,7 +1718,7 @@ func TestRunnerRunAPIKeyEnvResolvesFromEnvironment(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_TEST_API_KEY"},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if err != nil {
|
||||
@@ -1344,7 +1734,7 @@ func TestRunnerRunAPIKeyEnvMissingEnvironmentValueFailsClearly(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
@@ -1365,7 +1755,7 @@ func TestRunnerRunDirectAPIKeyBypassesMissingEnvAndReachesLLM(t *testing.T) {
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1389,7 +1779,7 @@ func TestRunnerPrepareAPIKeyRequiredFailsWithoutDirectKey(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyRequired: true},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1408,7 +1798,7 @@ func TestRunnerRunAPIKeyRequiredSucceedsWithDirectKey(t *testing.T) {
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyRequired: true},
|
||||
}}
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), llmClient, nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1435,7 +1825,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideWorks(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1460,7 +1850,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideBeatsProfile(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: profileEnv},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1484,7 +1874,7 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) {
|
||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: envName},
|
||||
}}
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil)
|
||||
runner := NewRunner(promptRepo, execRepo, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}, nil, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if err != nil {
|
||||
@@ -1500,7 +1890,7 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunnerRunPromptLoadFailure(t *testing.T) {
|
||||
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil)
|
||||
runner := NewRunner(&fakePromptRepo{err: errors.New("boom")}, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{}, nil, nil)
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p"})
|
||||
if !errors.Is(err, ErrPromptLoad) {
|
||||
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
||||
@@ -1518,7 +1908,7 @@ func TestRunnerRunArtifactLoadFailure(t *testing.T) {
|
||||
&fakeArtifactReader{errByURI: map[string]error{"a://bad": errors.New("read failed")}},
|
||||
&fakeRenderer{rendered: &domain.RenderedPrompt{}},
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1538,7 +1928,7 @@ func TestRunnerRunPromptRenderFailure(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
&fakeRenderer{err: errors.New("render failed")},
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1558,7 +1948,7 @@ func TestRunnerRunLLMFailure(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{err: errors.New("llm failed")},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1578,7 +1968,7 @@ func TestRunnerRunCancellationPreservesGenerationCategory(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ignored"}},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -1604,7 +1994,7 @@ func TestRunnerRunLLMInvalidRequestMapsToUsecaseInvalidRequest(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{err: llm.ErrInvalidRequest},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1628,7 +2018,7 @@ func TestRunnerRunValidationStillWorks(t *testing.T) {
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "raw output"}},
|
||||
validator)
|
||||
validator, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1659,7 +2049,7 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validate.NewStandardValidator("."),
|
||||
repairer)
|
||||
repairer, nil)
|
||||
|
||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1690,6 +2080,97 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerSchedulesInitialAndRepairGenerationThroughOneBackendPool(t *testing.T) {
|
||||
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{
|
||||
"custom": {
|
||||
ConcurrencyLimit: 1,
|
||||
QueueCapacity: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("construct capacity manager: %v", err)
|
||||
}
|
||||
baseClient := &controlledRepairLLM{
|
||||
events: make(chan controlledGeneration, 4),
|
||||
}
|
||||
scheduledClient := capacity.NewClient(manager, baseClient)
|
||||
admitter := &recordingRunAdmitter{next: manager}
|
||||
runner := NewRunnerWithRepairer(
|
||||
staticPromptRepo{def: promptDef(domain.FormatJSON, domain.ValidationJSON, 1)},
|
||||
staticExecutionProfileRepo{profile: &domain.ExecutionProfile{
|
||||
ID: "exec", BackendID: "custom", Model: "model",
|
||||
}},
|
||||
fakeBackendResolver{backends: map[string]domain.Backend{
|
||||
"custom": {ID: "custom", Endpoint: "http://backend.example/v1"},
|
||||
}},
|
||||
staticArtifactReader{},
|
||||
staticRenderer{},
|
||||
scheduledClient,
|
||||
validate.NewStandardValidator("."),
|
||||
NewDefaultOutputRepairer(scheduledClient),
|
||||
admitter,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
type runOutcome struct {
|
||||
result *domain.RunResult
|
||||
err error
|
||||
}
|
||||
outcomes := make(chan runOutcome, 2)
|
||||
request := domain.RunRequest{
|
||||
PromptID: "p",
|
||||
ProfileID: "exec",
|
||||
Inputs: singleInputRef(),
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
result, runErr := runner.Run(ctx, request)
|
||||
outcomes <- runOutcome{result: result, err: runErr}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
select {
|
||||
case event := <-baseClient.events:
|
||||
close(event.release)
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timed out waiting for generation %d: %v", i+1, ctx.Err())
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case outcome := <-outcomes:
|
||||
if outcome.err != nil || outcome.result == nil {
|
||||
t.Fatalf("run outcome=(%+v, %v), want success", outcome.result, outcome.err)
|
||||
}
|
||||
if outcome.result.Validation.Status != domain.ValidationPassed ||
|
||||
outcome.result.Validation.RepairAttempts != 1 {
|
||||
t.Fatalf("unexpected repaired result: %+v", outcome.result.Validation)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timed out waiting for repaired run: %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
admitted := admitter.admittedBackendIDs()
|
||||
if !reflect.DeepEqual(admitted, []string{"custom", "custom"}) {
|
||||
t.Fatalf("admission backend IDs=%#v, want one admission per run", admitted)
|
||||
}
|
||||
peak, calls, repairCalls, backendIDs := baseClient.snapshot()
|
||||
if peak != 1 || calls != 4 || repairCalls != 2 {
|
||||
t.Fatalf(
|
||||
"generation observations=(peak=%d calls=%d repairs=%d), want (1, 4, 2)",
|
||||
peak,
|
||||
calls,
|
||||
repairCalls,
|
||||
)
|
||||
}
|
||||
if !reflect.DeepEqual(backendIDs, []string{"custom", "custom", "custom", "custom"}) {
|
||||
t.Fatalf("generation backend IDs=%#v, want custom for initial and repair calls", backendIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunRepairCarriesEffectiveSessionID(t *testing.T) {
|
||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":`}}
|
||||
runner := NewRunnerWithRepairer(
|
||||
@@ -1702,8 +2183,7 @@ func TestRunnerRunRepairCarriesEffectiveSessionID(t *testing.T) {
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validate.NewStandardValidator("."),
|
||||
NewDefaultOutputRepairer(llmClient),
|
||||
)
|
||||
NewDefaultOutputRepairer(llmClient), nil)
|
||||
|
||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -1757,7 +2237,7 @@ func TestRunnerRunJSONSchemaRepairCarriesStructuredOutputSpec(t *testing.T) {
|
||||
defaultRenderer(),
|
||||
llmClient,
|
||||
validator,
|
||||
repairer)
|
||||
repairer, nil)
|
||||
|
||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||
PromptID: "p",
|
||||
@@ -2114,7 +2594,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
||||
t.Run("unknown backend is a profile load failure", func(t *testing.T) {
|
||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", BackendID: "unknown", Model: "model"},
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if !errors.Is(err, ErrProfileLoad) {
|
||||
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
||||
@@ -2124,7 +2605,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
||||
t.Run("nil resolver is a profile load failure", func(t *testing.T) {
|
||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", BackendID: "custom", Model: "model"},
|
||||
}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
}}, nil, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if !errors.Is(err, ErrProfileLoad) {
|
||||
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
||||
@@ -2135,7 +2617,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
||||
t.Setenv("REQUEST_KEY", "request-secret")
|
||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", BackendID: "custom", Model: "model", APIKeyEnv: "PROFILE_KEY"},
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||
PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(),
|
||||
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: "REQUEST_KEY"},
|
||||
@@ -2152,7 +2635,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
||||
t.Setenv("BACKEND_KEY", "backend-secret")
|
||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||
"exec": {ID: "exec", BackendID: "custom", Model: "model", APIKeyRequired: true},
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil)
|
||||
}}, resolver, defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, nil, nil)
|
||||
|
||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||
if !errors.Is(err, ErrAPIKeyRequired) {
|
||||
t.Fatalf("expected ErrAPIKeyRequired, got %v", err)
|
||||
@@ -2203,6 +2687,6 @@ func newMinimalRunner(promptRepo *fakePromptRepo, execRepo *fakeExecutionProfile
|
||||
defaultArtifactReader(),
|
||||
defaultRenderer(),
|
||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||
nil)
|
||||
nil, nil)
|
||||
|
||||
}
|
||||
|
||||
@@ -303,6 +303,28 @@ func TestBackendOptionsAccumulateAndRegistrationsAreEngineLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithBackendCopiesQueueCapacity(t *testing.T) {
|
||||
queueCapacity := 4
|
||||
option := promptkit.WithBackend(promptkit.Backend{
|
||||
ID: "custom",
|
||||
Endpoint: "http://custom.example/v1",
|
||||
ConcurrencyLimit: 1,
|
||||
QueueCapacity: &queueCapacity,
|
||||
})
|
||||
queueCapacity = -1
|
||||
|
||||
_, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "message"), "."),
|
||||
option,
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "profile", BackendID: "custom", Model: "model",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine after mutating queue pointer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendRegistrationRejectsInvalidAndDuplicateDefinitions(t *testing.T) {
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
|
||||
11
types.go
11
types.go
@@ -561,10 +561,13 @@ type StructuredOutputJSONSpec struct {
|
||||
|
||||
// LLMClient executes rendered prompts for [Engine.Run].
|
||||
//
|
||||
// Generate may be called concurrently. It must honor context cancellation to
|
||||
// make Run responsive to cancellation. The request and all nested maps,
|
||||
// slices, and pointers are client-owned copies and may be mutated or retained
|
||||
// without affecting engine state.
|
||||
// Generate is scheduled according to the resolved backend's capacity policy.
|
||||
// It may still be called concurrently for different backend pools or unlimited
|
||||
// backends. Cancellation while waiting for capacity can prevent Generate from
|
||||
// being called. Once invoked, it must honor context cancellation to make Run
|
||||
// responsive to cancellation. The request and all nested maps, slices, and
|
||||
// pointers are client-owned copies and may be mutated or retained without
|
||||
// affecting engine state.
|
||||
//
|
||||
// Generate receives rendered messages and may receive a direct API key. A
|
||||
// client must protect those values and any raw output in its logging, storage,
|
||||
|
||||
Reference in New Issue
Block a user