Compare commits
4 Commits
ad115a2259
...
280916bf4a
| Author | SHA1 | Date | |
|---|---|---|---|
| 280916bf4a | |||
| 033bc93d3c | |||
| 45c2644b9d | |||
| a74c03bd9b |
39
artifact_reader.go
Normal file
39
artifact_reader.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package scriptorium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|
||||||
|
type publicArtifactReaderAdapter struct {
|
||||||
|
reader ArtifactReader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ artifactadapter.Reader = publicArtifactReaderAdapter{}
|
||||||
|
|
||||||
|
func (a publicArtifactReaderAdapter) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
artifact, err := a.reader.Read(ctx, ArtifactRef{
|
||||||
|
Type: ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if artifact == nil {
|
||||||
|
return nil, errNilArtifactReaderResponse
|
||||||
|
}
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
36
artifact_reader_internal_test.go
Normal file
36
artifact_reader_internal_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package scriptorium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
reader := internalArtifactReaderFake{
|
||||||
|
artifact: &Artifact{Body: []byte("original")},
|
||||||
|
}
|
||||||
|
adapter := publicArtifactReaderAdapter{reader: &reader}
|
||||||
|
|
||||||
|
artifact, err := adapter.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
URI: "memory://input",
|
||||||
|
Body: "input",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact: %v", err)
|
||||||
|
}
|
||||||
|
artifact.Body[0] = 'X'
|
||||||
|
if got := string(reader.artifact.Body); got != "original" {
|
||||||
|
t.Fatalf("reader artifact body was mutated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalArtifactReaderFake struct {
|
||||||
|
artifact *Artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *internalArtifactReaderFake) Read(context.Context, ArtifactRef) (*Artifact, error) {
|
||||||
|
return r.artifact, nil
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ fields:
|
|||||||
| `HTTPClient` | Optional HTTP client for that built-in client. It is cloned; a positive `Timeout` on it is the transport cap and takes precedence over `Config.Timeout`. A non-positive client timeout is treated as unset. |
|
| `HTTPClient` | Optional HTTP client for that built-in client. It is cloned; a positive `Timeout` on it is the transport cap and takes precedence over `Config.Timeout`. A non-positive client timeout is treated as unset. |
|
||||||
|
|
||||||
Nil options are ignored. Invalid construction, including
|
Nil options are ignored. Invalid construction, including
|
||||||
`WithLLMClient(nil)`, returns an error matching `ErrInvalidConfig`.
|
`WithLLMClient(nil)` and `WithArtifactReader(nil)`, returns an error matching
|
||||||
|
`ErrInvalidConfig`.
|
||||||
|
|
||||||
Profile and request `timeout_seconds` values select a per-generation-call
|
Profile and request `timeout_seconds` values select a per-generation-call
|
||||||
deadline independently of the transport cap. An explicit request override of
|
deadline independently of the transport cap. An explicit request override of
|
||||||
@@ -38,7 +39,8 @@ Source options replace their matching directory source:
|
|||||||
- profiles: `WithProfileFS(fsys, root)`, `WithProfileFile(path)`, and
|
- profiles: `WithProfileFS(fsys, root)`, `WithProfileFile(path)`, and
|
||||||
`WithProfiles(profiles...)`;
|
`WithProfiles(profiles...)`;
|
||||||
- schemas: `WithSchemaFS(fsys, root)`, `WithSchemaFile(path)`; and
|
- schemas: `WithSchemaFS(fsys, root)`, `WithSchemaFile(path)`; and
|
||||||
- LLM client: `WithLLMClient(client)`.
|
- LLM client: `WithLLMClient(client)`; and
|
||||||
|
- artifact reader: `WithArtifactReader(reader)`.
|
||||||
|
|
||||||
`fs.FS` prompt-content and schema paths stay inside their configured roots.
|
`fs.FS` prompt-content and schema paths stay inside their configured roots.
|
||||||
Single-file prompt and profile sources are selected by their YAML `id`, not
|
Single-file prompt and profile sources are selected by their YAML `id`, not
|
||||||
@@ -109,6 +111,14 @@ The exported constants define these serialized values:
|
|||||||
counts. `RenderedPrompt`, `RenderedMessage`, `CacheControl`, and
|
counts. `RenderedPrompt`, `RenderedMessage`, `CacheControl`, and
|
||||||
`StructuredOutputSpec` are the public shapes used by injected LLM clients.
|
`StructuredOutputSpec` are the public shapes used by injected LLM clients.
|
||||||
|
|
||||||
|
`ArtifactReader` implements
|
||||||
|
`Read(context.Context, ArtifactRef) (*Artifact, error)`. Supplying it through
|
||||||
|
`WithArtifactReader` replaces the engine's default inline and file reader for
|
||||||
|
all inputs. Reader errors remain available through `errors.Is` alongside
|
||||||
|
`ErrArtifactLoad`; a nil artifact with no error is treated as an artifact-load
|
||||||
|
failure. Readers must supply artifact metadata and should not retain or mutate
|
||||||
|
the caller's values.
|
||||||
|
|
||||||
## Requests, Inputs, And Overrides
|
## Requests, Inputs, And Overrides
|
||||||
|
|
||||||
`RunRequest` fields are `PromptID`, `PromptVersion`, `ProfileID`,
|
`RunRequest` fields are `PromptID`, `PromptVersion`, `ProfileID`,
|
||||||
@@ -172,8 +182,10 @@ Public methods preserve these sentinel checks through `errors.Is`:
|
|||||||
- `ErrInvalidRequest`
|
- `ErrInvalidRequest`
|
||||||
- `ErrPromptNotFound`
|
- `ErrPromptNotFound`
|
||||||
- `ErrProfileNotFound`
|
- `ErrProfileNotFound`
|
||||||
|
- `ErrProfileRequired`
|
||||||
- `ErrPromptLoad`
|
- `ErrPromptLoad`
|
||||||
- `ErrProfileLoad`
|
- `ErrProfileLoad`
|
||||||
|
- `ErrAPIKeyEnvMissing`
|
||||||
- `ErrArtifactLoad`
|
- `ErrArtifactLoad`
|
||||||
- `ErrPromptRender`
|
- `ErrPromptRender`
|
||||||
- `ErrLLMGenerate`
|
- `ErrLLMGenerate`
|
||||||
|
|||||||
@@ -52,8 +52,12 @@ failures are operational errors.
|
|||||||
|
|
||||||
`internal/artifact` composes inline and file readers. The ordinary composite
|
`internal/artifact` composes inline and file readers. The ordinary composite
|
||||||
reader used by CLI and the public engine reads file references from the process
|
reader used by CLI and the public engine reads file references from the process
|
||||||
filesystem. The restricted composite reader used by the HTTP adapter combines
|
filesystem. `internal/adapter/http` provides the restricted public artifact
|
||||||
inline reading with a rooted file reader and optional byte limit.
|
reader for HTTP containment: it combines inline reading with a rooted file
|
||||||
|
reader and optional byte limit. The existing internal restricted composite
|
||||||
|
reader remains a temporary bridge for the current handler and serve wiring; it
|
||||||
|
does not define the HTTP reader's long-term boundary or carry a compatibility
|
||||||
|
promise.
|
||||||
|
|
||||||
The rooted reader cleans paths and applies lexical containment without resolving
|
The rooted reader cleans paths and applies lexical containment without resolving
|
||||||
symlinks. It checks relative references against the configured root and accepts
|
symlinks. It checks relative references against the configured root and accepts
|
||||||
@@ -80,6 +84,7 @@ Inspect:
|
|||||||
- `internal/profile/repository_test.go`
|
- `internal/profile/repository_test.go`
|
||||||
- `internal/profile/builtin/repository_test.go`
|
- `internal/profile/builtin/repository_test.go`
|
||||||
- `internal/artifact/reader_test.go`
|
- `internal/artifact/reader_test.go`
|
||||||
|
- `internal/adapter/http/artifact_reader_test.go`
|
||||||
- `internal/validate/standard_validator_test.go`
|
- `internal/validate/standard_validator_test.go`
|
||||||
- `internal/usecase/integration_test.go`
|
- `internal/usecase/integration_test.go`
|
||||||
- `engine_test.go`
|
- `engine_test.go`
|
||||||
|
|||||||
23
engine.go
23
engine.go
@@ -29,8 +29,10 @@ var (
|
|||||||
ErrInvalidRequest = errors.New("invalid run request")
|
ErrInvalidRequest = errors.New("invalid run request")
|
||||||
ErrPromptNotFound = errors.New("prompt not found")
|
ErrPromptNotFound = errors.New("prompt not found")
|
||||||
ErrProfileNotFound = errors.New("profile not found")
|
ErrProfileNotFound = errors.New("profile not found")
|
||||||
|
ErrProfileRequired = errors.New("profile selection is required")
|
||||||
ErrPromptLoad = errors.New("failed to load prompt definition")
|
ErrPromptLoad = errors.New("failed to load prompt definition")
|
||||||
ErrProfileLoad = errors.New("failed to load execution profile")
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
||||||
|
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
||||||
ErrArtifactLoad = errors.New("failed to load artifact")
|
ErrArtifactLoad = errors.New("failed to load artifact")
|
||||||
ErrPromptRender = errors.New("failed to render prompt")
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
ErrLLMGenerate = errors.New("failed to generate output")
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
@@ -68,6 +70,7 @@ func (f optionFunc) apply(options *engineOptions) error {
|
|||||||
|
|
||||||
type engineOptions struct {
|
type engineOptions struct {
|
||||||
llmClient llm.Client
|
llmClient llm.Client
|
||||||
|
artifactReader artifactadapter.Reader
|
||||||
promptDefs promptdef.Repository
|
promptDefs promptdef.Repository
|
||||||
profiles profile.Repository
|
profiles profile.Repository
|
||||||
memoryProfiles profile.Repository
|
memoryProfiles profile.Repository
|
||||||
@@ -76,6 +79,7 @@ type engineOptions struct {
|
|||||||
profileSource bool
|
profileSource bool
|
||||||
memorySource bool
|
memorySource bool
|
||||||
validatorSource bool
|
validatorSource bool
|
||||||
|
artifactSource bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLLMClient injects a custom LLM client for execution.
|
// WithLLMClient injects a custom LLM client for execution.
|
||||||
@@ -89,6 +93,18 @@ func WithLLMClient(client LLMClient) Option {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithArtifactReader injects a reader for every input artifact reference.
|
||||||
|
func WithArtifactReader(reader ArtifactReader) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if reader == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
||||||
|
options.artifactSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// WithPromptFS loads prompt definitions from fsys under root.
|
// WithPromptFS loads prompt definitions from fsys under root.
|
||||||
//
|
//
|
||||||
// The source uses the same strict prompt YAML rules as configured prompt
|
// The source uses the same strict prompt YAML rules as configured prompt
|
||||||
@@ -253,11 +269,16 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artifacts := options.artifactReader
|
||||||
|
if !options.artifactSource {
|
||||||
|
artifacts = artifactadapter.NewCompositeReader()
|
||||||
|
}
|
||||||
|
|
||||||
return &Engine{
|
return &Engine{
|
||||||
runner: usecase.NewRunner(
|
runner: usecase.NewRunner(
|
||||||
promptDefs,
|
promptDefs,
|
||||||
profiles,
|
profiles,
|
||||||
artifactadapter.NewCompositeReader(),
|
artifacts,
|
||||||
prompt.NewGoRenderer(),
|
prompt.NewGoRenderer(),
|
||||||
llmClient,
|
llmClient,
|
||||||
validator,
|
validator,
|
||||||
|
|||||||
155
engine_test.go
155
engine_test.go
@@ -835,11 +835,132 @@ func TestMissingCredentialsFailClearlyWhenProfileRequiresAuth(t *testing.T) {
|
|||||||
if !errors.Is(err, scriptorium.ErrInvalidRequest) {
|
if !errors.Is(err, scriptorium.ErrInvalidRequest) {
|
||||||
t.Fatalf("expected invalid request for missing credentials, got %v", err)
|
t.Fatalf("expected invalid request for missing credentials, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, scriptorium.ErrAPIKeyEnvMissing) {
|
||||||
|
t.Fatalf("expected missing credential environment error, got %v", err)
|
||||||
|
}
|
||||||
if err == nil || !strings.Contains(err.Error(), missingEnv) {
|
if err == nil || !strings.Contains(err.Error(), missingEnv) {
|
||||||
t.Fatalf("expected missing env name in error, got %v", err)
|
t.Fatalf("expected missing env name in error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithArtifactReaderRejectsNilReader(t *testing.T) {
|
||||||
|
_, err := scriptorium.NewEngine(contractConfig(frameworkSchemaDir), scriptorium.WithArtifactReader(nil))
|
||||||
|
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactReaderReceivesPublicReferenceAndPreparesArtifact(t *testing.T) {
|
||||||
|
reader := &recordingArtifactReader{
|
||||||
|
artifact: &scriptorium.Artifact{
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Body: []byte("Reader-supplied transcript."),
|
||||||
|
URI: "reader://transcript",
|
||||||
|
Size: int64(len("Reader-supplied transcript.")),
|
||||||
|
Hash: "reader-transcript-hash",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
engine := newArtifactReaderEngine(t, reader)
|
||||||
|
|
||||||
|
ref := scriptorium.ArtifactRef{
|
||||||
|
Type: scriptorium.ArtifactRefInline,
|
||||||
|
URI: "reader://transcript",
|
||||||
|
Body: "request body",
|
||||||
|
}
|
||||||
|
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
||||||
|
PromptID: "artifact-reader",
|
||||||
|
Inputs: map[string]scriptorium.ArtifactRef{
|
||||||
|
"transcript": ref,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare with artifact reader: %v", err)
|
||||||
|
}
|
||||||
|
if len(reader.refs) != 1 || !reflect.DeepEqual(reader.refs[0], ref) {
|
||||||
|
t.Fatalf("reader received %#v, want %#v", reader.refs, ref)
|
||||||
|
}
|
||||||
|
if prepared.InputHashes["transcript"] != "reader-transcript-hash" {
|
||||||
|
t.Fatalf("unexpected input hash: %#v", prepared.InputHashes)
|
||||||
|
}
|
||||||
|
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "Reader-supplied transcript.") {
|
||||||
|
t.Fatalf("prepared prompt omitted reader artifact: %#v", prepared.Messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactReaderFailuresPreserveArtifactLoadErrors(t *testing.T) {
|
||||||
|
readerErr := errors.New("artifact reader failed")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
reader *recordingArtifactReader
|
||||||
|
wantNested error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "reader error",
|
||||||
|
ctx: context.Background(),
|
||||||
|
reader: &recordingArtifactReader{err: readerErr},
|
||||||
|
wantNested: readerErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil artifact",
|
||||||
|
ctx: context.Background(),
|
||||||
|
reader: &recordingArtifactReader{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reader cancellation",
|
||||||
|
ctx: context.Background(),
|
||||||
|
reader: &recordingArtifactReader{read: func(context.Context, scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}},
|
||||||
|
wantNested: context.Canceled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
engine := newArtifactReaderEngine(t, tc.reader)
|
||||||
|
_, err := engine.Prepare(tc.ctx, scriptorium.RunRequest{
|
||||||
|
PromptID: "artifact-reader",
|
||||||
|
Inputs: map[string]scriptorium.ArtifactRef{
|
||||||
|
"transcript": scriptorium.Inline("input"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, scriptorium.ErrArtifactLoad) {
|
||||||
|
t.Fatalf("expected ErrArtifactLoad, got %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantNested != nil && !errors.Is(err, tc.wantNested) {
|
||||||
|
t.Fatalf("expected nested %v, got %v", tc.wantNested, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareWithoutProfileMatchesSpecificPublicError(t *testing.T) {
|
||||||
|
promptDir := t.TempDir()
|
||||||
|
writePublicPromptFile(t, promptDir, "profile-required", "")
|
||||||
|
engine, err := scriptorium.NewEngine(scriptorium.Config{
|
||||||
|
PromptDir: promptDir,
|
||||||
|
SchemaDir: frameworkSchemaDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
|
||||||
|
PromptID: "profile-required",
|
||||||
|
Inputs: map[string]scriptorium.ArtifactRef{
|
||||||
|
"transcript": scriptorium.Inline("input"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, scriptorium.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, scriptorium.ErrProfileRequired) {
|
||||||
|
t.Fatalf("expected ErrProfileRequired, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunValidationFailureReturnsResult(t *testing.T) {
|
func TestRunValidationFailureReturnsResult(t *testing.T) {
|
||||||
fake := &fakeLLMClient{
|
fake := &fakeLLMClient{
|
||||||
response: &scriptorium.GenerateResponse{Content: ""},
|
response: &scriptorium.GenerateResponse{Content: ""},
|
||||||
@@ -2207,6 +2328,22 @@ func newContractEngineWithOptions(t *testing.T, schemaDir string, opts ...script
|
|||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newArtifactReaderEngine(t *testing.T, reader scriptorium.ArtifactReader) *scriptorium.Engine {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
promptDir := t.TempDir()
|
||||||
|
writePublicPromptFile(t, promptDir, "artifact-reader", frameworkFastProfileID)
|
||||||
|
engine, err := scriptorium.NewEngine(scriptorium.Config{
|
||||||
|
PromptDir: promptDir,
|
||||||
|
ProfileDir: frameworkProfileDir,
|
||||||
|
SchemaDir: frameworkSchemaDir,
|
||||||
|
}, scriptorium.WithArtifactReader(reader))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine with artifact reader: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
func contractConfig(schemaDir string) scriptorium.Config {
|
func contractConfig(schemaDir string) scriptorium.Config {
|
||||||
return scriptorium.Config{
|
return scriptorium.Config{
|
||||||
PromptDir: frameworkPromptDir,
|
PromptDir: frameworkPromptDir,
|
||||||
@@ -2363,6 +2500,24 @@ type fakeLLMClient struct {
|
|||||||
requests []scriptorium.GenerateRequest
|
requests []scriptorium.GenerateRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type recordingArtifactReader struct {
|
||||||
|
artifact *scriptorium.Artifact
|
||||||
|
err error
|
||||||
|
refs []scriptorium.ArtifactRef
|
||||||
|
read func(context.Context, scriptorium.ArtifactRef) (*scriptorium.Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingArtifactReader) Read(ctx context.Context, ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
|
||||||
|
r.refs = append(r.refs, ref)
|
||||||
|
if r.read != nil {
|
||||||
|
return r.read(ctx, ref)
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
return r.artifact, nil
|
||||||
|
}
|
||||||
|
|
||||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ func hasPublicError(err error) bool {
|
|||||||
ErrInvalidRequest,
|
ErrInvalidRequest,
|
||||||
ErrPromptNotFound,
|
ErrPromptNotFound,
|
||||||
ErrProfileNotFound,
|
ErrProfileNotFound,
|
||||||
|
ErrProfileRequired,
|
||||||
ErrPromptLoad,
|
ErrPromptLoad,
|
||||||
ErrProfileLoad,
|
ErrProfileLoad,
|
||||||
|
ErrAPIKeyEnvMissing,
|
||||||
ErrArtifactLoad,
|
ErrArtifactLoad,
|
||||||
ErrPromptRender,
|
ErrPromptRender,
|
||||||
ErrLLMGenerate,
|
ErrLLMGenerate,
|
||||||
@@ -49,6 +51,8 @@ func publicErrorFor(err error) error {
|
|||||||
return ErrPromptNotFound
|
return ErrPromptNotFound
|
||||||
case errors.Is(err, profile.ErrProfileNotFound):
|
case errors.Is(err, profile.ErrProfileNotFound):
|
||||||
return ErrProfileNotFound
|
return ErrProfileNotFound
|
||||||
|
case errors.Is(err, usecase.ErrProfileRequired):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrProfileRequired)
|
||||||
case errors.Is(err, usecase.ErrPromptLoad):
|
case errors.Is(err, usecase.ErrPromptLoad):
|
||||||
return ErrPromptLoad
|
return ErrPromptLoad
|
||||||
case errors.Is(err, usecase.ErrProfileLoad):
|
case errors.Is(err, usecase.ErrProfileLoad):
|
||||||
@@ -57,6 +61,8 @@ func publicErrorFor(err error) error {
|
|||||||
return ErrPromptLoad
|
return ErrPromptLoad
|
||||||
case isProfileLoadCause(err):
|
case isProfileLoadCause(err):
|
||||||
return ErrProfileLoad
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
||||||
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):
|
||||||
|
|||||||
@@ -12,18 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
|
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
|
||||||
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
|
||||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -140,15 +133,13 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
llmClient, err := newOpenAIClient()
|
engine, err := newEngine(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "llm client error: %v\n", err)
|
fmt.Fprintf(stderr, "engine error: %v\n", err)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient)
|
res, runErr := engine.Run(context.Background(), req)
|
||||||
|
|
||||||
res, runErr := runner.Run(context.Background(), req)
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
fmt.Fprintf(stderr, "run error: %v\n", runErr)
|
fmt.Fprintf(stderr, "run error: %v\n", runErr)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
@@ -176,9 +167,13 @@ func renderCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := newRunner(cfg.promptDir, cfg.profileDir, cfg.schemaDir, nil)
|
engine, err := newEngine(&cfg.runConfig)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "engine error: %v\n", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
|
||||||
prepared, prepErr := runner.Prepare(context.Background(), req)
|
prepared, prepErr := engine.Prepare(context.Background(), req)
|
||||||
if prepErr != nil {
|
if prepErr != nil {
|
||||||
fmt.Fprintf(stderr, "render error: %v\n", prepErr)
|
fmt.Fprintf(stderr, "render error: %v\n", prepErr)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
@@ -204,21 +199,23 @@ func serveCommand(args []string, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
llmClient, err := newOpenAIClient()
|
artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes)
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(stderr, "llm client error: %v\n", err)
|
|
||||||
return ExitRuntimeError
|
|
||||||
}
|
|
||||||
|
|
||||||
artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
|
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
|
engine, err := newEngine(&runConfig{
|
||||||
|
promptDir: cfg.promptDir,
|
||||||
|
profileDir: cfg.profileDir,
|
||||||
|
schemaDir: cfg.schemaDir,
|
||||||
|
}, scriptorium.WithArtifactReader(artifactReader))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "engine error: %v\n", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
|
||||||
h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{
|
h := httpadapter.NewHandlerWithOptions(engine, httpadapter.HandlerOptions{
|
||||||
MaxRequestBytes: cfg.maxRequestBytes,
|
MaxRequestBytes: cfg.maxRequestBytes,
|
||||||
MaxResponseBytes: cfg.maxResponseBytes,
|
MaxResponseBytes: cfg.maxResponseBytes,
|
||||||
})
|
})
|
||||||
@@ -541,52 +538,36 @@ func validateRequiredLibraryDirs(promptDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner {
|
func newEngine(cfg *runConfig, options ...scriptorium.Option) (*scriptorium.Engine, error) {
|
||||||
return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader())
|
return scriptorium.NewEngine(scriptorium.Config{
|
||||||
|
PromptDir: cfg.promptDir,
|
||||||
|
ProfileDir: cfg.profileDir,
|
||||||
|
SchemaDir: cfg.schemaDir,
|
||||||
|
}, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner {
|
func buildRunRequestFromConfig(cfg *runConfig) (scriptorium.RunRequest, error) {
|
||||||
if artifactReader == nil {
|
|
||||||
artifactReader = artifactadapter.NewCompositeReader()
|
|
||||||
}
|
|
||||||
return usecase.NewRunner(
|
|
||||||
promptdef.NewFilesystemRepository(promptDir),
|
|
||||||
builtin.NewRepositoryWithDirectory(profileDir),
|
|
||||||
artifactReader,
|
|
||||||
prompt.NewGoRenderer(),
|
|
||||||
llmClient,
|
|
||||||
validate.NewStandardValidator(schemaDir),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newOpenAIClient() (*llm.OpenAICompatibleClient, error) {
|
|
||||||
return llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
|
||||||
Timeout: defaults.LLMRequestTimeoutDefault,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
|
|
||||||
inputMappings, err := parseMappings(cfg.inputRaw, false)
|
inputMappings, err := parseMappings(cfg.inputRaw, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunRequest{}, fmt.Errorf("input parse error: %w", err)
|
return scriptorium.RunRequest{}, fmt.Errorf("input parse error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
varMappings := map[string]string{}
|
varMappings := map[string]string{}
|
||||||
if len(cfg.varRaw) > 0 {
|
if len(cfg.varRaw) > 0 {
|
||||||
varMappings, err = parseMappings(cfg.varRaw, false)
|
varMappings, err = parseMappings(cfg.varRaw, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunRequest{}, fmt.Errorf("var parse error: %w", err)
|
return scriptorium.RunRequest{}, fmt.Errorf("var parse error: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs := make(map[string]domain.ArtifactRef, len(inputMappings))
|
inputs := make(map[string]scriptorium.ArtifactRef, len(inputMappings))
|
||||||
for name, path := range inputMappings {
|
for name, path := range inputMappings {
|
||||||
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
|
inputs[name] = scriptorium.File(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelOverride *domain.ExecutionTargetOverride
|
var modelOverride *scriptorium.ExecutionTargetOverride
|
||||||
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
|
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
|
||||||
modelOverride = &domain.ExecutionTargetOverride{
|
modelOverride = &scriptorium.ExecutionTargetOverride{
|
||||||
Endpoint: cfg.llmBaseURL,
|
Endpoint: cfg.llmBaseURL,
|
||||||
Model: cfg.model,
|
Model: cfg.model,
|
||||||
APIKeyEnv: cfg.apiKeyEnv,
|
APIKeyEnv: cfg.apiKeyEnv,
|
||||||
@@ -606,7 +587,7 @@ func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.RunRequest{
|
return scriptorium.RunRequest{
|
||||||
PromptID: cfg.promptID,
|
PromptID: cfg.promptID,
|
||||||
ProfileID: cfg.profileID,
|
ProfileID: cfg.profileID,
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
@@ -670,17 +651,17 @@ func writeOutput(stdout io.Writer, outputPath string, body []byte) error {
|
|||||||
return os.WriteFile(outputPath, body, 0644)
|
return os.WriteFile(outputPath, body, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func determineExitCode(runErr error, result *domain.RunResult) int {
|
func determineExitCode(runErr error, result *scriptorium.RunResult) int {
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
if result != nil && result.Validation.Status == domain.ValidationFailed {
|
if result != nil && result.Validation.Status == scriptorium.ValidationFailed {
|
||||||
return ExitValidationFailed
|
return ExitValidationFailed
|
||||||
}
|
}
|
||||||
return ExitOK
|
return ExitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
func printSummary(stderr io.Writer, res *domain.RunResult) {
|
func printSummary(stderr io.Writer, res *scriptorium.RunResult) {
|
||||||
if res == nil {
|
if res == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -655,6 +655,40 @@ func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildRunRequestPreservesNumericOverridePresence(t *testing.T) {
|
||||||
|
omitted, err := buildRunRequestFromConfig(&runConfig{
|
||||||
|
promptID: "prompt-1",
|
||||||
|
inputRaw: []string{"transcript=./transcript.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected omitted override request to build, got %v", err)
|
||||||
|
}
|
||||||
|
if omitted.Execution != nil {
|
||||||
|
t.Fatalf("expected omitted numeric flags to leave execution override nil, got %#v", omitted.Execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitZeros, err := buildRunRequestFromConfig(&runConfig{
|
||||||
|
promptID: "prompt-1",
|
||||||
|
inputRaw: []string{"transcript=./transcript.md"},
|
||||||
|
temperatureSet: true,
|
||||||
|
maxTokensSet: true,
|
||||||
|
topPSet: true,
|
||||||
|
timeoutSet: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected explicit zero override request to build, got %v", err)
|
||||||
|
}
|
||||||
|
if explicitZeros.Execution == nil {
|
||||||
|
t.Fatal("expected explicit numeric flags to create execution override")
|
||||||
|
}
|
||||||
|
if explicitZeros.Execution.Temperature == nil || explicitZeros.Execution.MaxTokens == nil || explicitZeros.Execution.TopP == nil || explicitZeros.Execution.TimeoutSeconds == nil {
|
||||||
|
t.Fatalf("expected explicit zero numeric overrides to remain non-nil, got %#v", explicitZeros.Execution)
|
||||||
|
}
|
||||||
|
if *explicitZeros.Execution.Temperature != 0 || *explicitZeros.Execution.MaxTokens != 0 || *explicitZeros.Execution.TopP != 0 || *explicitZeros.Execution.TimeoutSeconds != 0 {
|
||||||
|
t.Fatalf("expected explicit numeric overrides to retain zero values, got %#v", explicitZeros.Execution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
|
func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
|
||||||
configPath := writeAppConfigFile(t, `
|
configPath := writeAppConfigFile(t, `
|
||||||
profile_dir: ./profiles
|
profile_dir: ./profiles
|
||||||
@@ -731,13 +765,13 @@ func TestDetermineExitCode(t *testing.T) {
|
|||||||
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
|
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
|
||||||
t.Fatalf("expected runtime exit code, got %d", got)
|
t.Fatalf("expected runtime exit code, got %d", got)
|
||||||
}
|
}
|
||||||
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationFailed}}); got != ExitValidationFailed {
|
if got := determineExitCode(nil, &scriptorium.RunResult{Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationFailed}}); got != ExitValidationFailed {
|
||||||
t.Fatalf("expected validation exit code, got %d", got)
|
t.Fatalf("expected validation exit code, got %d", got)
|
||||||
}
|
}
|
||||||
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationPassed}}); got != ExitOK {
|
if got := determineExitCode(nil, &scriptorium.RunResult{Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed}}); got != ExitOK {
|
||||||
t.Fatalf("expected success exit code for passed validation, got %d", got)
|
t.Fatalf("expected success exit code for passed validation, got %d", got)
|
||||||
}
|
}
|
||||||
if got := determineExitCode(nil, &domain.RunResult{Validation: domain.ValidationResult{Status: domain.ValidationSkipped}}); got != ExitOK {
|
if got := determineExitCode(nil, &scriptorium.RunResult{Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationSkipped}}); got != ExitOK {
|
||||||
t.Fatalf("expected success exit code for skipped validation, got %d", got)
|
t.Fatalf("expected success exit code for skipped validation, got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1208,12 +1242,12 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
|
|||||||
if err := writeOutput(&stdout, "", []byte("artifact-body")); err != nil {
|
if err := writeOutput(&stdout, "", []byte("artifact-body")); err != nil {
|
||||||
t.Fatalf("unexpected writeOutput error: %v", err)
|
t.Fatalf("unexpected writeOutput error: %v", err)
|
||||||
}
|
}
|
||||||
printSummary(&stderr, &domain.RunResult{
|
printSummary(&stderr, &scriptorium.RunResult{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
PromptVersion: "1",
|
PromptVersion: "1",
|
||||||
SelectedProfileID: "exec",
|
SelectedProfileID: "exec",
|
||||||
ModelName: "m",
|
ModelName: "m",
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic},
|
||||||
RenderedPromptHash: "h",
|
RenderedPromptHash: "h",
|
||||||
InputHashes: map[string]string{"in": "x"},
|
InputHashes: map[string]string{"in": "x"},
|
||||||
})
|
})
|
||||||
@@ -1232,15 +1266,15 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
|
|||||||
func TestPrintSummaryIncludesCacheUsageWhenPresent(t *testing.T) {
|
func TestPrintSummaryIncludesCacheUsageWhenPresent(t *testing.T) {
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
printSummary(&stderr, &domain.RunResult{
|
printSummary(&stderr, &scriptorium.RunResult{
|
||||||
PromptID: "p",
|
PromptID: "p",
|
||||||
PromptVersion: "1",
|
PromptVersion: "1",
|
||||||
SelectedProfileID: "exec",
|
SelectedProfileID: "exec",
|
||||||
ModelName: "m",
|
ModelName: "m",
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic},
|
||||||
RenderedPromptHash: "h",
|
RenderedPromptHash: "h",
|
||||||
InputHashes: map[string]string{"in": "x"},
|
InputHashes: map[string]string{"in": "x"},
|
||||||
Usage: domain.TokenUsage{
|
Usage: scriptorium.TokenUsage{
|
||||||
PromptTokens: 10,
|
PromptTokens: 10,
|
||||||
CompletionTokens: 5,
|
CompletionTokens: 5,
|
||||||
TotalTokens: 15,
|
TotalTokens: 15,
|
||||||
|
|||||||
165
internal/adapter/http/artifact_reader.go
Normal file
165
internal/adapter/http/artifact_reader.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package httpadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
|
||||||
|
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
|
||||||
|
ErrFileTooLarge = errors.New("file artifact exceeds size limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
const fallbackArtifactContentType = "text/plain"
|
||||||
|
|
||||||
|
// NewRestrictedArtifactReader creates the HTTP artifact reader for a rooted
|
||||||
|
// filesystem and optional byte limit. An empty root permits inline artifacts
|
||||||
|
// but denies file references; a zero limit permits artifacts of any size.
|
||||||
|
func NewRestrictedArtifactReader(root string, maxBytes int64) (scriptorium.ArtifactReader, error) {
|
||||||
|
if maxBytes < 0 {
|
||||||
|
return nil, fmt.Errorf("artifact size limit must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanRoot := strings.TrimSpace(root)
|
||||||
|
if cleanRoot == "" {
|
||||||
|
return &restrictedArtifactReader{maxBytes: maxBytes}, nil
|
||||||
|
}
|
||||||
|
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
||||||
|
}
|
||||||
|
return &restrictedArtifactReader{root: absRoot, maxBytes: maxBytes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type restrictedArtifactReader struct {
|
||||||
|
root string
|
||||||
|
maxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ scriptorium.ArtifactReader = (*restrictedArtifactReader)(nil)
|
||||||
|
|
||||||
|
func (r *restrictedArtifactReader) Read(ctx context.Context, ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ref.Type {
|
||||||
|
case scriptorium.ArtifactRefInline:
|
||||||
|
return readInlineArtifact(ref)
|
||||||
|
case scriptorium.ArtifactRefFile:
|
||||||
|
return r.readFileArtifact(ref)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported artifact reference type %q", ref.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInlineArtifact(ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
|
||||||
|
if ref.Body == "" {
|
||||||
|
return nil, errors.New("inline artifact body is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := []byte(ref.Body)
|
||||||
|
return &scriptorium.Artifact{
|
||||||
|
ContentType: fallbackArtifactContentType,
|
||||||
|
Body: body,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
Hash: artifactHash(body),
|
||||||
|
URI: ref.URI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *restrictedArtifactReader) readFileArtifact(ref scriptorium.ArtifactRef) (*scriptorium.Artifact, error) {
|
||||||
|
if ref.URI == "" {
|
||||||
|
return nil, errors.New("file artifact path is required")
|
||||||
|
}
|
||||||
|
if r.root == "" {
|
||||||
|
return nil, ErrFileNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := r.resolveLexicalPath(ref.URI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readArtifactFile(path, r.maxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
|
||||||
|
func (r *restrictedArtifactReader) resolveLexicalPath(rawPath string) (string, error) {
|
||||||
|
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
|
||||||
|
candidate := cleanPath
|
||||||
|
if !filepath.IsAbs(cleanPath) {
|
||||||
|
candidate = filepath.Join(r.root, cleanPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
absCandidate, err := filepath.Abs(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve artifact path: %w", err)
|
||||||
|
}
|
||||||
|
absCandidate = filepath.Clean(absCandidate)
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(r.root, absCandidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("compare artifact path to root: %w", err)
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||||
|
return "", ErrFileOutsideRoot
|
||||||
|
}
|
||||||
|
return absCandidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArtifactFile(path string, maxBytes int64) (*scriptorium.Artifact, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if maxBytes > 0 && info.Size() > maxBytes {
|
||||||
|
return nil, ErrFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader io.Reader = file
|
||||||
|
if maxBytes > 0 {
|
||||||
|
reader = io.LimitReader(file, maxBytes+1)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if maxBytes > 0 && int64(len(body)) > maxBytes {
|
||||||
|
return nil, ErrFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = fallbackArtifactContentType
|
||||||
|
}
|
||||||
|
return &scriptorium.Artifact{
|
||||||
|
Name: filepath.Base(path),
|
||||||
|
ContentType: contentType,
|
||||||
|
Body: body,
|
||||||
|
URI: path,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
Hash: artifactHash(body),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func artifactHash(body []byte) string {
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256(body))
|
||||||
|
}
|
||||||
181
internal/adapter/http/artifact_reader_test.go
Normal file
181
internal/adapter/http/artifact_reader_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package httpadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestrictedArtifactReaderReadsContainedFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
inputPath := filepath.Join(root, "input.md")
|
||||||
|
if err := os.WriteFile(inputPath, []byte("allowed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "input.unknown"), []byte("unknown type"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := NewRestrictedArtifactReader(root, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct restricted reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range []scriptorium.ArtifactRef{
|
||||||
|
{Type: scriptorium.ArtifactRefFile, URI: "nested/../input.md"},
|
||||||
|
{Type: scriptorium.ArtifactRefFile, URI: inputPath},
|
||||||
|
} {
|
||||||
|
artifact, err := reader.Read(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read contained path %q: %v", ref.URI, err)
|
||||||
|
}
|
||||||
|
if artifact.Name != "input.md" || artifact.URI != inputPath || artifact.Size != int64(len("allowed")) || string(artifact.Body) != "allowed" {
|
||||||
|
t.Fatalf("unexpected artifact metadata: %#v", artifact)
|
||||||
|
}
|
||||||
|
if artifact.ContentType != mime.TypeByExtension(".md") {
|
||||||
|
t.Fatalf("unexpected artifact content type: %q", artifact.ContentType)
|
||||||
|
}
|
||||||
|
if artifact.Hash != artifactHash([]byte("allowed")) {
|
||||||
|
t.Fatalf("unexpected artifact hash: %q", artifact.Hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact, err := reader.Read(context.Background(), scriptorium.File("input.unknown"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read unknown-extension path: %v", err)
|
||||||
|
}
|
||||||
|
if artifact.ContentType != fallbackArtifactContentType {
|
||||||
|
t.Fatalf("unexpected fallback content type: %q", artifact.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range []scriptorium.ArtifactRef{
|
||||||
|
{Type: scriptorium.ArtifactRefFile, URI: filepath.Join("..", filepath.Base(outside), "secret.txt")},
|
||||||
|
{Type: scriptorium.ArtifactRefFile, URI: filepath.Join(outside, "secret.txt")},
|
||||||
|
} {
|
||||||
|
_, err := reader.Read(context.Background(), ref)
|
||||||
|
if !errors.Is(err, ErrFileOutsideRoot) {
|
||||||
|
t.Fatalf("expected ErrFileOutsideRoot for %q, got %v", ref.URI, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedArtifactReaderFollowsSymlinkAfterLexicalCheck(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
target := filepath.Join(outside, "linked.txt")
|
||||||
|
if err := os.WriteFile(target, []byte("linked outside root"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, "linked.txt")); err != nil {
|
||||||
|
t.Skipf("symlink creation unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := NewRestrictedArtifactReader(root, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct restricted reader: %v", err)
|
||||||
|
}
|
||||||
|
artifact, err := reader.Read(context.Background(), scriptorium.File("linked.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read symlink inside root: %v", err)
|
||||||
|
}
|
||||||
|
if string(artifact.Body) != "linked outside root" {
|
||||||
|
t.Fatalf("unexpected symlink artifact body: %q", artifact.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedArtifactReaderWithoutRootDeniesFiles(t *testing.T) {
|
||||||
|
reader, err := NewRestrictedArtifactReader("", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct rootless reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact, err := reader.Read(context.Background(), scriptorium.Inline("inline"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read inline artifact: %v", err)
|
||||||
|
}
|
||||||
|
if artifact.ContentType != fallbackArtifactContentType || string(artifact.Body) != "inline" || artifact.Hash != artifactHash([]byte("inline")) {
|
||||||
|
t.Fatalf("unexpected inline artifact: %#v", artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = reader.Read(context.Background(), scriptorium.File("input.txt"))
|
||||||
|
if !errors.Is(err, ErrFileNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrFileNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedArtifactReaderEnforcesLimits(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "exact.txt"), []byte("12345"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := NewRestrictedArtifactReader(root, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct limited reader: %v", err)
|
||||||
|
}
|
||||||
|
artifact, err := reader.Read(context.Background(), scriptorium.File("exact.txt"))
|
||||||
|
if err != nil || string(artifact.Body) != "12345" {
|
||||||
|
t.Fatalf("expected exact-limit artifact, got %#v and %v", artifact, err)
|
||||||
|
}
|
||||||
|
_, err = reader.Read(context.Background(), scriptorium.File("large.txt"))
|
||||||
|
if !errors.Is(err, ErrFileTooLarge) {
|
||||||
|
t.Fatalf("expected ErrFileTooLarge, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlimited, err := NewRestrictedArtifactReader(root, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct unlimited reader: %v", err)
|
||||||
|
}
|
||||||
|
artifact, err = unlimited.Read(context.Background(), scriptorium.File("large.txt"))
|
||||||
|
if err != nil || string(artifact.Body) != "123456" {
|
||||||
|
t.Fatalf("expected unlimited artifact, got %#v and %v", artifact, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := NewRestrictedArtifactReader(root, -1); err == nil {
|
||||||
|
t.Fatal("expected negative limit to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestrictedArtifactReaderRejectsCanceledAndMalformedReferences(t *testing.T) {
|
||||||
|
reader, err := NewRestrictedArtifactReader(t.TempDir(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
for _, ref := range []scriptorium.ArtifactRef{
|
||||||
|
scriptorium.Inline("input"),
|
||||||
|
scriptorium.File("input.txt"),
|
||||||
|
} {
|
||||||
|
_, err := reader.Read(canceledCtx, ref)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected cancellation for %#v, got %v", ref, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range []scriptorium.ArtifactRef{
|
||||||
|
{Type: scriptorium.ArtifactRefType("unsupported")},
|
||||||
|
{Type: scriptorium.ArtifactRefInline},
|
||||||
|
{Type: scriptorium.ArtifactRefFile},
|
||||||
|
} {
|
||||||
|
if _, err := reader.Read(context.Background(), ref); err == nil {
|
||||||
|
t.Fatalf("expected malformed reference %#v to fail", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error)
|
Run(ctx context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -85,21 +81,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mappedInputs := make(map[string]domain.ArtifactRef, len(req.Inputs))
|
mappedInputs := make(map[string]scriptorium.ArtifactRef, len(req.Inputs))
|
||||||
for name, in := range req.Inputs {
|
for name, in := range req.Inputs {
|
||||||
mappedInputs[name] = domain.ArtifactRef{
|
mappedInputs[name] = scriptorium.ArtifactRef{
|
||||||
Type: domain.ArtifactRefType(in.Type),
|
Type: scriptorium.ArtifactRefType(in.Type),
|
||||||
URI: in.URI,
|
URI: in.URI,
|
||||||
Body: in.Body,
|
Body: in.Body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var model *domain.ExecutionTargetOverride
|
var model *scriptorium.ExecutionTargetOverride
|
||||||
if req.Model != nil {
|
if req.Model != nil {
|
||||||
model = executionTargetOverrideFromModelOverrideDTO(req.Model)
|
model = executionTargetOverrideFromModelOverrideDTO(req.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := h.runner.Run(r.Context(), domain.RunRequest{
|
res, err := h.runner.Run(r.Context(), scriptorium.RunRequest{
|
||||||
PromptID: req.PromptID,
|
PromptID: req.PromptID,
|
||||||
PromptVersion: req.PromptVersion,
|
PromptVersion: req.PromptVersion,
|
||||||
ProfileID: req.ProfileID,
|
ProfileID: req.ProfileID,
|
||||||
@@ -156,11 +152,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
|
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
|
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *scriptorium.ExecutionTargetOverride {
|
||||||
if dto == nil {
|
if dto == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &domain.ExecutionTargetOverride{
|
return &scriptorium.ExecutionTargetOverride{
|
||||||
Endpoint: dto.Endpoint,
|
Endpoint: dto.Endpoint,
|
||||||
Model: dto.Model,
|
Model: dto.Model,
|
||||||
Temperature: dto.Temperature,
|
Temperature: dto.Temperature,
|
||||||
@@ -174,7 +170,7 @@ func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParamsDTO {
|
func modelParamsDTOFromExecutionTarget(target scriptorium.ExecutionTarget) modelParamsDTO {
|
||||||
return modelParamsDTO{
|
return modelParamsDTO{
|
||||||
Endpoint: target.Endpoint,
|
Endpoint: target.Endpoint,
|
||||||
Model: target.Model,
|
Model: target.Model,
|
||||||
@@ -189,7 +185,7 @@ func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapValidation(v domain.ValidationResult) validationDTO {
|
func mapValidation(v scriptorium.ValidationResult) validationDTO {
|
||||||
return validationDTO{
|
return validationDTO{
|
||||||
Status: string(v.Status),
|
Status: string(v.Status),
|
||||||
Mode: string(v.Mode),
|
Mode: string(v.Mode),
|
||||||
@@ -202,35 +198,31 @@ func mapValidation(v domain.ValidationResult) validationDTO {
|
|||||||
|
|
||||||
func mapRunError(err error) (int, string, string) {
|
func mapRunError(err error) (int, string, string) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
case errors.Is(err, scriptorium.ErrPromptNotFound):
|
||||||
return http.StatusNotFound, "prompt_not_found", "prompt definition not found"
|
return http.StatusNotFound, "prompt_not_found", "prompt definition not found"
|
||||||
case errors.Is(err, profile.ErrProfileNotFound):
|
case errors.Is(err, scriptorium.ErrProfileNotFound):
|
||||||
return http.StatusNotFound, "profile_not_found", "execution profile not found"
|
return http.StatusNotFound, "profile_not_found", "execution profile not found"
|
||||||
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
|
case errors.Is(err, scriptorium.ErrProfileRequired):
|
||||||
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
|
||||||
case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile), errors.Is(err, profile.ErrRawAPIKeyNotAllowed):
|
|
||||||
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
|
|
||||||
case errors.Is(err, usecase.ErrProfileRequired):
|
|
||||||
return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
|
return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
|
||||||
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
case errors.Is(err, scriptorium.ErrAPIKeyEnvMissing):
|
||||||
return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing"
|
return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing"
|
||||||
case errors.Is(err, usecase.ErrInvalidRequest):
|
case errors.Is(err, scriptorium.ErrPromptLoad):
|
||||||
return http.StatusBadRequest, "invalid_request", "invalid run request"
|
|
||||||
case errors.Is(err, usecase.ErrPromptLoad):
|
|
||||||
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
||||||
case errors.Is(err, usecase.ErrProfileLoad):
|
case errors.Is(err, scriptorium.ErrProfileLoad):
|
||||||
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
|
return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile"
|
||||||
case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot):
|
case errors.Is(err, scriptorium.ErrInvalidRequest):
|
||||||
|
return http.StatusBadRequest, "invalid_request", "invalid run request"
|
||||||
|
case errors.Is(err, ErrFileNotAllowed), errors.Is(err, ErrFileOutsideRoot):
|
||||||
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
|
return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed"
|
||||||
case errors.Is(err, artifact.ErrFileTooLarge):
|
case errors.Is(err, ErrFileTooLarge):
|
||||||
return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large"
|
return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large"
|
||||||
case errors.Is(err, usecase.ErrArtifactLoad):
|
case errors.Is(err, scriptorium.ErrArtifactLoad):
|
||||||
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
|
return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact"
|
||||||
case errors.Is(err, usecase.ErrPromptRender):
|
case errors.Is(err, scriptorium.ErrPromptRender):
|
||||||
return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt"
|
return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt"
|
||||||
case errors.Is(err, usecase.ErrLLMGenerate):
|
case errors.Is(err, scriptorium.ErrLLMGenerate):
|
||||||
return http.StatusBadGateway, "llm_failed", "model generation request failed"
|
return http.StatusBadGateway, "llm_failed", "model generation request failed"
|
||||||
case errors.Is(err, usecase.ErrValidation):
|
case errors.Is(err, scriptorium.ErrValidation):
|
||||||
return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed"
|
return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed"
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError, "internal_error", "internal server error"
|
return http.StatusInternalServerError, "internal_error", "internal server error"
|
||||||
|
|||||||
@@ -14,21 +14,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRunner struct {
|
type fakeRunner struct {
|
||||||
result *domain.RunResult
|
result *scriptorium.RunResult
|
||||||
err error
|
err error
|
||||||
last domain.RunRequest
|
last scriptorium.RunRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) {
|
func (f *fakeRunner) Run(ctx context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
|
||||||
f.last = req
|
f.last = req
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
@@ -42,7 +37,7 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
|
|||||||
t.Fatalf("read maintained HTTP request example: %v", err)
|
t.Fatalf("read maintained HTTP request example: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeRunner{result: &domain.RunResult{}}
|
runner := &fakeRunner{result: &scriptorium.RunResult{}}
|
||||||
h := NewHandler(runner)
|
h := NewHandler(runner)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -68,38 +63,10 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
|
|||||||
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
|
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
|
||||||
}
|
}
|
||||||
|
|
||||||
type handlerPromptRepo struct {
|
|
||||||
def *domain.PromptDefinition
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r handlerPromptRepo) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
|
||||||
return r.def, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerProfileRepo struct {
|
|
||||||
profile *domain.ExecutionProfile
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r handlerProfileRepo) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
|
||||||
return r.profile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerArtifactReader struct{}
|
|
||||||
|
|
||||||
func (handlerArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
||||||
return &domain.Artifact{Name: "input", Body: []byte("input"), Hash: "hash"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerRenderer struct{}
|
|
||||||
|
|
||||||
func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
|
||||||
return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerLLMClient struct{}
|
type handlerLLMClient struct{}
|
||||||
|
|
||||||
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
func (handlerLLMClient) Generate(ctx context.Context, req scriptorium.GenerateRequest) (*scriptorium.GenerateResponse, error) {
|
||||||
return &domain.GenerateResponse{Content: "ok"}, nil
|
return &scriptorium.GenerateResponse{Content: "ok"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
||||||
@@ -108,16 +75,16 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
const envName = "SCRIPTORIUM_API_KEY"
|
const envName = "SCRIPTORIUM_API_KEY"
|
||||||
const secret = "never-include-me"
|
const secret = "never-include-me"
|
||||||
|
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
RunID: "11111111-1111-4111-8111-111111111111",
|
RunID: "11111111-1111-4111-8111-111111111111",
|
||||||
Artifact: domain.Artifact{
|
Artifact: scriptorium.Artifact{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Body: []byte("hello"),
|
Body: []byte("hello"),
|
||||||
Size: 5,
|
Size: 5,
|
||||||
Hash: "abc",
|
Hash: "abc",
|
||||||
},
|
},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
PromptID: "prompt-1",
|
PromptID: "prompt-1",
|
||||||
PromptVersion: "1.0.0",
|
PromptVersion: "1.0.0",
|
||||||
PromptHash: "phash",
|
PromptHash: "phash",
|
||||||
@@ -125,7 +92,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
SelectedProfileID: "exec-default",
|
SelectedProfileID: "exec-default",
|
||||||
ModelName: "m1",
|
ModelName: "m1",
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
EffectiveModelParams: domain.ExecutionTarget{
|
EffectiveModelParams: scriptorium.ExecutionTarget{
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
Model: "m1",
|
Model: "m1",
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
@@ -136,7 +103,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
APIKeyEnv: envName,
|
APIKeyEnv: envName,
|
||||||
},
|
},
|
||||||
InputHashes: map[string]string{"transcript": "h1"},
|
InputHashes: map[string]string{"transcript": "h1"},
|
||||||
Usage: domain.TokenUsage{
|
Usage: scriptorium.TokenUsage{
|
||||||
PromptTokens: 1,
|
PromptTokens: 1,
|
||||||
CompletionTokens: 2,
|
CompletionTokens: 2,
|
||||||
TotalTokens: 3,
|
TotalTokens: 3,
|
||||||
@@ -325,13 +292,13 @@ func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
PromptID: "prompt-1",
|
PromptID: "prompt-1",
|
||||||
PromptVersion: "1.0.0",
|
PromptVersion: "1.0.0",
|
||||||
SelectedProfileID: "prompt-default",
|
SelectedProfileID: "prompt-default",
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -360,10 +327,10 @@ func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -420,10 +387,10 @@ func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -464,10 +431,10 @@ func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -491,10 +458,10 @@ func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -528,16 +495,16 @@ func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{
|
Artifact: scriptorium.Artifact{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Body: []byte("ok"),
|
Body: []byte("ok"),
|
||||||
Size: 2,
|
Size: 2,
|
||||||
Hash: "abc",
|
Hash: "abc",
|
||||||
},
|
},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{
|
EffectiveModelParams: scriptorium.ExecutionTarget{
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
Model: "gpt-test",
|
Model: "gpt-test",
|
||||||
Temperature: 0.4,
|
Temperature: 0.4,
|
||||||
@@ -657,10 +624,10 @@ func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerResponseTooLarge(t *testing.T) {
|
func TestHandlerResponseTooLarge(t *testing.T) {
|
||||||
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
Artifact: scriptorium.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -671,11 +638,11 @@ func TestHandlerResponseTooLarge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
||||||
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
RawOutput: strings.Repeat("raw", 80),
|
RawOutput: strings.Repeat("raw", 80),
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
"prompt_id":"p",
|
"prompt_id":"p",
|
||||||
@@ -709,35 +676,12 @@ func TestHandlerMissingPromptID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.T) {
|
func TestHandlerReservedExtraParamsThroughEngineMapsToInvalidRequest(t *testing.T) {
|
||||||
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
h := NewHandler(newHandlerEngineWithDefaultClient(t))
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runner := usecase.NewRunner(
|
|
||||||
handlerPromptRepo{def: &domain.PromptDefinition{
|
|
||||||
ID: "p",
|
|
||||||
Version: "1",
|
|
||||||
DefaultProfile: "exec",
|
|
||||||
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
|
|
||||||
OutputFormat: domain.FormatText,
|
|
||||||
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
|
|
||||||
}},
|
|
||||||
handlerProfileRepo{profile: &domain.ExecutionProfile{
|
|
||||||
ID: "exec",
|
|
||||||
Endpoint: "http://example.invalid/v1",
|
|
||||||
Model: "model",
|
|
||||||
}},
|
|
||||||
handlerArtifactReader{},
|
|
||||||
handlerRenderer{},
|
|
||||||
llmClient,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
h := NewHandler(runner)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
"prompt_id":"p",
|
"prompt_id":"p",
|
||||||
"inputs":{"x":{"type":"file","uri":"a"}},
|
"inputs":{"x":{"type":"inline","body":"input"}},
|
||||||
"model":{"extra_params":{"model":"collision"}}
|
"model":{"extra_params":{"model":"collision"}}
|
||||||
}`))
|
}`))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -757,7 +701,7 @@ func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
func TestHandlerPublicErrorMapping(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
@@ -766,18 +710,20 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
|||||||
message string
|
message string
|
||||||
avoidCause string
|
avoidCause string
|
||||||
}{
|
}{
|
||||||
{name: "prompt not found", err: wrap(usecase.ErrPromptLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
|
{name: "prompt not found", err: scriptorium.ErrPromptNotFound, status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"},
|
||||||
{name: "prompt load invalid", err: wrap(usecase.ErrPromptLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"},
|
{name: "prompt load", err: wrap(scriptorium.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"},
|
||||||
{name: "prompt load generic", err: wrap(usecase.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(scriptorium.ErrProfileRequired, scriptorium.ErrInvalidRequest), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
|
||||||
{name: "missing profile/default", err: wrap(usecase.ErrInvalidRequest, usecase.ErrProfileRequired), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
|
{name: "profile not found", err: scriptorium.ErrProfileNotFound, status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
|
||||||
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
|
{name: "profile load", err: wrap(scriptorium.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"},
|
||||||
{name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"},
|
{name: "api key env missing", err: wrap(scriptorium.ErrAPIKeyEnvMissing, scriptorium.ErrInvalidRequest), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
|
||||||
{name: "profile load generic", err: wrap(usecase.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"},
|
{name: "invalid request", err: scriptorium.ErrInvalidRequest, status: http.StatusBadRequest, code: "invalid_request", message: "invalid run request"},
|
||||||
{name: "api key env missing", err: wrap(usecase.ErrInvalidRequest, usecase.ErrAPIKeyEnvMissing), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"},
|
{name: "file denied", err: ErrFileNotAllowed, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
||||||
{name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
|
{name: "file outside root", err: ErrFileOutsideRoot, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
||||||
{name: "prompt render", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
|
{name: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"},
|
||||||
{name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
|
{name: "artifact", err: wrap(scriptorium.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
|
||||||
{name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
|
{name: "prompt render", err: wrap(scriptorium.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
|
||||||
|
{name: "llm", err: wrap(scriptorium.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
|
||||||
|
{name: "validation runtime", err: wrap(scriptorium.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -830,12 +776,12 @@ func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
||||||
h := NewHandler(&fakeRunner{result: &domain.RunResult{
|
h := NewHandler(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("bad json")},
|
Artifact: scriptorium.Artifact{Body: []byte("bad json")},
|
||||||
RawOutput: "bad json",
|
RawOutput: "bad json",
|
||||||
Validation: domain.ValidationResult{
|
Validation: scriptorium.ValidationResult{
|
||||||
Status: domain.ValidationFailed,
|
Status: scriptorium.ValidationFailed,
|
||||||
Mode: domain.ValidationJSON,
|
Mode: scriptorium.ValidationJSON,
|
||||||
Errors: []string{"invalid JSON"},
|
Errors: []string{"invalid JSON"},
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -889,30 +835,58 @@ func newArtifactRootHandler(t *testing.T, root string) *Handler {
|
|||||||
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
|
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes)
|
reader, err := NewRestrictedArtifactReader(root, maxArtifactBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected restricted artifact reader: %v", err)
|
t.Fatalf("expected restricted artifact reader: %v", err)
|
||||||
}
|
}
|
||||||
runner := usecase.NewRunner(
|
return NewHandler(newHandlerEngine(t, scriptorium.WithArtifactReader(reader)))
|
||||||
handlerPromptRepo{def: &domain.PromptDefinition{
|
}
|
||||||
ID: "p",
|
|
||||||
Version: "1",
|
func newHandlerEngine(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
DefaultProfile: "exec",
|
t.Helper()
|
||||||
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
|
|
||||||
OutputFormat: domain.FormatText,
|
return newHandlerEngineWithOptions(t, append(options, scriptorium.WithLLMClient(handlerLLMClient{}))...)
|
||||||
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
|
}
|
||||||
}},
|
|
||||||
handlerProfileRepo{profile: &domain.ExecutionProfile{
|
func newHandlerEngineWithDefaultClient(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
ID: "exec",
|
t.Helper()
|
||||||
Endpoint: "http://example.invalid/v1",
|
|
||||||
Model: "model",
|
return newHandlerEngineWithOptions(t, options...)
|
||||||
}},
|
}
|
||||||
reader,
|
|
||||||
handlerRenderer{},
|
func newHandlerEngineWithOptions(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
handlerLLMClient{},
|
t.Helper()
|
||||||
nil,
|
|
||||||
)
|
promptDir := t.TempDir()
|
||||||
return NewHandler(runner)
|
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 := scriptorium.NewEngine(scriptorium.Config{
|
||||||
|
PromptDir: promptDir,
|
||||||
|
ProfileDir: profileDir,
|
||||||
|
}, options...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct public engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
||||||
|
|||||||
@@ -5,22 +5,19 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
||||||
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
||||||
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
|
|
||||||
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
|
|
||||||
ErrFileTooLarge = errors.New("file artifact exceeds size limit")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reader resolves artifact references into actual artifacts.
|
// Reader resolves artifact references into actual artifacts.
|
||||||
@@ -41,21 +38,6 @@ func NewCompositeReader() Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRestrictedCompositeReader(root string) (Reader, error) {
|
|
||||||
return NewRestrictedCompositeReaderWithLimit(root, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRestrictedCompositeReaderWithLimit(root string, maxBytes int64) (Reader, error) {
|
|
||||||
fileReader, err := newRestrictedFileReader(root, maxBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &CompositeReader{
|
|
||||||
inlineReader: &inlineReader{},
|
|
||||||
fileReader: fileReader,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -112,119 +94,17 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.
|
|||||||
return readFileArtifact(ref.URI)
|
return readFileArtifact(ref.URI)
|
||||||
}
|
}
|
||||||
|
|
||||||
type deniedFileReader struct{}
|
|
||||||
|
|
||||||
func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if ref.URI == "" {
|
|
||||||
return nil, ErrMissingFilePath
|
|
||||||
}
|
|
||||||
return nil, ErrFileNotAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
type restrictedFileReader struct {
|
|
||||||
root string
|
|
||||||
maxBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRestrictedFileReader(root string, maxBytes int64) (Reader, error) {
|
|
||||||
if maxBytes < 0 {
|
|
||||||
return nil, fmt.Errorf("artifact size limit must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
cleanRoot := strings.TrimSpace(root)
|
|
||||||
if cleanRoot == "" {
|
|
||||||
return deniedFileReader{}, nil
|
|
||||||
}
|
|
||||||
absRoot, err := filepath.Abs(filepath.Clean(cleanRoot))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
|
||||||
}
|
|
||||||
return &restrictedFileReader{root: absRoot, maxBytes: maxBytes}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if ref.URI == "" {
|
|
||||||
return nil, ErrMissingFilePath
|
|
||||||
}
|
|
||||||
|
|
||||||
path, err := r.resolveLexicalPath(ref.URI)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readFileArtifactWithLimit(path, r.maxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveLexicalPath checks cleaned path containment without resolving symlinks.
|
|
||||||
func (r *restrictedFileReader) resolveLexicalPath(rawPath string) (string, error) {
|
|
||||||
cleanPath := filepath.Clean(strings.TrimSpace(rawPath))
|
|
||||||
var candidate string
|
|
||||||
if filepath.IsAbs(cleanPath) {
|
|
||||||
candidate = cleanPath
|
|
||||||
} else {
|
|
||||||
candidate = filepath.Join(r.root, cleanPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
absCandidate, err := filepath.Abs(candidate)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve artifact path: %w", err)
|
|
||||||
}
|
|
||||||
absCandidate = filepath.Clean(absCandidate)
|
|
||||||
|
|
||||||
rel, err := filepath.Rel(r.root, absCandidate)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("compare artifact path to root: %w", err)
|
|
||||||
}
|
|
||||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
||||||
return "", ErrFileOutsideRoot
|
|
||||||
}
|
|
||||||
return absCandidate, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFileArtifact(path string) (*domain.Artifact, error) {
|
func readFileArtifact(path string) (*domain.Artifact, error) {
|
||||||
return readFileArtifactWithLimit(path, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFileArtifactWithLimit(path string, maxBytes int64) (*domain.Artifact, error) {
|
|
||||||
if maxBytes < 0 {
|
|
||||||
return nil, fmt.Errorf("file size limit must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
info, err := file.Stat()
|
data, err := io.ReadAll(file)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to stat file %s: %w", path, err)
|
|
||||||
}
|
|
||||||
if maxBytes > 0 && info.Size() > maxBytes {
|
|
||||||
return nil, ErrFileTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
var reader io.Reader = file
|
|
||||||
if maxBytes > 0 {
|
|
||||||
reader = io.LimitReader(file, maxBytes+1)
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
|
||||||
return nil, ErrFileTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
@@ -57,157 +56,6 @@ func TestCompositeReader_Read(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRestrictedCompositeReader(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("allowed"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("denied"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := NewRestrictedCompositeReader(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected restricted reader construction, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("accepts relative contained path", func(t *testing.T) {
|
|
||||||
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "nested/../input.txt"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected contained relative path to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "allowed" {
|
|
||||||
t.Fatalf("unexpected artifact body: %q", string(art.Body))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("accepts absolute contained path", func(t *testing.T) {
|
|
||||||
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(root, "input.txt")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected contained absolute path to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if art.Name != "input.txt" {
|
|
||||||
t.Fatalf("unexpected artifact name: %q", art.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects relative traversal outside root", func(t *testing.T) {
|
|
||||||
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join("..", filepath.Base(outside), "secret.txt")})
|
|
||||||
if !errors.Is(err, ErrFileOutsideRoot) {
|
|
||||||
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects absolute path outside root", func(t *testing.T) {
|
|
||||||
_, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filepath.Join(outside, "secret.txt")})
|
|
||||||
if !errors.Is(err, ErrFileOutsideRoot) {
|
|
||||||
t.Fatalf("expected ErrFileOutsideRoot, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestrictedCompositeReaderFollowsSymlinkInsideRoot(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
|
|
||||||
target := filepath.Join(outside, "linked.txt")
|
|
||||||
if err := os.WriteFile(target, []byte("linked outside root"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
link := filepath.Join(root, "linked.txt")
|
|
||||||
if err := os.Symlink(target, link); err != nil {
|
|
||||||
t.Skipf("symlink creation unavailable: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := NewRestrictedCompositeReader(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected restricted reader construction, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "linked.txt"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected symlink inside root to be followed, got %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "linked outside root" {
|
|
||||||
t.Fatalf("unexpected artifact body: %q", string(art.Body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) {
|
|
||||||
reader, err := NewRestrictedCompositeReader("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected restricted reader construction, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
art, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: "inline"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected inline ref to work without artifact root, got %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "inline" {
|
|
||||||
t.Fatalf("unexpected inline body: %q", string(art.Body))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "input.txt"})
|
|
||||||
if !errors.Is(err, ErrFileNotAllowed) {
|
|
||||||
t.Fatalf("expected ErrFileNotAllowed, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestrictedCompositeReaderFileSizeLimit(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
root := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "exact.txt"), []byte("12345"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := NewRestrictedCompositeReaderWithLimit(root, 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected restricted reader construction, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
art, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "exact.txt"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected file at limit to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "12345" {
|
|
||||||
t.Fatalf("unexpected artifact body: %q", string(art.Body))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "large.txt"})
|
|
||||||
if !errors.Is(err, ErrFileTooLarge) {
|
|
||||||
t.Fatalf("expected ErrFileTooLarge, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestrictedCompositeReaderFileSizeLimitZeroDisablesLimit(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := NewRestrictedCompositeReaderWithLimit(root, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected restricted reader construction, got %v", err)
|
|
||||||
}
|
|
||||||
art, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: "large.txt"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected unlimited reader to succeed, got %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "123456" {
|
|
||||||
t.Fatalf("unexpected artifact body: %q", string(art.Body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileReader_Read(t *testing.T) {
|
func TestFileReader_Read(t *testing.T) {
|
||||||
content := []byte("test file content")
|
content := []byte("test file content")
|
||||||
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")
|
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package format formats already-prepared domain data for adapters.
|
// Package format formats already-prepared public data for adapters.
|
||||||
package format
|
package format
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnknownPreparedRunFormat = errors.New("unknown prepared run format")
|
var ErrUnknownPreparedRunFormat = errors.New("unknown prepared run format")
|
||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
|
|
||||||
// PreparedRunFormatter serializes a prepared run without performing use case work.
|
// PreparedRunFormatter serializes a prepared run without performing use case work.
|
||||||
type PreparedRunFormatter interface {
|
type PreparedRunFormatter interface {
|
||||||
Format(prepared *domain.PreparedRun) ([]byte, error)
|
Format(prepared *scriptorium.PreparedRun) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsePreparedRunOutputFormat parses a format name.
|
// ParsePreparedRunOutputFormat parses a format name.
|
||||||
@@ -56,7 +56,7 @@ func FormatterForPreparedRun(outputFormat PreparedRunOutputFormat) (PreparedRunF
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FormatPreparedRun formats a prepared run using the selected format.
|
// FormatPreparedRun formats a prepared run using the selected format.
|
||||||
func FormatPreparedRun(prepared *domain.PreparedRun, outputFormat PreparedRunOutputFormat) ([]byte, error) {
|
func FormatPreparedRun(prepared *scriptorium.PreparedRun, outputFormat PreparedRunOutputFormat) ([]byte, error) {
|
||||||
formatter, err := FormatterForPreparedRun(outputFormat)
|
formatter, err := FormatterForPreparedRun(outputFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -65,7 +65,7 @@ func FormatPreparedRun(prepared *domain.PreparedRun, outputFormat PreparedRunOut
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FormatPreparedRunByName parses a format name and formats a prepared run.
|
// FormatPreparedRunByName parses a format name and formats a prepared run.
|
||||||
func FormatPreparedRunByName(prepared *domain.PreparedRun, rawFormat string) ([]byte, error) {
|
func FormatPreparedRunByName(prepared *scriptorium.PreparedRun, rawFormat string) ([]byte, error) {
|
||||||
outputFormat, err := ParsePreparedRunOutputFormat(rawFormat)
|
outputFormat, err := ParsePreparedRunOutputFormat(rawFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -75,7 +75,7 @@ func FormatPreparedRunByName(prepared *domain.PreparedRun, rawFormat string) ([]
|
|||||||
|
|
||||||
type jsonPreparedRunFormatter struct{}
|
type jsonPreparedRunFormatter struct{}
|
||||||
|
|
||||||
func (jsonPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, error) {
|
func (jsonPreparedRunFormatter) Format(prepared *scriptorium.PreparedRun) ([]byte, error) {
|
||||||
if prepared == nil {
|
if prepared == nil {
|
||||||
return nil, errors.New("prepared run is nil")
|
return nil, errors.New("prepared run is nil")
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ func (jsonPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
|
|||||||
|
|
||||||
type textPreparedRunFormatter struct{}
|
type textPreparedRunFormatter struct{}
|
||||||
|
|
||||||
func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, error) {
|
func (textPreparedRunFormatter) Format(prepared *scriptorium.PreparedRun) ([]byte, error) {
|
||||||
if prepared == nil {
|
if prepared == nil {
|
||||||
return nil, errors.New("prepared run is nil")
|
return nil, errors.New("prepared run is nil")
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
|
|||||||
|
|
||||||
fmt.Fprintln(&b, "messages:")
|
fmt.Fprintln(&b, "messages:")
|
||||||
roleOrder := make([]string, 0)
|
roleOrder := make([]string, 0)
|
||||||
byRole := make(map[string][]domain.RenderedMessage)
|
byRole := make(map[string][]scriptorium.RenderedMessage)
|
||||||
for _, msg := range prepared.Messages {
|
for _, msg := range prepared.Messages {
|
||||||
if _, exists := byRole[msg.Role]; !exists {
|
if _, exists := byRole[msg.Role]; !exists {
|
||||||
roleOrder = append(roleOrder, msg.Role)
|
roleOrder = append(roleOrder, msg.Role)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTextFormatterIncludesPreparedRunDetails(t *testing.T) {
|
func TestTextFormatterIncludesPreparedRunDetails(t *testing.T) {
|
||||||
@@ -94,9 +94,8 @@ func TestTextFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
|
|||||||
|
|
||||||
func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
|
func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
|
||||||
const directKey = "direct-format-key"
|
const directKey = "direct-format-key"
|
||||||
|
// PreparedRun intentionally has no field for direct API keys.
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.EffectiveModelParams.APIKey = directKey
|
|
||||||
|
|
||||||
out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -108,12 +107,12 @@ func TestTextFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
|
|||||||
|
|
||||||
func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) {
|
func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.Messages = []domain.RenderedMessage{
|
prepared.Messages = []scriptorium.RenderedMessage{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "System guidance.",
|
Content: "System guidance.",
|
||||||
CacheControl: &domain.CacheControl{
|
CacheControl: &scriptorium.CacheControl{
|
||||||
Type: domain.CacheControlEphemeral,
|
Type: scriptorium.CacheControlEphemeral,
|
||||||
TTL: "1h",
|
TTL: "1h",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -148,12 +147,12 @@ func TestTextFormatterIncludesSessionIDWhenPresent(t *testing.T) {
|
|||||||
|
|
||||||
func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.Messages = []domain.RenderedMessage{
|
prepared.Messages = []scriptorium.RenderedMessage{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "System guidance.",
|
Content: "System guidance.",
|
||||||
CacheControl: &domain.CacheControl{
|
CacheControl: &scriptorium.CacheControl{
|
||||||
Type: domain.CacheControlEphemeral,
|
Type: scriptorium.CacheControlEphemeral,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -231,12 +230,12 @@ func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.Messages = []domain.RenderedMessage{
|
prepared.Messages = []scriptorium.RenderedMessage{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "System guidance.",
|
Content: "System guidance.",
|
||||||
CacheControl: &domain.CacheControl{
|
CacheControl: &scriptorium.CacheControl{
|
||||||
Type: domain.CacheControlEphemeral,
|
Type: scriptorium.CacheControlEphemeral,
|
||||||
TTL: "1h",
|
TTL: "1h",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -262,7 +261,7 @@ func TestJSONFormatterIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected first message cache_control, got %#v", decoded.Messages[0])
|
t.Fatalf("expected first message cache_control, got %#v", decoded.Messages[0])
|
||||||
}
|
}
|
||||||
if cacheControl["type"] != string(domain.CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
if cacheControl["type"] != string(scriptorium.CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
||||||
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
||||||
}
|
}
|
||||||
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
||||||
@@ -285,9 +284,8 @@ func TestJSONFormatterDoesNotIncludeResolvedAPIKeyValue(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
|
func TestJSONFormatterDoesNotIncludeDirectAPIKeyValue(t *testing.T) {
|
||||||
const directKey = "direct-format-key"
|
const directKey = "direct-format-key"
|
||||||
|
// PreparedRun intentionally has no field for direct API keys.
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.EffectiveModelParams.APIKey = directKey
|
|
||||||
|
|
||||||
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -342,13 +340,13 @@ func TestFormatPreparedRunByNameUnknownFailsClearly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func samplePreparedRun() *domain.PreparedRun {
|
func samplePreparedRun() *scriptorium.PreparedRun {
|
||||||
return &domain.PreparedRun{
|
return &scriptorium.PreparedRun{
|
||||||
PromptID: "prompt.id",
|
PromptID: "prompt.id",
|
||||||
PromptVersion: "v1",
|
PromptVersion: "v1",
|
||||||
PromptHash: "prompt-hash",
|
PromptHash: "prompt-hash",
|
||||||
SelectedProfileID: "local-fast",
|
SelectedProfileID: "local-fast",
|
||||||
EffectiveModelParams: domain.ExecutionTarget{
|
EffectiveModelParams: scriptorium.ExecutionTarget{
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
Model: "gpt-test",
|
Model: "gpt-test",
|
||||||
Temperature: 0.4,
|
Temperature: 0.4,
|
||||||
@@ -364,7 +362,7 @@ func samplePreparedRun() *domain.PreparedRun {
|
|||||||
"glossary": "hash-glossary",
|
"glossary": "hash-glossary",
|
||||||
},
|
},
|
||||||
RenderedPromptHash: "rendered-hash",
|
RenderedPromptHash: "rendered-hash",
|
||||||
Messages: []domain.RenderedMessage{
|
Messages: []scriptorium.RenderedMessage{
|
||||||
{Role: "system", Content: "System guidance."},
|
{Role: "system", Content: "System guidance."},
|
||||||
{Role: "user", Content: "Summarize the transcript.\nInclude key entities."},
|
{Role: "user", Content: "Summarize the transcript.\nInclude key entities."},
|
||||||
{Role: "user", Content: "Second user message."},
|
{Role: "user", Content: "Second user message."},
|
||||||
|
|||||||
8
types.go
8
types.go
@@ -126,6 +126,14 @@ type Artifact struct {
|
|||||||
Hash string
|
Hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtifactReader resolves a prompt input reference into its content.
|
||||||
|
//
|
||||||
|
// Readers are responsible for supplying artifact metadata. The engine assigns
|
||||||
|
// an input-map name only when the returned artifact name is empty.
|
||||||
|
type ArtifactReader interface {
|
||||||
|
Read(context.Context, ArtifactRef) (*Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionTarget represents effective model runtime settings.
|
// ExecutionTarget represents effective model runtime settings.
|
||||||
type ExecutionTarget struct {
|
type ExecutionTarget struct {
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
|
|||||||
Reference in New Issue
Block a user