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
|
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||||
boundary and constraints that framework work must preserve.
|
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
|
## Related Project
|
||||||
|
|
||||||
[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and
|
[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
|
// service_tier, reasoning_effort, or response_format. An empty map supplies
|
||||||
// no defaults. NewEngine deeply copies the map.
|
// no defaults. NewEngine deeply copies the map.
|
||||||
ExtraParams map[string]any
|
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.
|
// 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
|
// mutated after construction. WithBackend does not install package-global
|
||||||
// state.
|
// state.
|
||||||
func WithBackend(backend Backend) Option {
|
func WithBackend(backend Backend) Option {
|
||||||
|
queueCapacity := 0
|
||||||
|
queueCapacitySet := backend.QueueCapacity != nil
|
||||||
|
if queueCapacitySet {
|
||||||
|
queueCapacity = *backend.QueueCapacity
|
||||||
|
}
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
options.backends = append(options.backends, domain.Backend{
|
options.backends = append(options.backends, domain.Backend{
|
||||||
ID: backend.ID,
|
ID: backend.ID,
|
||||||
Endpoint: backend.Endpoint,
|
Endpoint: backend.Endpoint,
|
||||||
APIKeyEnv: backend.APIKeyEnv,
|
APIKeyEnv: backend.APIKeyEnv,
|
||||||
ExtraParams: backend.ExtraParams,
|
ExtraParams: backend.ExtraParams,
|
||||||
|
ConcurrencyLimit: backend.ConcurrencyLimit,
|
||||||
|
QueueCapacity: queueCapacity,
|
||||||
|
QueueCapacitySet: queueCapacitySet,
|
||||||
})
|
})
|
||||||
return nil
|
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
|
// # Concurrency and ownership
|
||||||
//
|
//
|
||||||
// An Engine supports concurrent Prepare and Run calls. An injected [LLMClient]
|
// An Engine supports concurrent Prepare and Run calls. Engine-local backend
|
||||||
// or [ArtifactReader] can therefore receive concurrent calls and must be safe
|
// policies bound admitted Run calls and model generations where configured,
|
||||||
// for that use.
|
// 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
|
// NewEngine copies in-memory profiles and backend definitions. Prepare and Run
|
||||||
// copy request maps, slices, pointer values, and JSON-compatible extra
|
// 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 Custom Backend
|
||||||
|
|
||||||
Register a reusable OpenAI-compatible connection once, then select it from a
|
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
|
```go
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
@@ -137,6 +139,7 @@ engine, err := promptkit.NewEngine(promptkit.Config{
|
|||||||
ID: "local",
|
ID: "local",
|
||||||
Endpoint: "http://localhost:8000/v1",
|
Endpoint: "http://localhost:8000/v1",
|
||||||
APIKeyEnv: "LOCAL_LLM_API_KEY",
|
APIKeyEnv: "LOCAL_LLM_API_KEY",
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
}),
|
}),
|
||||||
promptkit.WithProfiles(promptkit.Profile{
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
ID: "local-summary",
|
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.
|
Registrations belong to one engine and custom IDs cannot replace built-ins.
|
||||||
The [`Backend` and `WithBackend` GoDoc](../../backends.go) defines validation,
|
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
|
Both file-backed and in-memory profiles select a registration through
|
||||||
`backend` or `Profile.BackendID`. Profile and request endpoint overrides retain
|
`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`
|
`RunResult.SelectedBackendID`, and the effective `ExecutionTarget.BackendID`
|
||||||
expose it to consumers and injected model clients. Endpoint-only profiles
|
expose it to consumers and injected model clients. Endpoint-only profiles
|
||||||
remain supported and expose an empty backend ID.
|
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
|
## Credentials
|
||||||
|
|
||||||
File-backed profiles name an environment variable; in-memory profiles can
|
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
|
documented. Invalid or duplicate backend registrations match
|
||||||
`ErrInvalidConfig`; selecting an unknown backend matches `ErrProfileLoad`.
|
`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
|
## Application Boundary
|
||||||
|
|
||||||
Promptkit is an importable library. It does not own a command, inbound HTTP
|
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
|
strings replace both. Request reasoning is the exception: a nil
|
||||||
`ReasoningEffort` pointer inherits the profile, a pointer to a nonblank string
|
`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
|
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
|
`extra_params` map at each layer replaces the entire lower-precedence map
|
||||||
rather than merging keys.
|
rather than merging keys.
|
||||||
The [outbound integration contract](integrations/openai-compatible-chat.md)
|
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 |
|
| 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/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) |
|
| `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/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/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/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/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,
|
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
||||||
profiles, backend resolution, artifacts, rendering, model generation, and
|
profiles, backend resolution, artifacts, rendering, model generation, and
|
||||||
validation. The root engine supplies one immutable registry containing the
|
validation. The root engine supplies one immutable registry containing the
|
||||||
built-in backend and validated consumer additions. Schema documents are loaded
|
built-in backend and validated consumer additions, one engine-local run
|
||||||
through the validator's optional schema-loader interface. An output repairer
|
admitter, and a model client wrapped by the same capacity manager. Schema
|
||||||
can be injected internally, but the ordinary runner constructor does not
|
documents are loaded through the validator's optional schema-loader interface.
|
||||||
enable one.
|
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.
|
Each invocation carries its state in request, prepared-run, and result values.
|
||||||
The runner has no durable run or session store.
|
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;
|
1. validate the required prompt selection and normalize any direct session ID;
|
||||||
2. load the prompt definition and hash the original definition;
|
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,
|
5. resolve application-neutral defaults, backend defaults, profile values,
|
||||||
and explicit request overrides in that order;
|
and explicit request overrides in that order;
|
||||||
6. validate endpoint, model, numeric overrides, and credential requirements;
|
6. validate endpoint, model, numeric overrides, and credential requirements;
|
||||||
7. resolve the output contract and load a structured-output schema when
|
7. resolve the effective output contract without loading its schema; and
|
||||||
required;
|
8. retain the definition, source identities, effective settings, output
|
||||||
8. load and hash input artifacts;
|
contract, and preparation start time in invocation-local state.
|
||||||
9. render messages, resolve the effective session ID, and hash the effective
|
|
||||||
rendered prompt; and
|
The completion phase consumes that state without reloading the prompt,
|
||||||
10. return the effective settings, source identities, messages, hashes, and
|
profile, or backend:
|
||||||
preparation timing.
|
|
||||||
|
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
|
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
|
||||||
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
||||||
@@ -70,15 +85,28 @@ invocation state local.
|
|||||||
|
|
||||||
## Run Flow
|
## Run Flow
|
||||||
|
|
||||||
`Run` calls `Prepare` rather than maintaining a second preparation path. It
|
`Run` records its start time, performs the shared resolution phase, and asks
|
||||||
performs one initial generation call, builds the named output artifact, and
|
its `RunAdmitter` to reserve capacity for the effective backend ID. A nil
|
||||||
validates that artifact. Invalid generated content remains a validation result;
|
admitter is an internal unlimited fallback. After successful admission, `Run`
|
||||||
an inability to perform validation is an operational error.
|
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
|
When an internal repairer is present, a JSON or JSON Schema content failure can
|
||||||
trigger bounded repair attempts. Repair receives the effective execution
|
trigger bounded repair attempts. Repair receives the effective execution
|
||||||
target and session ID, validation errors, prior output, and structured-output
|
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
|
A successful result includes the output artifact and raw output, validation
|
||||||
state, effective session ID, prompt and rendered-prompt hashes, selected
|
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
|
credential failures, and prompt, profile, artifact, rendering, generation, and
|
||||||
validation failures. Wrapping preserves the package identities mapped by the
|
validation failures. Wrapping preserves the package identities mapped by the
|
||||||
public facade and retains collaborator identities where they are part of the
|
public facade and retains collaborator identities where they are part of the
|
||||||
internal contract. Context cancellation propagates through the invoked
|
internal contract.
|
||||||
collaborator and is classified by the owning operation.
|
|
||||||
|
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 overlong direct session is an invalid request before source loading, while
|
||||||
an invalid or overlong prompt session template remains a prompt-render failure.
|
an invalid or overlong prompt session template remains a prompt-render failure.
|
||||||
An unknown selected backend, or a selected backend with no configured resolver,
|
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
|
## Test Ownership And Changes
|
||||||
|
|
||||||
The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
||||||
selection and override precedence, direct-session resolution,
|
selection and override precedence, the two-phase boundary, early admission,
|
||||||
schema-before-generation behavior, hashing, generation and validation
|
lease lifetime and release, direct-session resolution, schema-before-generation
|
||||||
outcomes, backend propagation, bounded repair, credentials and redaction,
|
behavior, hashing, generation and validation outcomes, backend propagation,
|
||||||
error categories, artifact metadata, usage, and timing.
|
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
|
Changes to orchestration should continue to use the existing package
|
||||||
interfaces, keep request state local to an invocation, and preserve `Run`'s use
|
interfaces, keep request state local to an invocation, and preserve the shared
|
||||||
of `Prepare`. Source, renderer, validator, or model-client contract changes
|
resolution and completion pipeline. Source, renderer, validator, or
|
||||||
belong first in their owning package and document.
|
model-client contract changes belong first in their owning package and
|
||||||
|
document.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ The implemented internal components consist of:
|
|||||||
components;
|
components;
|
||||||
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
||||||
definitions and the built-in OpenRouter definition;
|
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
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
constructs the default execution target;
|
constructs the default execution target;
|
||||||
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
- `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
|
maintained downstream consumers of the root facade. They do not expose library
|
||||||
packages or participate in internal assembly.
|
packages or participate in internal assembly.
|
||||||
|
|
||||||
The root facade assembles one immutable backend registry, the internal
|
The root facade assembles one immutable backend registry, one capacity manager,
|
||||||
repositories, renderer, validator, outbound client, and use-case runner while
|
the internal repositories, renderer, validator, outbound client, and use-case
|
||||||
translating public values and errors at the library boundary. The registry
|
runner while translating public values and errors at the library boundary. The
|
||||||
contains built-ins plus validated engine-scoped consumer additions. The
|
registry contains built-ins plus validated engine-scoped consumer additions.
|
||||||
defaults and renderer depend on the domain model. Prompt-definition and
|
The facade constructs the capacity manager from the registry's immutable
|
||||||
profile repositories use the domain model, file catalog, and YAML decoder. The
|
policy snapshot, wraps the selected built-in or injected model client, and
|
||||||
built-in profile repository supplies an embedded `fs.FS` to the profile
|
supplies bounded admission to the runner. The defaults and renderer depend on
|
||||||
package. Artifact reading uses the domain model and application-neutral
|
the domain model. Prompt-definition and profile repositories use the domain
|
||||||
defaults. Validation uses the domain model, file catalog, and JSON Schema
|
model, file catalog, and YAML decoder. The built-in profile repository supplies
|
||||||
implementation. The model client uses the domain model, application-neutral
|
an embedded `fs.FS` to the profile package. Artifact reading uses the domain
|
||||||
defaults, and an injected or standard-library HTTP client. The use-case runner
|
model and application-neutral defaults. Validation uses the domain model, file
|
||||||
depends on the narrow interfaces owned by each internal component, including
|
catalog, and JSON Schema implementation. The model client uses the domain
|
||||||
backend lookup.
|
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:
|
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
|
The backend registry depends on the domain model and shared JSON-value
|
||||||
validation, has no mutation API after construction, and consumes the
|
validation, has no mutation API after construction, and consumes the
|
||||||
OpenAI-compatible reserved request-field rule owned by the model client. The
|
OpenAI-compatible reserved request-field rule owned by the model client. The
|
||||||
model client does not depend on registry configuration. The facade coordinates
|
capacity component depends on the domain model and the narrow internal
|
||||||
internal components and adapts
|
model-client boundary, not on provider transport implementation. The model
|
||||||
the supported public extension interfaces to narrow internal abstractions.
|
client does not depend on registry or capacity configuration. The facade
|
||||||
Internal components must not depend on consumers or on Scriptorium.
|
coordinates internal components and adapts the supported public extension
|
||||||
|
interfaces to narrow internal abstractions. Internal components must not depend
|
||||||
|
on consumers or on Scriptorium.
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, built-in profiles, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. |
|
||||||
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
||||||
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
||||||
|
| Supplemental release guidance | None. `docs/releases/` may be used when a release benefits from a changelog or migration guide. | No canonical content. These files may briefly summarize release-specific changes, compatibility, and consumer migration paths, and may be corrected, consolidated, archived, or removed when no longer useful. | Public API and behavior contracts, formats, integrations, architecture, release procedure, and the authoritative annotated-tag release record. |
|
||||||
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
||||||
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
||||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||||
@@ -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. |
|
| Complete copyable artifacts | `examples/` | Valid inputs, Go programs, and other files intended to be copied or run. | Field-by-field reference, exact API declarations, and prose explanation. |
|
||||||
|
|
||||||
Conditional owners do not require placeholder files or directories. Create a
|
Conditional owners do not require placeholder files or directories. Create a
|
||||||
consumer, integration, subsystem, ADR, roadmap, or example document only when
|
consumer, integration, release, subsystem, ADR, roadmap, or example document
|
||||||
the corresponding implemented interface, decision, planned effort, or
|
only when the corresponding implemented interface, release, decision, planned
|
||||||
maintained artifact exists.
|
effort, or maintained artifact exists.
|
||||||
|
|
||||||
## Boundary Rules
|
## Boundary Rules
|
||||||
|
|
||||||
@@ -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
|
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
||||||
schema-file contracts. Integration documents own external wire formats.
|
schema-file contracts. Integration documents own external wire formats.
|
||||||
|
|
||||||
|
### Supplemental Release Guidance
|
||||||
|
|
||||||
|
Files under `docs/releases/` may provide changelog-style summaries and
|
||||||
|
migration guidance for a particular release. They are navigation and
|
||||||
|
orientation aids, not canonical owners of public APIs, behavior, formats,
|
||||||
|
integrations, architecture, release procedure, or other durable facts. When a
|
||||||
|
reader needs detail beyond a short release-specific note, the release document
|
||||||
|
must link to the applicable canonical documentation rather than reproduce its
|
||||||
|
contract.
|
||||||
|
|
||||||
|
The annotated tag message required by the
|
||||||
|
[release procedure](../release.md#write-the-release-note) remains the
|
||||||
|
authoritative release record. Supplemental release documents may be corrected,
|
||||||
|
consolidated, archived, or removed at any time when they are no longer useful,
|
||||||
|
provided maintained documentation does not depend on them and the annotated
|
||||||
|
tag record remains intact.
|
||||||
|
|
||||||
### Security Topics
|
### Security Topics
|
||||||
|
|
||||||
This policy owns what documentation and examples may contain. Architecture owns
|
This policy owns what documentation and examples may contain. Architecture owns
|
||||||
@@ -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
|
according to repository practice. Do not preserve completed roadmaps as a
|
||||||
second current-state reference.
|
second current-state reference.
|
||||||
|
|
||||||
|
Supplemental release documents may likewise be removed without preserving a
|
||||||
|
replacement. Before removal, update maintained incoming links so current
|
||||||
|
documentation does not depend on an optional historical guide.
|
||||||
|
|
||||||
Before completing documentation work:
|
Before completing documentation work:
|
||||||
|
|
||||||
- verify affected behavior and examples;
|
- verify affected behavior and examples;
|
||||||
|
|||||||
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
|
# Backend-Specific Concurrency Management
|
||||||
|
|
||||||
**Status:** Accepted.
|
**Status:** Complete.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend-Specific Concurrency Management Implementation Plan
|
# Backend-Specific Concurrency Management Implementation Plan
|
||||||
|
|
||||||
**Status:** Ready for implementation.
|
**Status:** Complete.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -464,7 +464,7 @@ behavior already protects them.
|
|||||||
|
|
||||||
## Stage 1 — Backend Policy And Public Configuration
|
## Stage 1 — Backend Policy And Public Configuration
|
||||||
|
|
||||||
**Status:** Pending.
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
@@ -523,7 +523,7 @@ unlimited by omission, and no runtime call is scheduled yet.
|
|||||||
|
|
||||||
## Stage 2 — Engine-Local Capacity Manager
|
## Stage 2 — Engine-Local Capacity Manager
|
||||||
|
|
||||||
**Status:** Pending.
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
@@ -586,7 +586,7 @@ are independent, and the wrapper is transparent apart from waiting.
|
|||||||
|
|
||||||
## Stage 3 — Shared Preparation And Early Run Admission
|
## Stage 3 — Shared Preparation And Early Run Admission
|
||||||
|
|
||||||
**Status:** Pending.
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
@@ -647,7 +647,7 @@ releases admission, and existing preparation semantics remain unchanged.
|
|||||||
|
|
||||||
## Stage 4 — Engine Assembly And Public Runtime Contract
|
## Stage 4 — Engine Assembly And Public Runtime Contract
|
||||||
|
|
||||||
**Status:** Pending.
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
@@ -721,7 +721,7 @@ through the same wrapper.
|
|||||||
|
|
||||||
## Stage 5 — Durable Documentation And Final Validation
|
## Stage 5 — Durable Documentation And Final Validation
|
||||||
|
|
||||||
**Status:** Pending.
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### 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
|
prose, and the OpenRouter compatibility change is clearly reported for the
|
||||||
next minor release.
|
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
|
## Open Questions
|
||||||
|
|
||||||
None. The feature roadmap and this plan fix the public representation,
|
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"
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
"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/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
@@ -62,6 +63,10 @@ var (
|
|||||||
// ErrPromptRender identifies a failure to render prompt messages or the
|
// ErrPromptRender identifies a failure to render prompt messages or the
|
||||||
// session ID from the resolved inputs and variables.
|
// session ID from the resolved inputs and variables.
|
||||||
ErrPromptRender = errors.New("failed to render prompt")
|
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
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
// response. Errors returned by an injected LLMClient remain available
|
// response. Errors returned by an injected LLMClient remain available
|
||||||
// through errors.Is.
|
// through errors.Is.
|
||||||
@@ -75,7 +80,9 @@ var (
|
|||||||
// Engine prepares and runs Promptkit prompt requests.
|
// Engine prepares and runs Promptkit prompt requests.
|
||||||
//
|
//
|
||||||
// An Engine is safe for concurrent calls to [Engine.Prepare] and [Engine.Run].
|
// 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 {
|
type Engine struct {
|
||||||
runner *usecase.Runner
|
runner *usecase.Runner
|
||||||
}
|
}
|
||||||
@@ -141,8 +148,10 @@ type engineOptions struct {
|
|||||||
|
|
||||||
// WithLLMClient replaces the built-in model client used by [Engine.Run].
|
// WithLLMClient replaces the built-in model client used by [Engine.Run].
|
||||||
//
|
//
|
||||||
// A nil client makes NewEngine fail with ErrInvalidConfig. The client may be
|
// A nil client makes NewEngine fail with ErrInvalidConfig. The Engine schedules
|
||||||
// called concurrently and is not used by [Engine.Prepare].
|
// 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 {
|
func WithLLMClient(client LLMClient) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
@@ -313,8 +322,9 @@ func WithSchemaFile(path string) Option {
|
|||||||
// or Run needs them.
|
// or Run needs them.
|
||||||
//
|
//
|
||||||
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
||||||
// configuration or options. It does not perform model requests or require
|
// configuration, options, or backend-capacity policies. Each constructed
|
||||||
// credentials.
|
// Engine has independent backend-capacity pools. Construction does not perform
|
||||||
|
// model requests or require credentials.
|
||||||
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||||
var options engineOptions
|
var options engineOptions
|
||||||
for _, opt := range opts {
|
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)
|
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
|
validator := options.validator
|
||||||
if !options.validatorSource {
|
if !options.validatorSource {
|
||||||
schemaDir := cfg.SchemaDir
|
schemaDir := cfg.SchemaDir
|
||||||
@@ -367,6 +382,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
llmClient = capacity.NewClient(capacityManager, llmClient)
|
||||||
|
|
||||||
artifacts := options.artifactReader
|
artifacts := options.artifactReader
|
||||||
if !options.artifactSource {
|
if !options.artifactSource {
|
||||||
@@ -382,6 +398,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validator,
|
validator,
|
||||||
|
capacityManager,
|
||||||
),
|
),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -450,11 +467,14 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
// single-pass even when OutputContract.RepairAttempts is positive.
|
// single-pass even when OutputContract.RepairAttempts is positive.
|
||||||
//
|
//
|
||||||
// Run can return every error category documented by [Engine.Prepare], plus
|
// Run can return every error category documented by [Engine.Prepare], plus
|
||||||
// ErrLLMGenerate. Errors from injected clients remain available through
|
// ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies
|
||||||
// errors.Is. Cancellation is passed through the active collaborator and is
|
// rejection before artifacts, schemas, rendering, or model generation because
|
||||||
// reported in the applicable operation category; no general errors.Is
|
// the selected backend's admission capacity is full; it does not match
|
||||||
// relationship to ctx.Err is promised. A nil Engine returns ErrInvalidConfig.
|
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
||||||
// Run returns no partial result on error.
|
// 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) {
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.runner == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
@@ -38,6 +39,8 @@ func publicErrorFor(err error) error {
|
|||||||
return ErrProfileLoad
|
return ErrProfileLoad
|
||||||
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
||||||
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
||||||
|
case errors.Is(err, capacity.ErrCapacityExceeded):
|
||||||
|
return ErrCapacityExceeded
|
||||||
case errors.Is(err, usecase.ErrArtifactLoad):
|
case errors.Is(err, usecase.ErrArtifactLoad):
|
||||||
return ErrArtifactLoad
|
return ErrArtifactLoad
|
||||||
case errors.Is(err, usecase.ErrPromptRender):
|
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"
|
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
||||||
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
||||||
|
|
||||||
|
openRouterConcurrencyLimit = 16
|
||||||
|
defaultQueueCapacity = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
// 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 := make([]domain.Backend, 0, len(additions)+1)
|
||||||
definitions = append(definitions, domain.Backend{
|
definitions = append(definitions, domain.Backend{
|
||||||
ID: OpenRouterID,
|
ID: OpenRouterID,
|
||||||
Endpoint: openRouterEndpoint,
|
Endpoint: openRouterEndpoint,
|
||||||
APIKeyEnv: openRouterAPIKeyEnv,
|
APIKeyEnv: openRouterAPIKeyEnv,
|
||||||
|
ConcurrencyLimit: openRouterConcurrencyLimit,
|
||||||
})
|
})
|
||||||
definitions = append(definitions, additions...)
|
definitions = append(definitions, additions...)
|
||||||
|
|
||||||
@@ -85,6 +89,25 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) {
|
|||||||
return definition, nil
|
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) {
|
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
||||||
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
||||||
if err := validateEndpoint(definition.Endpoint); err != nil {
|
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))
|
keys := make([]string, 0, len(definition.ExtraParams))
|
||||||
for key := range definition.ExtraParams {
|
for key := range definition.ExtraParams {
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
|
|||||||
@@ -24,9 +24,20 @@ func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) {
|
|||||||
if definition.ID != "openrouter" ||
|
if definition.ID != "openrouter" ||
|
||||||
definition.Endpoint != "https://openrouter.ai/api/v1" ||
|
definition.Endpoint != "https://openrouter.ai/api/v1" ||
|
||||||
definition.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
definition.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
||||||
|
definition.ConcurrencyLimit != 16 ||
|
||||||
|
definition.QueueCapacity != 1024 ||
|
||||||
|
!definition.QueueCapacitySet ||
|
||||||
definition.ExtraParams != nil {
|
definition.ExtraParams != nil {
|
||||||
t.Fatalf("unexpected OpenRouter definition: %#v", definition)
|
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) {
|
func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
||||||
@@ -37,10 +48,13 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
registry, err := backend.NewRegistry([]domain.Backend{
|
registry, err := backend.NewRegistry([]domain.Backend{
|
||||||
{
|
{
|
||||||
ID: " custom ",
|
ID: " custom ",
|
||||||
Endpoint: " https://custom.example/openai/v1 ",
|
Endpoint: " https://custom.example/openai/v1 ",
|
||||||
APIKeyEnv: " CUSTOM_API_KEY ",
|
APIKeyEnv: " CUSTOM_API_KEY ",
|
||||||
ExtraParams: extraParams,
|
ExtraParams: extraParams,
|
||||||
|
ConcurrencyLimit: 3,
|
||||||
|
QueueCapacity: 2,
|
||||||
|
QueueCapacitySet: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "Custom",
|
ID: "Custom",
|
||||||
@@ -60,7 +74,10 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if got.ID != "custom" ||
|
if got.ID != "custom" ||
|
||||||
got.Endpoint != "https://custom.example/openai/v1" ||
|
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)
|
t.Fatalf("unexpected normalized definition: %#v", got)
|
||||||
}
|
}
|
||||||
if count, ok := got.ExtraParams["count"].(int64); !ok || count != 7 {
|
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 {
|
if _, err := registry.GetBackend("Custom"); err != nil {
|
||||||
t.Fatalf("backend IDs should be case-sensitive: %v", err)
|
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) {
|
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.
|
// Backend describes reusable OpenAI-compatible connection defaults.
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
ID string
|
ID string
|
||||||
Endpoint string
|
Endpoint string
|
||||||
APIKeyEnv string
|
APIKeyEnv string
|
||||||
ExtraParams map[string]any
|
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.
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
"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/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
@@ -46,6 +47,7 @@ type Runner struct {
|
|||||||
llm llm.Client
|
llm llm.Client
|
||||||
validator validate.Validator
|
validator validate.Validator
|
||||||
repairer OutputRepairer
|
repairer OutputRepairer
|
||||||
|
admitter RunAdmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackendResolver resolves one normalized backend ID.
|
// BackendResolver resolves one normalized backend ID.
|
||||||
@@ -53,6 +55,22 @@ type BackendResolver interface {
|
|||||||
GetBackend(string) (domain.Backend, error)
|
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(
|
func NewRunner(
|
||||||
promptDefs promptdef.Repository,
|
promptDefs promptdef.Repository,
|
||||||
profiles profile.Repository,
|
profiles profile.Repository,
|
||||||
@@ -61,8 +79,19 @@ func NewRunner(
|
|||||||
renderer prompt.Renderer,
|
renderer prompt.Renderer,
|
||||||
llmClient llm.Client,
|
llmClient llm.Client,
|
||||||
validator validate.Validator,
|
validator validate.Validator,
|
||||||
|
admitter RunAdmitter,
|
||||||
) *Runner {
|
) *Runner {
|
||||||
return NewRunnerWithRepairer(promptDefs, profiles, backends, artifacts, renderer, llmClient, validator, nil)
|
return NewRunnerWithRepairer(
|
||||||
|
promptDefs,
|
||||||
|
profiles,
|
||||||
|
backends,
|
||||||
|
artifacts,
|
||||||
|
renderer,
|
||||||
|
llmClient,
|
||||||
|
validator,
|
||||||
|
nil,
|
||||||
|
admitter,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRunnerWithRepairer(
|
func NewRunnerWithRepairer(
|
||||||
@@ -74,6 +103,7 @@ func NewRunnerWithRepairer(
|
|||||||
llmClient llm.Client,
|
llmClient llm.Client,
|
||||||
validator validate.Validator,
|
validator validate.Validator,
|
||||||
repairer OutputRepairer,
|
repairer OutputRepairer,
|
||||||
|
admitter RunAdmitter,
|
||||||
) *Runner {
|
) *Runner {
|
||||||
return &Runner{
|
return &Runner{
|
||||||
promptDefs: promptDefs,
|
promptDefs: promptDefs,
|
||||||
@@ -84,6 +114,7 @@ func NewRunnerWithRepairer(
|
|||||||
llm: llmClient,
|
llm: llmClient,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
repairer: repairer,
|
repairer: repairer,
|
||||||
|
admitter: admitter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +126,27 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
|||||||
|
|
||||||
start := time.Now().UTC()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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) == "" {
|
if strings.TrimSpace(req.PromptID) == "" {
|
||||||
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest)
|
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)
|
return nil, fmt.Errorf("%w: session_id: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now().UTC()
|
|
||||||
|
|
||||||
def, err := r.promptDefs.GetPromptDefinition(ctx, req.PromptID, req.PromptVersion)
|
def, err := r.promptDefs.GetPromptDefinition(ctx, req.PromptID, req.PromptVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrPromptLoad, err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -257,9 +339,9 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
|||||||
inputHashes[name] = art.Hash
|
inputHashes[name] = art.Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
definitionToRender := def
|
definitionToRender := state.definition
|
||||||
if directSessionID != "" {
|
if state.directSessionID != "" {
|
||||||
definitionCopy := *def
|
definitionCopy := *state.definition
|
||||||
definitionCopy.SessionID = ""
|
definitionCopy.SessionID = ""
|
||||||
definitionToRender = &definitionCopy
|
definitionToRender = &definitionCopy
|
||||||
}
|
}
|
||||||
@@ -267,28 +349,28 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrPromptRender, err)
|
return nil, fmt.Errorf("%w: %w", ErrPromptRender, err)
|
||||||
}
|
}
|
||||||
if directSessionID != "" {
|
if state.directSessionID != "" {
|
||||||
renderedPrompt.SessionID = directSessionID
|
renderedPrompt.SessionID = state.directSessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
end := time.Now().UTC()
|
end := time.Now().UTC()
|
||||||
return &domain.PreparedRun{
|
return &domain.PreparedRun{
|
||||||
PromptID: def.ID,
|
PromptID: state.definition.ID,
|
||||||
PromptVersion: def.Version,
|
PromptVersion: state.definition.Version,
|
||||||
PromptHash: promptDefinitionHash,
|
PromptHash: state.promptDefinitionHash,
|
||||||
SelectedProfileID: selectedProfileID,
|
SelectedProfileID: state.selectedProfileID,
|
||||||
SelectedBackendID: effectiveModel.BackendID,
|
SelectedBackendID: state.effectiveModel.BackendID,
|
||||||
EffectiveModelParams: effectiveModel,
|
EffectiveModelParams: state.effectiveModel,
|
||||||
TargetPresence: targetPresence,
|
TargetPresence: state.targetPresence,
|
||||||
OutputContract: effectiveContract,
|
OutputContract: state.effectiveContract,
|
||||||
StructuredOutput: structuredOutput,
|
StructuredOutput: structuredOutput,
|
||||||
InputHashes: inputHashes,
|
InputHashes: inputHashes,
|
||||||
SessionID: renderedPrompt.SessionID,
|
SessionID: renderedPrompt.SessionID,
|
||||||
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
|
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
|
||||||
Messages: renderedPrompt.Messages,
|
Messages: renderedPrompt.Messages,
|
||||||
StartTime: start,
|
StartTime: state.start,
|
||||||
EndTime: end,
|
EndTime: end,
|
||||||
DurationMS: end.Sub(start).Milliseconds(),
|
DurationMS: end.Sub(state.start).Milliseconds(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
@@ -38,6 +41,52 @@ type fakeBackendResolver struct {
|
|||||||
backends map[string]domain.Backend
|
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) {
|
func (f fakeBackendResolver) GetBackend(id string) (domain.Backend, error) {
|
||||||
value, ok := f.backends[id]
|
value, ok := f.backends[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -70,9 +119,11 @@ func (f *fakePromptRepo) GetPromptDefinition(ctx context.Context, id string, ver
|
|||||||
type fakeArtifactReader struct {
|
type fakeArtifactReader struct {
|
||||||
artifactsByURI map[string]*domain.Artifact
|
artifactsByURI map[string]*domain.Artifact
|
||||||
errByURI map[string]error
|
errByURI map[string]error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
func (f *fakeArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
f.calls++
|
||||||
if err, ok := f.errByURI[ref.URI]; ok {
|
if err, ok := f.errByURI[ref.URI]; ok {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -86,9 +137,11 @@ func (f *fakeArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (
|
|||||||
type fakeRenderer struct {
|
type fakeRenderer struct {
|
||||||
rendered *domain.RenderedPrompt
|
rendered *domain.RenderedPrompt
|
||||||
err error
|
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) {
|
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 {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
@@ -125,9 +178,11 @@ type fakeValidator struct {
|
|||||||
schemaErr error
|
schemaErr error
|
||||||
schemaLoadPath string
|
schemaLoadPath string
|
||||||
schemaLoads int
|
schemaLoads int
|
||||||
|
validateCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
func (f *fakeValidator) Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error) {
|
||||||
|
f.validateCalls++
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return domain.ValidationResult{}, f.err
|
return domain.ValidationResult{}, f.err
|
||||||
}
|
}
|
||||||
@@ -153,6 +208,97 @@ type fakeRepairer struct {
|
|||||||
reqs []RepairRequest
|
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) {
|
func (f *fakeRepairer) Repair(ctx context.Context, req RepairRequest) (*domain.GenerateResponse, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
f.reqs = append(f.reqs, req)
|
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"}}}}
|
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||||
llmClient := &fakeLLM{forbid: true}
|
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{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
PromptVersion: "1",
|
PromptVersion: "1",
|
||||||
@@ -234,8 +380,8 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil,
|
nil, nil)
|
||||||
)
|
|
||||||
req := domain.RunRequest{
|
req := domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
ProfileID: "exec",
|
ProfileID: "exec",
|
||||||
@@ -281,8 +427,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil,
|
nil, nil)
|
||||||
)
|
|
||||||
|
|
||||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -312,8 +457,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil,
|
nil, nil)
|
||||||
)
|
|
||||||
|
|
||||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -340,8 +484,7 @@ func TestRunnerDirectSessionResolution(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
nil,
|
nil, nil)
|
||||||
)
|
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -408,7 +551,7 @@ func TestRunnerPrepareSelectedProfileDoesNotExistFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerPreparePromptLoadFailure(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"})
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p"})
|
||||||
if !errors.Is(err, ErrPromptLoad) {
|
if !errors.Is(err, ErrPromptLoad) {
|
||||||
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
||||||
@@ -432,7 +575,7 @@ func TestRunnerPrepareRuntimeOverrideBeatsSelectedProfileValue(t *testing.T) {
|
|||||||
ServiceTier: "priority",
|
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{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -535,7 +678,7 @@ func TestRunnerPrepareRequestNumericOverridePresence(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -582,7 +725,7 @@ func TestRunnerPrepareInvalidRequestNumericOverridesFail(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -609,7 +752,7 @@ func TestRunnerPrepareSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
|||||||
ServiceTier: "priority",
|
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{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -651,7 +794,7 @@ func TestRunnerPrepareFileBackedPromptBodiesRenderCorrectly(t *testing.T) {
|
|||||||
reader,
|
reader,
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "valid-file-backed",
|
PromptID: "valid-file-backed",
|
||||||
@@ -682,7 +825,7 @@ func TestRunnerPrepareRequiredInputMissingFails(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -709,7 +852,7 @@ func TestRunnerPrepareUnknownTemplateInputReferenceFails(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -732,7 +875,7 @@ func TestRunnerPrepareAPIKeyEnvNameIncludedButNotResolvedValue(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: envName},
|
"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()})
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -765,7 +908,7 @@ func TestRunnerPrepareJSONSchemaBuildsStructuredOutputSpec(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
validator)
|
validator, nil)
|
||||||
|
|
||||||
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -809,7 +952,7 @@ func TestRunnerPrepareJSONSchemaSchemaLoadFailureReturnsValidationError(t *testi
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{forbid: true},
|
&fakeLLM{forbid: true},
|
||||||
validator)
|
validator, nil)
|
||||||
|
|
||||||
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -839,7 +982,7 @@ func TestRunnerRunJSONSchemaSchemaLoadFailureFailsBeforeLLM(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validator)
|
validator, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
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"}}}}
|
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}}}
|
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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
PromptVersion: "1",
|
PromptVersion: "1",
|
||||||
@@ -1071,7 +1214,7 @@ func TestRunnerRunPassesExtraParamsToGenerateRequestTarget(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
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"}}}}
|
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap"}}
|
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{
|
req := domain.RunRequest{
|
||||||
PromptID: "p",
|
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) {
|
func TestRunnerRunExplicitProfileIDIsUsed(t *testing.T) {
|
||||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)}
|
||||||
promptRepo.def.DefaultProfile = "default-prof"
|
promptRepo.def.DefaultProfile = "default-prof"
|
||||||
@@ -1227,7 +1617,7 @@ func TestRunnerRunExplicitRuntimeOverrideBeatsSelectedProfileValue(t *testing.T)
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1271,7 +1661,7 @@ func TestRunnerRunSelectedProfileBeatsBuiltInDefault(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1298,7 +1688,7 @@ func TestRunnerRunBuiltInDefaultsUsedWhenProfileOmitsOptionalFields(t *testing.T
|
|||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"},
|
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1328,7 +1718,7 @@ func TestRunnerRunAPIKeyEnvResolvesFromEnvironment(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_TEST_API_KEY"},
|
"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()})
|
res, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1344,7 +1734,7 @@ func TestRunnerRunAPIKeyEnvMissingEnvironmentValueFailsClearly(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
"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()})
|
_, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if !errors.Is(err, ErrInvalidRequest) {
|
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"},
|
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1389,7 +1779,7 @@ func TestRunnerPrepareAPIKeyRequiredFailsWithoutDirectKey(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyRequired: true},
|
"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{
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1408,7 +1798,7 @@ func TestRunnerRunAPIKeyRequiredSucceedsWithDirectKey(t *testing.T) {
|
|||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyRequired: true},
|
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyRequired: true},
|
||||||
}}
|
}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}}
|
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{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1435,7 +1825,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideWorks(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"},
|
"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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1460,7 +1850,7 @@ func TestRunnerRunRuntimeAPIKeyEnvOverrideBeatsProfile(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: profileEnv},
|
"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{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1484,7 +1874,7 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) {
|
|||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model", APIKeyEnv: envName},
|
"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()})
|
res, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1500,7 +1890,7 @@ func TestRunnerRunAPIKeyValueNotPresentInMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerRunPromptLoadFailure(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"})
|
_, err := runner.Run(context.Background(), domain.RunRequest{PromptID: "p"})
|
||||||
if !errors.Is(err, ErrPromptLoad) {
|
if !errors.Is(err, ErrPromptLoad) {
|
||||||
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
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")}},
|
&fakeArtifactReader{errByURI: map[string]error{"a://bad": errors.New("read failed")}},
|
||||||
&fakeRenderer{rendered: &domain.RenderedPrompt{}},
|
&fakeRenderer{rendered: &domain.RenderedPrompt{}},
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1538,7 +1928,7 @@ func TestRunnerRunPromptRenderFailure(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
&fakeRenderer{err: errors.New("render failed")},
|
&fakeRenderer{err: errors.New("render failed")},
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1558,7 +1948,7 @@ func TestRunnerRunLLMFailure(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{err: errors.New("llm failed")},
|
&fakeLLM{err: errors.New("llm failed")},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1578,7 +1968,7 @@ func TestRunnerRunCancellationPreservesGenerationCategory(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ignored"}},
|
&fakeLLM{resp: &domain.GenerateResponse{Content: "ignored"}},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
@@ -1604,7 +1994,7 @@ func TestRunnerRunLLMInvalidRequestMapsToUsecaseInvalidRequest(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{err: llm.ErrInvalidRequest},
|
&fakeLLM{err: llm.ErrInvalidRequest},
|
||||||
nil)
|
nil, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1628,7 +2018,7 @@ func TestRunnerRunValidationStillWorks(t *testing.T) {
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "raw output"}},
|
&fakeLLM{resp: &domain.GenerateResponse{Content: "raw output"}},
|
||||||
validator)
|
validator, nil)
|
||||||
|
|
||||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1659,7 +2049,7 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t
|
|||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validate.NewStandardValidator("."),
|
validate.NewStandardValidator("."),
|
||||||
repairer)
|
repairer, nil)
|
||||||
|
|
||||||
res, err := runner.Run(context.Background(), domain.RunRequest{
|
res, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
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) {
|
func TestRunnerRunRepairCarriesEffectiveSessionID(t *testing.T) {
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":`}}
|
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: `{"broken":`}}
|
||||||
runner := NewRunnerWithRepairer(
|
runner := NewRunnerWithRepairer(
|
||||||
@@ -1702,8 +2183,7 @@ func TestRunnerRunRepairCarriesEffectiveSessionID(t *testing.T) {
|
|||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validate.NewStandardValidator("."),
|
validate.NewStandardValidator("."),
|
||||||
NewDefaultOutputRepairer(llmClient),
|
NewDefaultOutputRepairer(llmClient), nil)
|
||||||
)
|
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), domain.RunRequest{
|
result, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -1757,7 +2237,7 @@ func TestRunnerRunJSONSchemaRepairCarriesStructuredOutputSpec(t *testing.T) {
|
|||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validator,
|
validator,
|
||||||
repairer)
|
repairer, nil)
|
||||||
|
|
||||||
_, err := runner.Run(context.Background(), domain.RunRequest{
|
_, err := runner.Run(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
@@ -2114,7 +2594,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
|||||||
t.Run("unknown backend is a profile load failure", func(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{
|
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", BackendID: "unknown", Model: "model"},
|
"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()})
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if !errors.Is(err, ErrProfileLoad) {
|
if !errors.Is(err, ErrProfileLoad) {
|
||||||
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
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) {
|
t.Run("nil resolver is a profile load failure", func(t *testing.T) {
|
||||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", BackendID: "custom", Model: "model"},
|
"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()})
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if !errors.Is(err, ErrProfileLoad) {
|
if !errors.Is(err, ErrProfileLoad) {
|
||||||
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
||||||
@@ -2135,7 +2617,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
|||||||
t.Setenv("REQUEST_KEY", "request-secret")
|
t.Setenv("REQUEST_KEY", "request-secret")
|
||||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", BackendID: "custom", Model: "model", APIKeyEnv: "PROFILE_KEY"},
|
"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{
|
prepared, err := runner.Prepare(context.Background(), domain.RunRequest{
|
||||||
PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(),
|
PromptID: "p", ProfileID: "exec", Inputs: singleInputRef(),
|
||||||
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: "REQUEST_KEY"},
|
Execution: &domain.ExecutionTargetOverride{APIKeyEnv: "REQUEST_KEY"},
|
||||||
@@ -2152,7 +2635,8 @@ func TestRunnerPrepareBackendResolutionAndCredentialPrecedence(t *testing.T) {
|
|||||||
t.Setenv("BACKEND_KEY", "backend-secret")
|
t.Setenv("BACKEND_KEY", "backend-secret")
|
||||||
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
runner := NewRunner(promptRepo, &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{
|
||||||
"exec": {ID: "exec", BackendID: "custom", Model: "model", APIKeyRequired: true},
|
"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()})
|
_, err := runner.Prepare(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()})
|
||||||
if !errors.Is(err, ErrAPIKeyRequired) {
|
if !errors.Is(err, ErrAPIKeyRequired) {
|
||||||
t.Fatalf("expected ErrAPIKeyRequired, got %v", err)
|
t.Fatalf("expected ErrAPIKeyRequired, got %v", err)
|
||||||
@@ -2203,6 +2687,6 @@ func newMinimalRunner(promptRepo *fakePromptRepo, execRepo *fakeExecutionProfile
|
|||||||
defaultArtifactReader(),
|
defaultArtifactReader(),
|
||||||
defaultRenderer(),
|
defaultRenderer(),
|
||||||
&fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}},
|
&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) {
|
func TestBackendRegistrationRejectsInvalidAndDuplicateDefinitions(t *testing.T) {
|
||||||
cycle := map[string]any{}
|
cycle := map[string]any{}
|
||||||
cycle["self"] = cycle
|
cycle["self"] = cycle
|
||||||
|
|||||||
11
types.go
11
types.go
@@ -561,10 +561,13 @@ type StructuredOutputJSONSpec struct {
|
|||||||
|
|
||||||
// LLMClient executes rendered prompts for [Engine.Run].
|
// LLMClient executes rendered prompts for [Engine.Run].
|
||||||
//
|
//
|
||||||
// Generate may be called concurrently. It must honor context cancellation to
|
// Generate is scheduled according to the resolved backend's capacity policy.
|
||||||
// make Run responsive to cancellation. The request and all nested maps,
|
// It may still be called concurrently for different backend pools or unlimited
|
||||||
// slices, and pointers are client-owned copies and may be mutated or retained
|
// backends. Cancellation while waiting for capacity can prevent Generate from
|
||||||
// without affecting engine state.
|
// 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
|
// 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,
|
// client must protect those values and any raw output in its logging, storage,
|
||||||
|
|||||||
Reference in New Issue
Block a user