1836 lines
68 KiB
Go
1836 lines
68 KiB
Go
package httpadapter
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit"
|
|
)
|
|
|
|
type fakeRunner struct {
|
|
result *promptkit.RunResult
|
|
err error
|
|
last promptkit.RunRequest
|
|
}
|
|
|
|
type runnerFunc func(context.Context, promptkit.RunRequest) (*promptkit.RunResult, error)
|
|
|
|
func (run runnerFunc) Run(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
return run(ctx, req)
|
|
}
|
|
|
|
func (f *fakeRunner) Run(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
f.last = req
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.result, nil
|
|
}
|
|
|
|
func TestHandlerExecutionReportOptInAndResultShapes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, body string
|
|
wantReport bool
|
|
invalid bool
|
|
}{
|
|
{"omitted", `{"prompt_id":"p"}`, false, false},
|
|
{"null", `{"prompt_id":"p","include_execution_report":null}`, false, false},
|
|
{"false", `{"prompt_id":"p","include_execution_report":false}`, false, false},
|
|
{"success", `{"prompt_id":"p","include_execution_report":true}`, true, false},
|
|
{"invalid output", `{"prompt_id":"p","include_execution_report":true}`, true, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
runner := runnerFunc(func(_ context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
if (req.Observer != nil) != tc.wantReport {
|
|
t.Fatalf("unexpected observer presence: %v", req.Observer != nil)
|
|
}
|
|
status := promptkit.ValidationPassed
|
|
outcome := promptkit.ExecutionOutcomeSuccess
|
|
if tc.invalid {
|
|
status = promptkit.ValidationFailed
|
|
outcome = promptkit.ExecutionOutcomeInvalidOutput
|
|
}
|
|
if req.Observer != nil {
|
|
req.Observer(promptkit.ExecutionEvent{Kind: promptkit.ExecutionFinished, RunID: "observed", Outcome: outcome, Failure: promptkit.ExecutionFailureNone, Attempts: 2})
|
|
}
|
|
return &promptkit.RunResult{Artifact: promptkit.Artifact{Body: []byte("body")}, Validation: promptkit.ValidationResult{Status: status}, Usage: promptkit.TokenUsage{PromptTokens: 7}}, nil
|
|
})
|
|
w := httptest.NewRecorder()
|
|
NewHandler(runner).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(tc.body)))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var response map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if (response["execution"] != nil) != tc.wantReport {
|
|
t.Fatalf("unexpected report: %#v", response["execution"])
|
|
}
|
|
if tc.wantReport {
|
|
report := response["execution"].(map[string]any)
|
|
wantOutcome := "success"
|
|
if tc.invalid {
|
|
wantOutcome = "invalid_output"
|
|
}
|
|
if report["outcome"] != wantOutcome || report["attempts"] != float64(2) {
|
|
t.Fatalf("unexpected report: %#v", report)
|
|
}
|
|
}
|
|
if response["metadata"].(map[string]any)["usage"].(map[string]any)["prompt_tokens"] != float64(7) {
|
|
t.Fatalf("metadata usage changed: %#v", response["metadata"])
|
|
}
|
|
})
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
NewHandler(&fakeRunner{}).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","include_execution_report":"true"}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
|
|
}
|
|
|
|
func TestHandlerExecutionReportFailureAndAbsence(t *testing.T) {
|
|
secret := "private provider diagnostic"
|
|
runner := runnerFunc(func(_ context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
req.Observer(promptkit.ExecutionEvent{Kind: promptkit.ExecutionFinished, Outcome: promptkit.ExecutionOutcomeError, Failure: promptkit.ExecutionFailureProviderStatus, StatusCode: 503, RetriesExhausted: true, Attempts: 3})
|
|
return nil, fmt.Errorf("%w: %s", promptkit.ErrLLMGenerate, secret)
|
|
})
|
|
w := httptest.NewRecorder()
|
|
NewHandler(runner).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","include_execution_report":true}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadGateway, "llm_failed")
|
|
var response map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report := response["execution"].(map[string]any)
|
|
if report["failure"] != "provider_status" || report["retries_exhausted"] != true || report["attempts"] != float64(3) {
|
|
t.Fatalf("unexpected failure report: %#v", report)
|
|
}
|
|
if strings.Contains(w.Body.String(), secret) {
|
|
t.Fatalf("response leaked provider diagnostic: %s", w.Body.String())
|
|
}
|
|
|
|
noTerminal := runnerFunc(func(_ context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
if req.Observer == nil {
|
|
t.Fatal("missing opted-in observer")
|
|
}
|
|
return nil, promptkit.ErrPromptLoad
|
|
})
|
|
w = httptest.NewRecorder()
|
|
NewHandler(noTerminal).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","include_execution_report":true}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "prompt_load_failed")
|
|
if strings.Contains(w.Body.String(), "execution") {
|
|
t.Fatalf("unexpected absent report: %s", w.Body.String())
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
NewHandler(noTerminal).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"include_execution_report":true}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_request")
|
|
if strings.Contains(w.Body.String(), "execution") {
|
|
t.Fatalf("pre-invocation failure has report: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerExecutionReportResponseLimit(t *testing.T) {
|
|
for _, failure := range []bool{false, true} {
|
|
runner := runnerFunc(func(_ context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
if req.Observer != nil {
|
|
req.Observer(promptkit.ExecutionEvent{Kind: promptkit.ExecutionFinished, Outcome: promptkit.ExecutionOutcomeError, Failure: promptkit.ExecutionFailureGeneration})
|
|
}
|
|
if failure {
|
|
return nil, promptkit.ErrLLMGenerate
|
|
}
|
|
return &promptkit.RunResult{}, nil
|
|
})
|
|
body := `{"prompt_id":"p"}`
|
|
base := httptest.NewRecorder()
|
|
NewHandler(runner).ServeHTTP(base, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(body)))
|
|
body = `{"prompt_id":"p","include_execution_report":true}`
|
|
limited := httptest.NewRecorder()
|
|
NewHandlerWithOptions(runner, HandlerOptions{MaxResponseBytes: int64(base.Body.Len())}).ServeHTTP(limited, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(body)))
|
|
assertHTTPErrorCode(t, limited, http.StatusRequestEntityTooLarge, "response_too_large")
|
|
if strings.Contains(limited.Body.String(), "execution") {
|
|
t.Fatalf("overflow fallback included report: %s", limited.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerRealEngineFailureIncludesTerminalReport(t *testing.T) {
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "private provider detail", http.StatusServiceUnavailable)
|
|
}))
|
|
defer provider.Close()
|
|
h := newRetryHandler(t, provider.URL+"/v1", intPointer(0), 0, nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","include_execution_report":true}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadGateway, "llm_failed")
|
|
var response map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report := response["execution"].(map[string]any)
|
|
if report["outcome"] != "error" || report["failure"] != "provider_status" || report["attempts"] != float64(1) {
|
|
t.Fatalf("unexpected terminal report: %#v", report)
|
|
}
|
|
if strings.Contains(w.Body.String(), "private provider detail") {
|
|
t.Fatalf("provider diagnostic leaked: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerConcurrentExecutionReportsAreIsolated(t *testing.T) {
|
|
started := make(chan struct{}, 2)
|
|
release := make(chan struct{})
|
|
defer func() {
|
|
select {
|
|
case <-release:
|
|
default:
|
|
close(release)
|
|
}
|
|
}()
|
|
runner := runnerFunc(func(_ context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
started <- struct{}{}
|
|
<-release
|
|
req.Observer(promptkit.ExecutionEvent{Kind: promptkit.ExecutionFinished, RunID: req.SessionID, Outcome: promptkit.ExecutionOutcomeSuccess, Attempts: uint64(len(req.SessionID))})
|
|
return &promptkit.RunResult{}, nil
|
|
})
|
|
h := NewHandler(runner)
|
|
responses := make([]*httptest.ResponseRecorder, 2)
|
|
done := make(chan struct{}, 2)
|
|
for i, id := range []string{"one", "second"} {
|
|
responses[i] = httptest.NewRecorder()
|
|
go func(w *httptest.ResponseRecorder, session string) {
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(fmt.Sprintf(`{"prompt_id":"p","session_id":%q,"include_execution_report":true}`, session))))
|
|
done <- struct{}{}
|
|
}(responses[i], id)
|
|
}
|
|
for range 2 {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("concurrent runs did not enter runner")
|
|
}
|
|
}
|
|
close(release)
|
|
for range 2 {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("concurrent run did not complete")
|
|
}
|
|
}
|
|
for i, id := range []string{"one", "second"} {
|
|
var response map[string]any
|
|
if err := json.Unmarshal(responses[i].Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report := response["execution"].(map[string]any)
|
|
if report["run_id"] != id || report["attempts"] != float64(len(id)) {
|
|
t.Fatalf("report crossed requests: %#v", report)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
|
|
body, err := os.ReadFile(filepath.Join("..", "..", "..", "examples", "http-run.json"))
|
|
if err != nil {
|
|
t.Fatalf("read maintained HTTP request example: %v", err)
|
|
}
|
|
|
|
runner := &fakeRunner{result: &promptkit.RunResult{}}
|
|
h := NewHandler(runner)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected maintained HTTP request example to be accepted, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var invalidExample map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &invalidExample); err != nil {
|
|
t.Fatalf("decode maintained HTTP request example: %v", err)
|
|
}
|
|
invalidExample["unexpected"] = json.RawMessage(`true`)
|
|
invalidBody, err := json.Marshal(invalidExample)
|
|
if err != nil {
|
|
t.Fatalf("encode structurally invalid request example: %v", err)
|
|
}
|
|
invalidReq := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(invalidBody))
|
|
invalidW := httptest.NewRecorder()
|
|
h.ServeHTTP(invalidW, invalidReq)
|
|
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
|
|
}
|
|
|
|
func TestHandlerTransientRetryOverridePresenceAndStrictness(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
want *int
|
|
status int
|
|
code string
|
|
}{
|
|
{name: "omitted", body: `{"prompt_id":"p"}`},
|
|
{name: "null", body: `{"prompt_id":"p","transient_retries":null}`},
|
|
{name: "zero", body: `{"prompt_id":"p","transient_retries":0}`, want: intPointer(0)},
|
|
{name: "positive", body: `{"prompt_id":"p","transient_retries":3}`, want: intPointer(3)},
|
|
{name: "negative", body: `{"prompt_id":"p","transient_retries":-1}`, status: http.StatusBadRequest, code: "invalid_request"},
|
|
{name: "string", body: `{"prompt_id":"p","transient_retries":"2"}`, status: http.StatusBadRequest, code: "invalid_json"},
|
|
{name: "fractional", body: `{"prompt_id":"p","transient_retries":1.5}`, status: http.StatusBadRequest, code: "invalid_json"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
runner := &fakeRunner{result: &promptkit.RunResult{}}
|
|
w := httptest.NewRecorder()
|
|
NewHandler(runner).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(tc.body)))
|
|
if tc.status != 0 {
|
|
assertHTTPErrorCode(t, w, tc.status, tc.code)
|
|
if runner.last.PromptID != "" {
|
|
t.Fatalf("runner invoked for invalid request: %+v", runner.last)
|
|
}
|
|
return
|
|
}
|
|
if w.Code != http.StatusOK || !reflect.DeepEqual(runner.last.TransientRetries, tc.want) {
|
|
t.Fatalf("retry mapping: status=%d got=%v want=%v body=%s", w.Code, runner.last.TransientRetries, tc.want, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRunTimeoutValidationAndContextMapping(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, body, code string
|
|
status int
|
|
}{
|
|
{name: "negative", body: `{"prompt_id":"p","run_timeout_seconds":-1}`, status: http.StatusBadRequest, code: "invalid_request"},
|
|
{name: "duration overflow", body: `{"prompt_id":"p","run_timeout_seconds":9223372037}`, status: http.StatusBadRequest, code: "invalid_request"},
|
|
{name: "string", body: `{"prompt_id":"p","run_timeout_seconds":"1"}`, status: http.StatusBadRequest, code: "invalid_json"},
|
|
{name: "fractional", body: `{"prompt_id":"p","run_timeout_seconds":1.5}`, status: http.StatusBadRequest, code: "invalid_json"},
|
|
{name: "int64 overflow", body: `{"prompt_id":"p","run_timeout_seconds":9223372036854775808}`, status: http.StatusBadRequest, code: "invalid_json"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
runner := &fakeRunner{result: &promptkit.RunResult{}}
|
|
w := httptest.NewRecorder()
|
|
NewHandler(runner).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(tc.body)))
|
|
assertHTTPErrorCode(t, w, tc.status, tc.code)
|
|
if runner.last.PromptID != "" {
|
|
t.Fatalf("runner invoked for invalid timeout: %+v", runner.last)
|
|
}
|
|
})
|
|
}
|
|
|
|
parent, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
|
|
defer cancel()
|
|
parentDeadline, _ := parent.Deadline()
|
|
for _, tc := range []struct {
|
|
name, body string
|
|
child bool
|
|
}{
|
|
{name: "omitted", body: `{"prompt_id":"p"}`},
|
|
{name: "null", body: `{"prompt_id":"p","run_timeout_seconds":null}`},
|
|
{name: "zero", body: `{"prompt_id":"p","run_timeout_seconds":0}`},
|
|
{name: "positive", body: `{"prompt_id":"p","run_timeout_seconds":10}`, child: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
called := false
|
|
h := NewHandler(runnerFunc(func(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
called = true
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok || !deadline.Equal(parentDeadline) || (ctx != parent) != tc.child {
|
|
t.Fatalf("unexpected execution context: deadline=%v child=%t", deadline, ctx != parent)
|
|
}
|
|
return &promptkit.RunResult{}, nil
|
|
}))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(tc.body)).WithContext(parent))
|
|
if !called || w.Code != http.StatusOK {
|
|
t.Fatalf("context mapping: called=%t status=%d body=%s", called, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
canceled, cancelParent := context.WithCancel(context.Background())
|
|
cancelParent()
|
|
h := NewHandler(runnerFunc(func(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
return nil, ctx.Err()
|
|
}))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","run_timeout_seconds":10}`)).WithContext(canceled))
|
|
assertHTTPErrorCode(t, w, http.StatusRequestTimeout, "request_canceled")
|
|
|
|
h = NewHandler(runnerFunc(func(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
return nil, fmt.Errorf("attempt: %w: %w", promptkit.ErrLLMGenerate, context.DeadlineExceeded)
|
|
}))
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","run_timeout_seconds":10}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadGateway, "llm_failed")
|
|
|
|
successContext, cancelSuccess := context.WithCancel(context.Background())
|
|
h = NewHandler(runnerFunc(func(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) {
|
|
cancelSuccess()
|
|
return &promptkit.RunResult{}, nil
|
|
}))
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","run_timeout_seconds":10}`)).WithContext(successContext))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("successful result was reclassified after cancellation: %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func intPointer(value int) *int { return &value }
|
|
|
|
type handlerLLMClient struct{}
|
|
|
|
func (handlerLLMClient) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
|
}
|
|
|
|
type blockingLLMClient struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
current int32
|
|
peak int32
|
|
}
|
|
|
|
func (c *blockingLLMClient) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
|
current := atomic.AddInt32(&c.current, 1)
|
|
defer atomic.AddInt32(&c.current, -1)
|
|
for {
|
|
peak := atomic.LoadInt32(&c.peak)
|
|
if current <= peak || atomic.CompareAndSwapInt32(&c.peak, peak, current) {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case c.started <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-c.release:
|
|
return &promptkit.GenerateResponse{Content: "ok"}, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|
start := time.Now().UTC()
|
|
end := start.Add(2 * time.Second)
|
|
const envName = "SCRIPTORIUM_API_KEY"
|
|
const secret = "never-include-me"
|
|
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
RunID: "11111111-1111-4111-8111-111111111111",
|
|
Artifact: promptkit.Artifact{
|
|
Name: "output",
|
|
ContentType: "text/plain",
|
|
Body: []byte("hello"),
|
|
Size: 5,
|
|
Hash: "abc",
|
|
},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
PromptID: "prompt-1",
|
|
PromptVersion: "1.0.0",
|
|
PromptHash: "phash",
|
|
RenderedPromptHash: "rhash",
|
|
SelectedProfileID: "exec-default",
|
|
SelectedBackendID: "local",
|
|
ModelName: "m1",
|
|
Endpoint: "http://llm/v1",
|
|
EffectiveModelParams: promptkit.ExecutionTarget{
|
|
BackendID: "local",
|
|
Endpoint: "http://llm/v1",
|
|
Model: "m1",
|
|
Temperature: 0.2,
|
|
MaxTokens: 42,
|
|
TopP: 0.9,
|
|
TimeoutSeconds: 120,
|
|
ServiceTier: "priority",
|
|
APIKeyEnv: envName,
|
|
},
|
|
InputHashes: map[string]string{"transcript": "h1"},
|
|
Usage: promptkit.TokenUsage{
|
|
PromptTokens: 1,
|
|
CompletionTokens: 2,
|
|
TotalTokens: 3,
|
|
CachedTokens: 4,
|
|
CacheWriteTokens: 5,
|
|
},
|
|
StartTime: start,
|
|
EndTime: end,
|
|
Duration: 2 * time.Second,
|
|
RawOutput: "hello",
|
|
}}
|
|
|
|
h := NewHandler(r)
|
|
|
|
body := []byte(`{
|
|
"prompt_id": "prompt-1",
|
|
"profile_id": "exec-default",
|
|
"inputs": {
|
|
"transcript": {"type": "file", "uri": "./t.md"}
|
|
},
|
|
"vars": {"k": "v"},
|
|
"model": {"model": "gpt-x", "timeout_seconds": 120, "service_tier": "flex", "api_key_env": "SCRIPTORIUM_API_KEY"}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if metadata["prompt_id"] != "prompt-1" {
|
|
t.Fatalf("unexpected metadata.prompt_id: %#v", metadata["prompt_id"])
|
|
}
|
|
if metadata["prompt_version"] != "1.0.0" {
|
|
t.Fatalf("unexpected metadata.prompt_version: %#v", metadata["prompt_version"])
|
|
}
|
|
if metadata["selected_profile_id"] != "exec-default" {
|
|
t.Fatalf("unexpected metadata.selected_profile_id: %#v", metadata["selected_profile_id"])
|
|
}
|
|
if metadata["selected_backend_id"] != "local" {
|
|
t.Fatalf("unexpected metadata.selected_backend_id: %#v", metadata["selected_backend_id"])
|
|
}
|
|
if metadata["model_name"] != "m1" || metadata["endpoint"] != "http://llm/v1" {
|
|
t.Fatalf("unexpected model metadata: name=%#v endpoint=%#v", metadata["model_name"], metadata["endpoint"])
|
|
}
|
|
usage := metadata["usage"].(map[string]any)
|
|
if usage["prompt_tokens"] != float64(1) || usage["completion_tokens"] != float64(2) || usage["total_tokens"] != float64(3) {
|
|
t.Fatalf("unexpected base usage metadata: %#v", usage)
|
|
}
|
|
if usage["cached_tokens"] != float64(4) || usage["cache_write_tokens"] != float64(5) {
|
|
t.Fatalf("unexpected cache usage metadata: %#v", usage)
|
|
}
|
|
modelParams := metadata["model_params"].(map[string]any)
|
|
if modelParams["backend_id"] != "local" {
|
|
t.Fatalf("expected model_params.backend_id=local, got %#v", modelParams["backend_id"])
|
|
}
|
|
if modelParams["api_key_env"] != envName {
|
|
t.Fatalf("expected model_params.api_key_env=%q, got %#v", envName, modelParams["api_key_env"])
|
|
}
|
|
if modelParams["service_tier"] != "priority" {
|
|
t.Fatalf("expected model_params.service_tier=priority, got %#v", modelParams["service_tier"])
|
|
}
|
|
if strings.Contains(w.Body.String(), secret) {
|
|
t.Fatalf("response leaked raw API key value: %s", w.Body.String())
|
|
}
|
|
if _, ok := resp["raw_model_output"]; ok {
|
|
t.Fatalf("expected raw_model_output to be omitted by default, got %#v", resp["raw_model_output"])
|
|
}
|
|
|
|
if r.last.PromptID != "prompt-1" {
|
|
t.Fatalf("expected request prompt_id prompt-1, got %q", r.last.PromptID)
|
|
}
|
|
if r.last.ProfileID != "exec-default" {
|
|
t.Fatalf("expected request profile_id exec-default, got %q", r.last.ProfileID)
|
|
}
|
|
if r.last.Execution == nil || r.last.Execution.Model != "gpt-x" {
|
|
t.Fatalf("expected model override, got %#v", r.last.Execution)
|
|
}
|
|
if r.last.Execution.TimeoutSeconds == nil || *r.last.Execution.TimeoutSeconds != 120 {
|
|
t.Fatalf("expected timeout_seconds override 120, got %#v", r.last.Execution)
|
|
}
|
|
if r.last.Execution.ServiceTier != "flex" {
|
|
t.Fatalf("expected service_tier override flex, got %#v", r.last.Execution)
|
|
}
|
|
}
|
|
|
|
func TestHandlerAllowsOmittedInputsAndMapsPromptVersion(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationNone, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
for _, body := range []string{
|
|
`{"prompt_id":"prompt-1","prompt_version":"2"}`,
|
|
`{"prompt_id":"prompt-1","prompt_version":"2","inputs":{}}`,
|
|
} {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.PromptVersion != "2" {
|
|
t.Fatalf("expected prompt version to be mapped, got %q", r.last.PromptVersion)
|
|
}
|
|
if r.last.Inputs != nil {
|
|
t.Fatalf("expected omitted or empty inputs to remain nil, got %#v", r.last.Inputs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerDelegatesDefinitionInputRequirements(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
definition string
|
|
wantStatus int
|
|
wantCode string
|
|
}{
|
|
{
|
|
name: "no declared inputs",
|
|
definition: `id: p
|
|
version: "1"
|
|
default_profile: exec
|
|
messages:
|
|
- role: user
|
|
content: "hello"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "optional input omitted",
|
|
definition: `id: p
|
|
version: "1"
|
|
default_profile: exec
|
|
inputs:
|
|
- name: note
|
|
required: false
|
|
messages:
|
|
- role: user
|
|
content: "hello"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`,
|
|
wantStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "required input omitted",
|
|
definition: `id: p
|
|
version: "1"
|
|
default_profile: exec
|
|
inputs:
|
|
- name: note
|
|
required: true
|
|
messages:
|
|
- role: user
|
|
content: "hello"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: "prompt_render_failed",
|
|
},
|
|
{
|
|
name: "template input omitted",
|
|
definition: `id: p
|
|
version: "1"
|
|
default_profile: exec
|
|
messages:
|
|
- role: user
|
|
content: '{{input "note"}}'
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: "prompt_render_failed",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newDefinitionHandler(t, tc.definition)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != tc.wantStatus {
|
|
t.Fatalf("expected %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
|
|
}
|
|
if tc.wantCode != "" {
|
|
assertHTTPErrorCode(t, w, tc.wantStatus, tc.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
|
|
h := newArtifactRootHandler(t, "")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"inline","body":"inline body"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerFileRefsWithoutArtifactRootAreRejected(t *testing.T) {
|
|
h := newArtifactRootHandler(t, "")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"file","uri":"input.txt"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
|
|
}
|
|
|
|
func TestHandlerFileRefsUnderArtifactRootWork(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := newArtifactRootHandler(t, root)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"file","uri":"input.txt"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerFileRefsAboveArtifactLimitAreRejected(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := newArtifactRootHandlerWithLimit(t, root, 5)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"file","uri":"large.txt"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "artifact_too_large")
|
|
}
|
|
|
|
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := newArtifactRootHandler(t, root)
|
|
|
|
tests := []struct {
|
|
name string
|
|
uri string
|
|
}{
|
|
{name: "relative traversal", uri: filepath.Join("..", filepath.Base(outside), "secret.txt")},
|
|
{name: "absolute outside root", uri: filepath.Join(outside, "secret.txt")},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := fmt.Sprintf(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"file","uri":%q}}
|
|
}`, tc.uri)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "artifact_not_allowed")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
PromptID: "prompt-1",
|
|
PromptVersion: "1.0.0",
|
|
SelectedProfileID: "prompt-default",
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"prompt-1","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.ProfileID != "" {
|
|
t.Fatalf("expected empty request profile_id when omitted, got %q", r.last.ProfileID)
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if metadata["selected_profile_id"] != "prompt-default" {
|
|
t.Fatalf("expected selected_profile_id from result, got %#v", metadata["selected_profile_id"])
|
|
}
|
|
usage := metadata["usage"].(map[string]any)
|
|
if usage["cached_tokens"] != float64(0) || usage["cache_write_tokens"] != float64(0) {
|
|
t.Fatalf("expected zero cache usage fields to be included, got %#v", usage)
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
reqBody := `{
|
|
"prompt_id": "prompt-1",
|
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
|
"model": {
|
|
"endpoint": "http://override/v1",
|
|
"model": "override-model",
|
|
"temperature": 0.6,
|
|
"max_tokens": 250,
|
|
"top_p": 0.85,
|
|
"timeout_seconds": 33,
|
|
"service_tier": "flex",
|
|
"reasoning_effort": "medium",
|
|
"api_key_env": "SCRIPTORIUM_API_KEY",
|
|
"extra_params": {"provider_option":"on"}
|
|
}
|
|
}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(reqBody))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.Execution == nil {
|
|
t.Fatalf("expected execution override in run request")
|
|
}
|
|
got := r.last.Execution
|
|
if got.Endpoint != "http://override/v1" ||
|
|
got.Model != "override-model" ||
|
|
got.ServiceTier != "flex" ||
|
|
got.APIKeyEnv != "SCRIPTORIUM_API_KEY" {
|
|
t.Fatalf("unexpected mapped execution target: %+v", got)
|
|
}
|
|
if got.ReasoningEffort == nil || *got.ReasoningEffort != "medium" {
|
|
t.Fatalf("unexpected mapped reasoning_effort: %#v", got.ReasoningEffort)
|
|
}
|
|
if got.Temperature == nil || *got.Temperature != 0.6 {
|
|
t.Fatalf("unexpected mapped temperature: %#v", got.Temperature)
|
|
}
|
|
if got.MaxTokens == nil || *got.MaxTokens != 250 {
|
|
t.Fatalf("unexpected mapped max_tokens: %#v", got.MaxTokens)
|
|
}
|
|
if got.TopP == nil || *got.TopP != 0.85 {
|
|
t.Fatalf("unexpected mapped top_p: %#v", got.TopP)
|
|
}
|
|
if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 33 {
|
|
t.Fatalf("unexpected mapped timeout_seconds: %#v", got.TimeoutSeconds)
|
|
}
|
|
if !reflect.DeepEqual(got.ExtraParams, map[string]any{"provider_option": "on"}) {
|
|
t.Fatalf("unexpected mapped extra_params: %#v", got.ExtraParams)
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelOverridePreservesReasoningEffortPresence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
wantPresent bool
|
|
wantValue string
|
|
}{
|
|
{name: "omitted", model: `{}`},
|
|
{name: "nonblank", model: `{"reasoning_effort":"high"}`, wantPresent: true, wantValue: "high"},
|
|
{name: "explicit empty", model: `{"reasoning_effort":""}`, wantPresent: true},
|
|
{name: "null", model: `{"reasoning_effort":null}`},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"prompt-1","inputs":{"transcript":{"type":"file","uri":"./t.md"}},"model":`+tc.model+`}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.Execution == nil {
|
|
t.Fatal("expected execution override")
|
|
}
|
|
if (r.last.Execution.ReasoningEffort != nil) != tc.wantPresent {
|
|
t.Fatalf("unexpected reasoning_effort presence: %#v", r.last.Execution.ReasoningEffort)
|
|
}
|
|
if tc.wantPresent && *r.last.Execution.ReasoningEffort != tc.wantValue {
|
|
t.Fatalf("unexpected reasoning_effort: got %q want %q", *r.last.Execution.ReasoningEffort, tc.wantValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerMapsSessionIDAndReportsEffectiveResultSessionID(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
SessionID: "effective-session",
|
|
}}
|
|
h := NewHandler(r)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"prompt-1","session_id":"request-session"}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.SessionID != "request-session" {
|
|
t.Fatalf("expected session ID in run request, got %q", r.last.SessionID)
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if metadata["session_id"] != "effective-session" {
|
|
t.Fatalf("expected effective session ID in response, got %#v", metadata["session_id"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerOmitsEmptyEffectiveSessionID(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"prompt-1"}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if _, ok := metadata["session_id"]; ok {
|
|
t.Fatalf("expected empty effective session ID to be omitted, got %#v", metadata)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSessionIDUsesPromptkitResolution(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
definitionSession string
|
|
requestSession string
|
|
wantSession string
|
|
}{
|
|
{name: "definition session", definitionSession: "definition-session", wantSession: "definition-session"},
|
|
{name: "direct session", definitionSession: "definition-session", requestSession: "direct-session", wantSession: "direct-session"},
|
|
{name: "no effective session"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
promptDir := t.TempDir()
|
|
profileDir := t.TempDir()
|
|
sessionLine := ""
|
|
if tc.definitionSession != "" {
|
|
sessionLine = "session_id: " + tc.definitionSession + "\n"
|
|
}
|
|
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte("id: p\nversion: \"1\"\ndefault_profile: exec\n"+sessionLine+`messages:
|
|
- role: user
|
|
content: "hi"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write prompt fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte("id: exec\nendpoint: http://127.0.0.1:1/v1\nmodel: test\n"), 0o644); err != nil {
|
|
t.Fatalf("write profile fixture: %v", err)
|
|
}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{PromptDir: promptDir, ProfileDir: profileDir}, promptkit.WithLLMClient(handlerLLMClient{}))
|
|
if err != nil {
|
|
t.Fatalf("new engine: %v", err)
|
|
}
|
|
|
|
body := `{"prompt_id":"p"}`
|
|
if tc.requestSession != "" {
|
|
body = `{"prompt_id":"p","session_id":"` + tc.requestSession + `"}`
|
|
}
|
|
w := httptest.NewRecorder()
|
|
NewHandler(engine).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body)))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
if tc.wantSession == "" {
|
|
if _, ok := metadata["session_id"]; ok {
|
|
t.Fatalf("expected session_id to be omitted, got %#v", metadata)
|
|
}
|
|
return
|
|
}
|
|
if metadata["session_id"] != tc.wantSession {
|
|
t.Fatalf("expected effective session ID %q, got %#v", tc.wantSession, metadata["session_id"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerSharesBackendCapacityAcrossConcurrentRequests(t *testing.T) {
|
|
promptDir := t.TempDir()
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(`id: p
|
|
version: "1"
|
|
default_profile: limited
|
|
messages:
|
|
- role: user
|
|
content: "hi"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write prompt fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: limited
|
|
backend: limited
|
|
model: test
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write profile fixture: %v", err)
|
|
}
|
|
queueCapacity := 0
|
|
client := &blockingLLMClient{started: make(chan struct{}, 1), release: make(chan struct{})}
|
|
engine, err := promptkit.NewEngine(
|
|
promptkit.Config{PromptDir: promptDir, ProfileDir: profileDir},
|
|
promptkit.WithBackend(promptkit.Backend{
|
|
ID: "limited",
|
|
Endpoint: "http://127.0.0.1:1/v1",
|
|
ConcurrencyLimit: 1,
|
|
QueueCapacity: &queueCapacity,
|
|
}),
|
|
promptkit.WithLLMClient(client),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("new engine: %v", err)
|
|
}
|
|
h := NewHandler(engine)
|
|
first := httptest.NewRecorder()
|
|
firstDone := make(chan struct{})
|
|
go func() {
|
|
defer close(firstDone)
|
|
h.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`)))
|
|
}()
|
|
select {
|
|
case <-client.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first request did not reach generation")
|
|
}
|
|
|
|
second := httptest.NewRecorder()
|
|
h.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`)))
|
|
assertHTTPErrorCode(t, second, http.StatusServiceUnavailable, "capacity_exceeded")
|
|
if second.Header().Get("Retry-After") != "" {
|
|
t.Fatalf("expected no Retry-After header, got %q", second.Header().Get("Retry-After"))
|
|
}
|
|
if strings.Contains(second.Body.String(), "limited") {
|
|
t.Fatalf("capacity response leaked backend details: %s", second.Body.String())
|
|
}
|
|
|
|
close(client.release)
|
|
select {
|
|
case <-firstDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first request did not complete")
|
|
}
|
|
if first.Code != http.StatusOK {
|
|
t.Fatalf("expected first request to succeed, got %d body=%s", first.Code, first.Body.String())
|
|
}
|
|
if atomic.LoadInt32(&client.peak) != 1 {
|
|
t.Fatalf("expected peak generation concurrency of one, got %d", atomic.LoadInt32(&client.peak))
|
|
}
|
|
}
|
|
|
|
func TestHandlerBuiltInTransientRetriesInheritAndAllowExplicitZero(t *testing.T) {
|
|
var hits int32
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&hits, 1) == 1 {
|
|
http.Error(w, "private provider detail", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"recovered"}}]}`))
|
|
}))
|
|
defer provider.Close()
|
|
h := newRetryHandler(t, provider.URL+"/v1", intPointer(1), 0, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p"}`)))
|
|
if w.Code != http.StatusOK || atomic.LoadInt32(&hits) != 2 {
|
|
t.Fatalf("inherited retry: status=%d hits=%d body=%s", w.Code, atomic.LoadInt32(&hits), w.Body.String())
|
|
}
|
|
atomic.StoreInt32(&hits, 0)
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","transient_retries":0}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusBadGateway, "llm_failed")
|
|
if atomic.LoadInt32(&hits) != 1 || strings.Contains(w.Body.String(), "private provider detail") {
|
|
t.Fatalf("explicit zero or safe error failed: hits=%d body=%s", atomic.LoadInt32(&hits), w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerRetainsSharedBackendLimitDuringRetry(t *testing.T) {
|
|
started := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
var hits, active, peak int32
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
current := atomic.AddInt32(&active, 1)
|
|
defer atomic.AddInt32(&active, -1)
|
|
for {
|
|
old := atomic.LoadInt32(&peak)
|
|
if current <= old || atomic.CompareAndSwapInt32(&peak, old, current) {
|
|
break
|
|
}
|
|
}
|
|
if atomic.AddInt32(&hits, 1) == 1 {
|
|
started <- struct{}{}
|
|
<-release
|
|
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
|
}))
|
|
defer provider.Close()
|
|
defer func() {
|
|
select {
|
|
case <-release:
|
|
default:
|
|
close(release)
|
|
}
|
|
}()
|
|
queueCapacity := 0
|
|
h := newRetryHandler(t, provider.URL+"/v1", intPointer(1), 1, &queueCapacity)
|
|
first := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
h.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p"}`)))
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("first provider call did not start")
|
|
}
|
|
second := httptest.NewRecorder()
|
|
h.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","transient_retries":0}`)))
|
|
assertHTTPErrorCode(t, second, http.StatusServiceUnavailable, "capacity_exceeded")
|
|
close(release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("retrying request did not complete")
|
|
}
|
|
if first.Code != http.StatusOK || atomic.LoadInt32(&hits) != 2 || atomic.LoadInt32(&peak) > 1 {
|
|
t.Fatalf("shared backend limit failed: first=%d hits=%d peak=%d body=%s", first.Code, atomic.LoadInt32(&hits), atomic.LoadInt32(&peak), first.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeadlineCancelsBuiltInRetryWait(t *testing.T) {
|
|
var hits int32
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&hits, 1)
|
|
w.Header().Set("Retry-After", "3600")
|
|
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
|
}))
|
|
defer provider.Close()
|
|
h := newRetryHandler(t, provider.URL+"/v1", intPointer(1), 0, nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","run_timeout_seconds":1}`)))
|
|
assertHTTPErrorCode(t, w, http.StatusGatewayTimeout, "deadline_exceeded")
|
|
if atomic.LoadInt32(&hits) != 1 {
|
|
t.Fatalf("expected one provider call before canceled retry wait, got %d", atomic.LoadInt32(&hits))
|
|
}
|
|
}
|
|
|
|
func TestHandlerDeadlineCancelsCapacityWaitAndReleasesAdmission(t *testing.T) {
|
|
started := make(chan struct{}, 1)
|
|
release := make(chan struct{})
|
|
var hits int32
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if atomic.AddInt32(&hits, 1) == 1 {
|
|
started <- struct{}{}
|
|
<-release
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
|
|
}))
|
|
defer provider.Close()
|
|
defer func() {
|
|
select {
|
|
case <-release:
|
|
default:
|
|
close(release)
|
|
}
|
|
}()
|
|
queue := 1
|
|
h := newRetryHandler(t, provider.URL+"/v1", intPointer(0), 1, &queue)
|
|
first := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
h.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p"}`)))
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("first provider call did not start")
|
|
}
|
|
waiting := httptest.NewRecorder()
|
|
h.ServeHTTP(waiting, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p","run_timeout_seconds":1}`)))
|
|
assertHTTPErrorCode(t, waiting, http.StatusGatewayTimeout, "deadline_exceeded")
|
|
if atomic.LoadInt32(&hits) != 1 {
|
|
t.Fatalf("capacity waiter reached provider: hits=%d", atomic.LoadInt32(&hits))
|
|
}
|
|
close(release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("first request did not finish")
|
|
}
|
|
if first.Code != http.StatusOK {
|
|
t.Fatalf("first request: status=%d body=%s", first.Code, first.Body.String())
|
|
}
|
|
third := httptest.NewRecorder()
|
|
h.ServeHTTP(third, httptest.NewRequest(http.MethodPost, "/v1/runs", strings.NewReader(`{"prompt_id":"p"}`)))
|
|
if third.Code != http.StatusOK || atomic.LoadInt32(&hits) != 2 {
|
|
t.Fatalf("admission was not released: status=%d hits=%d body=%s", third.Code, atomic.LoadInt32(&hits), third.Body.String())
|
|
}
|
|
}
|
|
|
|
func newRetryHandler(t *testing.T, endpoint string, retries *int, concurrency int, queue *int) *Handler {
|
|
t.Helper()
|
|
promptDir := t.TempDir()
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte("id: p\nversion: \"1\"\ndefault_profile: limited\nmessages:\n - role: user\n content: hi\noutput:\n format: text\n validation_mode: none\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte("id: limited\nbackend: limited\nmodel: test\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{PromptDir: promptDir, ProfileDir: profileDir, TransientRetries: retries}, promptkit.WithBackend(promptkit.Backend{ID: "limited", Endpoint: endpoint, ConcurrencyLimit: concurrency, QueueCapacity: queue}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return NewHandler(engine)
|
|
}
|
|
|
|
func TestHandlerRejectsOverlongSessionIDAndNonStringReasoningEffort(t *testing.T) {
|
|
engine := newHandlerEngine(t)
|
|
for _, body := range []string{
|
|
`{"prompt_id":"p","session_id":"` + strings.Repeat("x", 257) + `"}`,
|
|
`{"prompt_id":"p","model":{"reasoning_effort":1}}`,
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
NewHandler(engine).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body)))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
reqBody := `{
|
|
"prompt_id": "prompt-1",
|
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
|
"model": {
|
|
"extra_params": {
|
|
"string_value": "enabled",
|
|
"number_value": 42,
|
|
"boolean_value": true,
|
|
"object_value": {"nested": "value", "count": 2},
|
|
"array_value": ["first", 3, false]
|
|
}
|
|
}
|
|
}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(reqBody))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.Execution == nil {
|
|
t.Fatal("expected execution override in run request")
|
|
}
|
|
want := map[string]any{
|
|
"string_value": "enabled",
|
|
"number_value": float64(42),
|
|
"boolean_value": true,
|
|
"object_value": map[string]any{"nested": "value", "count": float64(2)},
|
|
"array_value": []any{"first", float64(3), false},
|
|
}
|
|
if !reflect.DeepEqual(r.last.Execution.ExtraParams, want) {
|
|
t.Fatalf("unexpected mapped extra_params:\ngot=%#v\nwant=%#v", r.last.Execution.ExtraParams, want)
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id": "prompt-1",
|
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
|
"model": {"temperature": 0}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.Execution == nil || r.last.Execution.Temperature == nil {
|
|
t.Fatalf("expected temperature override to be present, got %#v", r.last.Execution)
|
|
}
|
|
if *r.last.Execution.Temperature != 0 {
|
|
t.Fatalf("expected zero temperature override, got %v", *r.last.Execution.Temperature)
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id": "prompt-1",
|
|
"inputs": {"transcript": {"type": "file", "uri": "./t.md"}},
|
|
"model": {"model": "override-model"}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if r.last.Execution == nil {
|
|
t.Fatal("expected model override")
|
|
}
|
|
if r.last.Execution.Temperature != nil {
|
|
t.Fatalf("expected omitted temperature to remain absent, got %#v", r.last.Execution.Temperature)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
params := metadata["model_params"].(map[string]any)
|
|
if params["temperature"] != 0.7 {
|
|
t.Fatalf("expected effective profile/default temperature in response, got %#v", params["temperature"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
|
r := &fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{
|
|
Name: "output",
|
|
ContentType: "text/plain",
|
|
Body: []byte("ok"),
|
|
Size: 2,
|
|
Hash: "abc",
|
|
},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{
|
|
Endpoint: "http://llm/v1",
|
|
Model: "gpt-test",
|
|
Temperature: 0.4,
|
|
MaxTokens: 321,
|
|
TopP: 0.7,
|
|
TimeoutSeconds: 45,
|
|
ServiceTier: "priority",
|
|
ReasoningEffort: "high",
|
|
APIKeyEnv: "SCRIPTORIUM_API_KEY",
|
|
ExtraParams: map[string]any{
|
|
"provider_option": "on",
|
|
"number_value": 42,
|
|
"object_value": map[string]any{"nested": "value"},
|
|
},
|
|
},
|
|
}}
|
|
h := NewHandler(r)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
metadata := resp["metadata"].(map[string]any)
|
|
params := metadata["model_params"].(map[string]any)
|
|
|
|
if params["endpoint"] != "http://llm/v1" {
|
|
t.Fatalf("unexpected endpoint: %#v", params["endpoint"])
|
|
}
|
|
if params["model"] != "gpt-test" {
|
|
t.Fatalf("unexpected model: %#v", params["model"])
|
|
}
|
|
if params["temperature"] != 0.4 {
|
|
t.Fatalf("unexpected temperature: %#v", params["temperature"])
|
|
}
|
|
if params["max_tokens"] != float64(321) {
|
|
t.Fatalf("unexpected max_tokens: %#v", params["max_tokens"])
|
|
}
|
|
if params["top_p"] != 0.7 {
|
|
t.Fatalf("unexpected top_p: %#v", params["top_p"])
|
|
}
|
|
if params["timeout_seconds"] != float64(45) {
|
|
t.Fatalf("unexpected timeout_seconds: %#v", params["timeout_seconds"])
|
|
}
|
|
if params["service_tier"] != "priority" {
|
|
t.Fatalf("unexpected service_tier: %#v", params["service_tier"])
|
|
}
|
|
if params["reasoning_effort"] != "high" {
|
|
t.Fatalf("unexpected reasoning_effort: %#v", params["reasoning_effort"])
|
|
}
|
|
if params["api_key_env"] != "SCRIPTORIUM_API_KEY" {
|
|
t.Fatalf("unexpected api_key_env: %#v", params["api_key_env"])
|
|
}
|
|
extraParams, ok := params["extra_params"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected extra_params object, got %#v", params["extra_params"])
|
|
}
|
|
if extraParams["provider_option"] != "on" {
|
|
t.Fatalf("unexpected extra_params.provider_option: %#v", extraParams["provider_option"])
|
|
}
|
|
if extraParams["number_value"] != float64(42) {
|
|
t.Fatalf("unexpected extra_params.number_value: %#v", extraParams["number_value"])
|
|
}
|
|
objectValue, ok := extraParams["object_value"].(map[string]any)
|
|
if !ok || objectValue["nested"] != "value" {
|
|
t.Fatalf("unexpected extra_params.object_value: %#v", extraParams["object_value"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerInvalidJSON(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{"))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRejectsTrailingJSON(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}} {}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
|
|
}
|
|
|
|
func TestHandlerRequestTooLarge(t *testing.T) {
|
|
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 12})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "request_too_large")
|
|
}
|
|
|
|
func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
|
|
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 1024})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{"))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
|
|
}
|
|
|
|
func TestHandlerResponseTooLarge(t *testing.T) {
|
|
h := NewHandlerWithOptions(&fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
|
|
}
|
|
|
|
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
|
h := NewHandlerWithOptions(&fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("ok")},
|
|
RawOutput: strings.Repeat("raw", 80),
|
|
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic, IsValid: true},
|
|
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"file","uri":"a"}},
|
|
"include_raw_output":true
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
|
|
}
|
|
|
|
func TestHandlerMissingPromptID(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != "invalid_request" {
|
|
t.Fatalf("expected invalid_request code, got %#v", errBody["code"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerReservedExtraParamsThroughEngineMapsToInvalidRequest(t *testing.T) {
|
|
h := NewHandler(newHandlerEngineWithDefaultClient(t))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
|
"prompt_id":"p",
|
|
"inputs":{"x":{"type":"inline","body":"input"}},
|
|
"model":{"extra_params":{"model":"collision"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != "invalid_request" {
|
|
t.Fatalf("expected invalid_request code, got %#v", errBody["code"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerPublicErrorMapping(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
code string
|
|
message string
|
|
avoidCause string
|
|
}{
|
|
{name: "prompt not found", err: promptkit.ErrPromptNotFound, status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
|
|
{name: "prompt load", err: wrap(promptkit.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"},
|
|
{name: "missing profile/default", err: wrap(promptkit.ErrProfileRequired, promptkit.ErrInvalidRequest), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
|
|
{name: "profile not found", err: promptkit.ErrProfileNotFound, status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
|
|
{name: "profile load", err: wrap(promptkit.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"},
|
|
{name: "api key env missing", err: wrap(promptkit.ErrAPIKeyEnvMissing, promptkit.ErrInvalidRequest), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
|
|
{name: "invalid request", err: promptkit.ErrInvalidRequest, status: http.StatusBadRequest, code: "invalid_request", message: "invalid run request"},
|
|
{name: "file denied", err: ErrFileNotAllowed, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
|
{name: "file outside root", err: ErrFileOutsideRoot, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
|
{name: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"},
|
|
{name: "artifact", err: wrap(promptkit.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
|
|
{name: "prompt render", err: wrap(promptkit.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
|
|
{name: "capacity", err: &promptkit.CapacityError{BackendID: "private-backend"}, status: http.StatusServiceUnavailable, code: "capacity_exceeded", message: "model backend capacity is exhausted", avoidCause: "private-backend"},
|
|
{name: "llm", err: wrap(promptkit.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
|
|
{name: "transport", err: wrap(promptkit.ErrTransport, fmt.Errorf("private transport detail")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "private transport detail"},
|
|
{name: "malformed response", err: wrap(promptkit.ErrMalformedResponse, fmt.Errorf("private response detail")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "private response detail"},
|
|
{name: "retry exhaustion", err: wrap(promptkit.ErrRetriesExhausted, fmt.Errorf("private provider detail")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "private provider detail"},
|
|
{name: "retry exhaustion value", err: fmt.Errorf("generation: %w", &promptkit.RetryExhaustedError{}), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed"},
|
|
{name: "validation runtime", err: wrap(promptkit.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{err: tc.err})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != tc.status {
|
|
t.Fatalf("expected %d, got %d body=%s", tc.status, w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != tc.code {
|
|
t.Fatalf("expected code %q, got %#v", tc.code, errBody["code"])
|
|
}
|
|
if errBody["message"] != tc.message {
|
|
t.Fatalf("expected message %q, got %#v", tc.message, errBody["message"])
|
|
}
|
|
if tc.avoidCause != "" && strings.Contains(w.Body.String(), tc.avoidCause) {
|
|
t.Fatalf("expected response to avoid leaking cause details, got %s", w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}},"model":{"model":"m","api_key":"secret"}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != "invalid_json" {
|
|
t.Fatalf("expected invalid_json code, got %#v", errBody["code"])
|
|
}
|
|
}
|
|
|
|
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
|
h := NewHandler(&fakeRunner{result: &promptkit.RunResult{
|
|
Artifact: promptkit.Artifact{Body: []byte("bad json")},
|
|
RawOutput: "bad json",
|
|
Validation: promptkit.ValidationResult{
|
|
Status: promptkit.ValidationFailed,
|
|
Mode: promptkit.ValidationJSON,
|
|
Errors: []string{"invalid JSON"},
|
|
},
|
|
}})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
validation := resp["validation"].(map[string]any)
|
|
if status, ok := validation["status"].(string); !ok || status != "failed" {
|
|
t.Fatalf("expected validation status=failed, got %#v", validation["status"])
|
|
}
|
|
if _, ok := resp["raw_model_output"]; ok {
|
|
t.Fatalf("expected raw_model_output omitted by default, got %#v", resp["raw_model_output"])
|
|
}
|
|
|
|
reqWithRaw := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}},"include_raw_output":true}`))
|
|
wWithRaw := httptest.NewRecorder()
|
|
h.ServeHTTP(wWithRaw, reqWithRaw)
|
|
|
|
if wWithRaw.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", wWithRaw.Code, wWithRaw.Body.String())
|
|
}
|
|
var respWithRaw map[string]any
|
|
if err := json.Unmarshal(wWithRaw.Body.Bytes(), &respWithRaw); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
if got, ok := respWithRaw["raw_model_output"].(string); !ok || got != "bad json" {
|
|
t.Fatalf("expected raw_model_output to be included when requested, got %#v", respWithRaw["raw_model_output"])
|
|
}
|
|
}
|
|
|
|
func wrap(stage error, cause error) error {
|
|
return fmt.Errorf("%w: %w", stage, cause)
|
|
}
|
|
|
|
func newArtifactRootHandler(t *testing.T, root string) *Handler {
|
|
t.Helper()
|
|
|
|
return newArtifactRootHandlerWithLimit(t, root, 0)
|
|
}
|
|
|
|
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
|
|
t.Helper()
|
|
|
|
reader, err := NewRestrictedArtifactReader(root, maxArtifactBytes)
|
|
if err != nil {
|
|
t.Fatalf("expected restricted artifact reader: %v", err)
|
|
}
|
|
return NewHandler(newHandlerEngine(t, promptkit.WithArtifactReader(reader)))
|
|
}
|
|
|
|
func newHandlerEngine(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
return newHandlerEngineWithOptions(t, append(options, promptkit.WithLLMClient(handlerLLMClient{}))...)
|
|
}
|
|
|
|
func newHandlerEngineWithDefaultClient(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
return newHandlerEngineWithOptions(t, options...)
|
|
}
|
|
|
|
func newHandlerEngineWithOptions(t *testing.T, options ...promptkit.Option) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
promptDir := t.TempDir()
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(`id: p
|
|
version: "1"
|
|
default_profile: exec
|
|
messages:
|
|
- role: user
|
|
content: "hi"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
repair_attempts: 0
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write prompt fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: exec
|
|
endpoint: http://example.invalid/v1
|
|
model: model
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write profile fixture: %v", err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: promptDir,
|
|
ProfileDir: profileDir,
|
|
}, options...)
|
|
if err != nil {
|
|
t.Fatalf("construct public engine: %v", err)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func newDefinitionHandler(t *testing.T, definition string) *Handler {
|
|
t.Helper()
|
|
|
|
promptDir := t.TempDir()
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(definition), 0o644); err != nil {
|
|
t.Fatalf("write prompt fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: exec
|
|
endpoint: http://example.invalid/v1
|
|
model: model
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write profile fixture: %v", err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: promptDir,
|
|
ProfileDir: profileDir,
|
|
}, promptkit.WithLLMClient(handlerLLMClient{}))
|
|
if err != nil {
|
|
t.Fatalf("construct public engine: %v", err)
|
|
}
|
|
return NewHandler(engine)
|
|
}
|
|
|
|
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
|
t.Helper()
|
|
|
|
if w.Code != status {
|
|
t.Fatalf("expected %d, got %d body=%s", status, w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
errBody := resp["error"].(map[string]any)
|
|
if errBody["code"] != code {
|
|
t.Fatalf("expected code %q, got %#v", code, errBody["code"])
|
|
}
|
|
}
|