Add HTTP size limits
This commit is contained in:
@@ -84,11 +84,14 @@ Notes:
|
|||||||
- `--profile-dir <dir>`: custom profile definition directory.
|
- `--profile-dir <dir>`: custom profile definition directory.
|
||||||
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
|
- `--schema-dir <dir>`: schema base directory for `json_schema` validation.
|
||||||
- `--artifact-root <dir>`: base directory for HTTP `file` input references.
|
- `--artifact-root <dir>`: base directory for HTTP `file` input references.
|
||||||
|
- `--max-request-bytes <n>`: maximum HTTP request body bytes; `0` disables this limit.
|
||||||
|
- `--max-artifact-bytes <n>`: maximum HTTP file artifact bytes; `0` disables this limit.
|
||||||
|
- `--max-response-bytes <n>`: maximum encoded HTTP response body bytes; `0` disables this limit.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `serve` does not accept runtime model override flags such as `--model` or `--llm-base-url`.
|
- `serve` does not accept runtime model override flags such as `--model` or `--llm-base-url`.
|
||||||
- HTTP `file` input references are rejected unless an artifact root is configured through `server.artifact_root` or `--artifact-root`.
|
- HTTP `file` input references are rejected unless an artifact root is configured through `server.artifact_root` or `--artifact-root`.
|
||||||
- `--artifact-root` affects only `serve`; `run` and `render` file input paths are unchanged.
|
- `--artifact-root` and the HTTP size-limit flags affect only `serve`; `run` and `render` file input paths are unchanged.
|
||||||
|
|
||||||
## Input And Variable Syntax
|
## Input And Variable Syntax
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ schema_dir: /opt/scriptorium/schemas
|
|||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:8080
|
addr: 127.0.0.1:8080
|
||||||
artifact_root: /var/lib/scriptorium/artifacts
|
artifact_root: /var/lib/scriptorium/artifacts
|
||||||
|
max_request_bytes: 16777216
|
||||||
|
max_artifact_bytes: 16777216
|
||||||
|
max_response_bytes: 16777216
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
render_format: text
|
render_format: text
|
||||||
@@ -49,6 +52,9 @@ Top-level fields:
|
|||||||
- `schema_dir` (optional): base directory for schema files used by `json_schema` validation.
|
- `schema_dir` (optional): base directory for schema files used by `json_schema` validation.
|
||||||
- `server.addr` (optional): default listen address for `serve`.
|
- `server.addr` (optional): default listen address for `serve`.
|
||||||
- `server.artifact_root` (optional): base directory for HTTP `file` input references.
|
- `server.artifact_root` (optional): base directory for HTTP `file` input references.
|
||||||
|
- `server.max_request_bytes` (optional): maximum HTTP request body size. `0` disables this limit.
|
||||||
|
- `server.max_artifact_bytes` (optional): maximum HTTP `file` input artifact size. `0` disables this limit.
|
||||||
|
- `server.max_response_bytes` (optional): maximum encoded HTTP response body size. `0` disables this limit.
|
||||||
- `defaults.render_format` (optional): default `render` output format (`text` or `json`).
|
- `defaults.render_format` (optional): default `render` output format (`text` or `json`).
|
||||||
|
|
||||||
Built-in defaults:
|
Built-in defaults:
|
||||||
@@ -56,11 +62,15 @@ Built-in defaults:
|
|||||||
- `schema_dir`: `.`
|
- `schema_dir`: `.`
|
||||||
- `server.addr`: `:8080`
|
- `server.addr`: `:8080`
|
||||||
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured.
|
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured.
|
||||||
|
- `server.max_request_bytes`: `16777216` (16 MiB)
|
||||||
|
- `server.max_artifact_bytes`: `16777216` (16 MiB)
|
||||||
|
- `server.max_response_bytes`: `16777216` (16 MiB)
|
||||||
- `defaults.render_format`: `text`
|
- `defaults.render_format`: `text`
|
||||||
|
|
||||||
Validation behavior:
|
Validation behavior:
|
||||||
|
|
||||||
- Config decoding is strict; unknown YAML fields are rejected.
|
- Config decoding is strict; unknown YAML fields are rejected.
|
||||||
|
- HTTP size limit values must be greater than or equal to `0`.
|
||||||
- Raw API key fields are not supported in `config.yml`.
|
- Raw API key fields are not supported in `config.yml`.
|
||||||
|
|
||||||
HTTP artifact root behavior:
|
HTTP artifact root behavior:
|
||||||
@@ -72,6 +82,13 @@ HTTP artifact root behavior:
|
|||||||
- Symlinks inside the root are followed by the operating system; do not make the artifact root writable by untrusted users.
|
- Symlinks inside the root are followed by the operating system; do not make the artifact root writable by untrusted users.
|
||||||
- CLI `run` and `render` file inputs keep their normal direct filesystem path behavior.
|
- CLI `run` and `render` file inputs keep their normal direct filesystem path behavior.
|
||||||
|
|
||||||
|
HTTP size-limit behavior:
|
||||||
|
|
||||||
|
- The request limit covers the encoded JSON request body, including inline input bodies.
|
||||||
|
- The artifact limit covers HTTP `file` input artifacts read through `serve`.
|
||||||
|
- The response limit covers the final encoded JSON response, including generated artifact bodies and `raw_model_output` when requested.
|
||||||
|
- Limits apply only to HTTP `serve`; CLI `run` and `render` keep direct filesystem behavior.
|
||||||
|
|
||||||
## Prompt Definition Files
|
## Prompt Definition Files
|
||||||
|
|
||||||
Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories.
|
Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories.
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ Relative file URIs resolve inside that root. Absolute file URIs are accepted
|
|||||||
only when they remain inside the root. Requests that escape the root, including
|
only when they remain inside the root. Requests that escape the root, including
|
||||||
`..` traversal and absolute paths outside the root, return
|
`..` traversal and absolute paths outside the root, return
|
||||||
`400 artifact_not_allowed`. `inline` references do not require an artifact root.
|
`400 artifact_not_allowed`. `inline` references do not require an artifact root.
|
||||||
|
HTTP file artifacts above the configured artifact limit return
|
||||||
|
`413 artifact_too_large`. Inline bodies are bounded by the request body limit.
|
||||||
|
|
||||||
Model override notes:
|
Model override notes:
|
||||||
|
|
||||||
@@ -91,6 +93,8 @@ Request decoding uses strict JSON field checks:
|
|||||||
- unknown request fields are rejected with `400 invalid_json`
|
- unknown request fields are rejected with `400 invalid_json`
|
||||||
- unknown `model` fields are rejected with `400 invalid_json`
|
- unknown `model` fields are rejected with `400 invalid_json`
|
||||||
- raw API-key payload fields such as `api_key` are rejected as unknown fields
|
- raw API-key payload fields such as `api_key` are rejected as unknown fields
|
||||||
|
- request bodies above the configured request limit are rejected with `413 request_too_large`
|
||||||
|
- trailing JSON tokens after the request object are rejected with `400 invalid_json`
|
||||||
|
|
||||||
## Success Response
|
## Success Response
|
||||||
|
|
||||||
@@ -204,6 +208,9 @@ Current error mapping (non-exhaustive):
|
|||||||
- `400 artifact_read_failed`: input artifact loading failed
|
- `400 artifact_read_failed`: input artifact loading failed
|
||||||
- `400 prompt_render_failed`: template render failed
|
- `400 prompt_render_failed`: template render failed
|
||||||
- `400 api_key_env_missing`: named API-key environment variable is missing
|
- `400 api_key_env_missing`: named API-key environment variable is missing
|
||||||
|
- `413 request_too_large`: request body exceeds the configured request limit
|
||||||
|
- `413 artifact_too_large`: HTTP file input artifact exceeds the configured artifact limit
|
||||||
|
- `413 response_too_large`: encoded JSON response exceeds the configured response limit
|
||||||
- `404 prompt_not_found`
|
- `404 prompt_not_found`
|
||||||
- `404 profile_not_found`
|
- `404 profile_not_found`
|
||||||
- `502 llm_failed`: outbound model request failed
|
- `502 llm_failed`: outbound model request failed
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ Primary app settings consumed by adapters:
|
|||||||
- `schema_dir`
|
- `schema_dir`
|
||||||
- `server.addr`
|
- `server.addr`
|
||||||
- `server.artifact_root` (HTTP `serve` file input root)
|
- `server.artifact_root` (HTTP `serve` file input root)
|
||||||
|
- `server.max_request_bytes`
|
||||||
|
- `server.max_artifact_bytes`
|
||||||
|
- `server.max_response_bytes`
|
||||||
- `defaults.render_format`
|
- `defaults.render_format`
|
||||||
|
|
||||||
Execution profile/request settings used through runner:
|
Execution profile/request settings used through runner:
|
||||||
@@ -122,6 +125,7 @@ Artifact refs:
|
|||||||
- Unsupported types return `ErrUnsupportedRefType`.
|
- Unsupported types return `ErrUnsupportedRefType`.
|
||||||
- CLI `run` and `render` use direct filesystem file reads for `file` references.
|
- CLI `run` and `render` use direct filesystem file reads for `file` references.
|
||||||
- HTTP `serve` uses a restricted artifact reader: `inline` references work without a root, while `file` references require `server.artifact_root` or `--artifact-root` and must stay inside that root.
|
- HTTP `serve` uses a restricted artifact reader: `inline` references work without a root, while `file` references require `server.artifact_root` or `--artifact-root` and must stay inside that root.
|
||||||
|
- HTTP `serve` applies request-body, file-artifact, and encoded-response size limits. CLI `run` and `render` do not use these HTTP limits.
|
||||||
- HTTP file paths are resolved with clean absolute paths and containment checks, not string-prefix checks.
|
- HTTP file paths are resolved with clean absolute paths and containment checks, not string-prefix checks.
|
||||||
- Symlinks inside the root are followed by the operating system; the configured root must not be writable by untrusted users.
|
- Symlinks inside the root are followed by the operating system; the configured root must not be writable by untrusted users.
|
||||||
|
|
||||||
@@ -149,6 +153,7 @@ Validator:
|
|||||||
HTTP error mapping:
|
HTTP error mapping:
|
||||||
|
|
||||||
- maps domain/use-case errors to stable HTTP code + error code/message.
|
- maps domain/use-case errors to stable HTTP code + error code/message.
|
||||||
|
- maps request, artifact, and response size failures to `413` errors.
|
||||||
- distinguishes missing profile selection and missing `api_key_env` variable using stable use-case sentinel errors.
|
- distinguishes missing profile selection and missing `api_key_env` variable using stable use-case sentinel errors.
|
||||||
- avoids returning internal wrapped-cause details in response payload.
|
- avoids returning internal wrapped-cause details in response payload.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ Built-in defaults relevant to operations:
|
|||||||
- `schema_dir: .`
|
- `schema_dir: .`
|
||||||
- `server.addr: :8080`
|
- `server.addr: :8080`
|
||||||
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured
|
- `server.artifact_root`: unset; HTTP `file` input references are rejected until configured
|
||||||
|
- `server.max_request_bytes: 16777216`
|
||||||
|
- `server.max_artifact_bytes: 16777216`
|
||||||
|
- `server.max_response_bytes: 16777216`
|
||||||
- `defaults.render_format: text`
|
- `defaults.render_format: text`
|
||||||
|
|
||||||
## Normal CLI Workflow
|
## Normal CLI Workflow
|
||||||
@@ -78,6 +81,7 @@ Current inbound API behavior:
|
|||||||
- Validation content failures still return `200 OK` with `validation.status: "failed"`.
|
- Validation content failures still return `200 OK` with `validation.status: "failed"`.
|
||||||
- `inline` input references work without filesystem configuration.
|
- `inline` input references work without filesystem configuration.
|
||||||
- `file` input references require `server.artifact_root` or `serve --artifact-root`; relative paths resolve inside that root and paths outside it are rejected.
|
- `file` input references require `server.artifact_root` or `serve --artifact-root`; relative paths resolve inside that root and paths outside it are rejected.
|
||||||
|
- Request bodies, HTTP file input artifacts, and encoded JSON responses are limited by `server.max_request_bytes`, `server.max_artifact_bytes`, and `server.max_response_bytes`.
|
||||||
|
|
||||||
Security caveat:
|
Security caveat:
|
||||||
|
|
||||||
@@ -85,6 +89,13 @@ Security caveat:
|
|||||||
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
|
- Deploy only behind trusted controls (private network boundary, authenticated reverse proxy, API gateway, or equivalent).
|
||||||
- Keep the HTTP artifact root as narrow as practical and do not make it writable by untrusted users.
|
- Keep the HTTP artifact root as narrow as practical and do not make it writable by untrusted users.
|
||||||
|
|
||||||
|
Sizing guidance:
|
||||||
|
|
||||||
|
- Keep limits at the defaults unless a deployment has a measured need for larger prompt inputs or outputs.
|
||||||
|
- Prefer `inline` inputs for small payloads and HTTP `file` inputs for larger local artifacts inside a controlled artifact root.
|
||||||
|
- Increase the response limit when prompts intentionally return large generated artifacts or when clients request `include_raw_output`.
|
||||||
|
- Set a limit to `0` only for trusted deployments where another layer enforces request and response size.
|
||||||
|
|
||||||
## Output, Logs, And Exit Codes
|
## Output, Logs, And Exit Codes
|
||||||
|
|
||||||
`run` command:
|
`run` command:
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ Symptom:
|
|||||||
- CLI run/render error reading input artifacts.
|
- CLI run/render error reading input artifacts.
|
||||||
- HTTP `400 artifact_read_failed`.
|
- HTTP `400 artifact_read_failed`.
|
||||||
- HTTP `400 artifact_not_allowed`.
|
- HTTP `400 artifact_not_allowed`.
|
||||||
|
- HTTP `413 artifact_too_large`.
|
||||||
|
|
||||||
Likely cause:
|
Likely cause:
|
||||||
|
|
||||||
@@ -154,12 +155,14 @@ Likely cause:
|
|||||||
- Unsupported artifact reference type in HTTP request.
|
- Unsupported artifact reference type in HTTP request.
|
||||||
- HTTP `file` input references are disabled because no artifact root is configured.
|
- HTTP `file` input references are disabled because no artifact root is configured.
|
||||||
- HTTP `file` input path escapes the configured artifact root.
|
- HTTP `file` input path escapes the configured artifact root.
|
||||||
|
- HTTP `file` input artifact exceeds `server.max_artifact_bytes`.
|
||||||
|
|
||||||
Diagnostic step:
|
Diagnostic step:
|
||||||
|
|
||||||
- Verify every mapped file path exists and is readable by the process.
|
- Verify every mapped file path exists and is readable by the process.
|
||||||
- For HTTP, verify each input uses supported `type` values.
|
- For HTTP, verify each input uses supported `type` values.
|
||||||
- For HTTP `file` inputs, verify `server.artifact_root` or `serve --artifact-root` is configured and the requested path stays inside that root.
|
- For HTTP `file` inputs, verify `server.artifact_root` or `serve --artifact-root` is configured and the requested path stays inside that root.
|
||||||
|
- For HTTP `file` inputs, compare file size to `server.max_artifact_bytes`.
|
||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
@@ -167,6 +170,7 @@ Safe fix:
|
|||||||
- Use supported input types (`file`, `inline`).
|
- Use supported input types (`file`, `inline`).
|
||||||
- Configure a narrow HTTP artifact root when HTTP file inputs are required.
|
- Configure a narrow HTTP artifact root when HTTP file inputs are required.
|
||||||
- Use relative paths under the artifact root, or switch to `inline` inputs.
|
- Use relative paths under the artifact root, or switch to `inline` inputs.
|
||||||
|
- Increase `server.max_artifact_bytes` only when the deployment expects larger file inputs.
|
||||||
|
|
||||||
Relevant links:
|
Relevant links:
|
||||||
|
|
||||||
@@ -351,22 +355,29 @@ Relevant links:
|
|||||||
Symptom:
|
Symptom:
|
||||||
|
|
||||||
- HTTP `400 invalid_json` or `400 invalid_request`.
|
- HTTP `400 invalid_json` or `400 invalid_request`.
|
||||||
|
- HTTP `413 request_too_large`.
|
||||||
|
- HTTP `413 response_too_large`.
|
||||||
|
|
||||||
Likely cause:
|
Likely cause:
|
||||||
|
|
||||||
- Malformed JSON body.
|
- Malformed JSON body.
|
||||||
- Unknown JSON fields.
|
- Unknown JSON fields.
|
||||||
- Missing required `prompt_id` or `inputs`.
|
- Missing required `prompt_id` or `inputs`.
|
||||||
|
- Request body exceeds `server.max_request_bytes`, including inline input bodies.
|
||||||
|
- Encoded JSON response exceeds `server.max_response_bytes`, including generated artifact body and optional raw model output.
|
||||||
|
|
||||||
Diagnostic step:
|
Diagnostic step:
|
||||||
|
|
||||||
- Revalidate request JSON.
|
- Revalidate request JSON.
|
||||||
- Confirm required request fields are present.
|
- Confirm required request fields are present.
|
||||||
|
- Compare request and expected response sizes to configured HTTP limits.
|
||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- Send valid JSON with only supported fields.
|
- Send valid JSON with only supported fields.
|
||||||
- Ensure `prompt_id` and at least one input mapping are included.
|
- Ensure `prompt_id` and at least one input mapping are included.
|
||||||
|
- Use smaller inline inputs, move large local inputs under the artifact root, or increase `server.max_request_bytes`.
|
||||||
|
- Omit `include_raw_output`, reduce generated output size, or increase `server.max_response_bytes`.
|
||||||
|
|
||||||
Relevant links:
|
Relevant links:
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,14 @@ type renderConfig struct {
|
|||||||
type serveConfig struct {
|
type serveConfig struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
|
||||||
addr string
|
addr string
|
||||||
promptDir string
|
promptDir string
|
||||||
profileDir string
|
profileDir string
|
||||||
schemaDir string
|
schemaDir string
|
||||||
artifactRoot string
|
artifactRoot string
|
||||||
|
maxRequestBytes int64
|
||||||
|
maxArtifactBytes int64
|
||||||
|
maxResponseBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type commonCommandSettings struct {
|
type commonCommandSettings struct {
|
||||||
@@ -87,6 +90,9 @@ type commonCommandSettings struct {
|
|||||||
schemaDir string
|
schemaDir string
|
||||||
serverAddr string
|
serverAddr string
|
||||||
artifactRoot string
|
artifactRoot string
|
||||||
|
maxRequestBytes int64
|
||||||
|
maxArtifactBytes int64
|
||||||
|
maxResponseBytes int64
|
||||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +210,7 @@ func serveCommand(args []string, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
artifactReader, err := artifactadapter.NewRestrictedCompositeReader(cfg.artifactRoot)
|
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
|
||||||
@@ -212,7 +218,10 @@ func serveCommand(args []string, stderr io.Writer) int {
|
|||||||
|
|
||||||
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
|
runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader)
|
||||||
|
|
||||||
h := httpadapter.NewHandler(runner)
|
h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{
|
||||||
|
MaxRequestBytes: cfg.maxRequestBytes,
|
||||||
|
MaxResponseBytes: cfg.maxResponseBytes,
|
||||||
|
})
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.addr,
|
Addr: cfg.addr,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
@@ -290,6 +299,9 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
||||||
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
||||||
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
|
fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts")
|
||||||
|
fs.Int64Var(&cfg.maxRequestBytes, "max-request-bytes", 0, "maximum HTTP request body bytes; 0 disables the limit")
|
||||||
|
fs.Int64Var(&cfg.maxArtifactBytes, "max-artifact-bytes", 0, "maximum HTTP file artifact bytes; 0 disables the limit")
|
||||||
|
fs.Int64Var(&cfg.maxResponseBytes, "max-response-bytes", 0, "maximum HTTP response body bytes; 0 disables the limit")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -299,11 +311,14 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
|
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
|
||||||
PromptDir: cfg.promptDirIfSet(fs),
|
PromptDir: cfg.promptDirIfSet(fs),
|
||||||
ProfileDir: cfg.profileDirIfSet(fs),
|
ProfileDir: cfg.profileDirIfSet(fs),
|
||||||
SchemaDir: cfg.schemaDirIfSet(fs),
|
SchemaDir: cfg.schemaDirIfSet(fs),
|
||||||
ServerAddr: cfg.addrIfSet(fs),
|
ServerAddr: cfg.addrIfSet(fs),
|
||||||
ArtifactRoot: cfg.artifactRootIfSet(fs),
|
ArtifactRoot: cfg.artifactRootIfSet(fs),
|
||||||
|
MaxRequestBytes: cfg.maxRequestBytesIfSet(fs),
|
||||||
|
MaxArtifactBytes: cfg.maxArtifactBytesIfSet(fs),
|
||||||
|
MaxResponseBytes: cfg.maxResponseBytesIfSet(fs),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -314,6 +329,9 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
cfg.schemaDir = settings.schemaDir
|
cfg.schemaDir = settings.schemaDir
|
||||||
cfg.addr = settings.serverAddr
|
cfg.addr = settings.serverAddr
|
||||||
cfg.artifactRoot = settings.artifactRoot
|
cfg.artifactRoot = settings.artifactRoot
|
||||||
|
cfg.maxRequestBytes = settings.maxRequestBytes
|
||||||
|
cfg.maxArtifactBytes = settings.maxArtifactBytes
|
||||||
|
cfg.maxResponseBytes = settings.maxResponseBytes
|
||||||
|
|
||||||
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -450,6 +468,27 @@ func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *serveConfig) maxRequestBytesIfSet(fs *flag.FlagSet) *int64 {
|
||||||
|
if flagWasSet(fs, "max-request-bytes") {
|
||||||
|
return &c.maxRequestBytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *serveConfig) maxArtifactBytesIfSet(fs *flag.FlagSet) *int64 {
|
||||||
|
if flagWasSet(fs, "max-artifact-bytes") {
|
||||||
|
return &c.maxArtifactBytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *serveConfig) maxResponseBytesIfSet(fs *flag.FlagSet) *int64 {
|
||||||
|
if flagWasSet(fs, "max-response-bytes") {
|
||||||
|
return &c.maxResponseBytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
||||||
fs.StringVar(
|
fs.StringVar(
|
||||||
target,
|
target,
|
||||||
@@ -488,6 +527,9 @@ func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appcon
|
|||||||
schemaDir: settings.SchemaDir,
|
schemaDir: settings.SchemaDir,
|
||||||
serverAddr: settings.ServerAddr,
|
serverAddr: settings.ServerAddr,
|
||||||
artifactRoot: settings.ArtifactRoot,
|
artifactRoot: settings.ArtifactRoot,
|
||||||
|
maxRequestBytes: settings.MaxRequestBytes,
|
||||||
|
maxArtifactBytes: settings.MaxArtifactBytes,
|
||||||
|
maxResponseBytes: settings.MaxResponseBytes,
|
||||||
defaultRenderFormat: settings.DefaultRenderFormat,
|
defaultRenderFormat: settings.DefaultRenderFormat,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,12 +436,18 @@ schema_dir: ./from-config/schemas
|
|||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9000
|
addr: 127.0.0.1:9000
|
||||||
artifact_root: ./from-config/artifacts
|
artifact_root: ./from-config/artifacts
|
||||||
|
max_request_bytes: 1024
|
||||||
|
max_artifact_bytes: 2048
|
||||||
|
max_response_bytes: 4096
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := parseServeArgs([]string{
|
cfg, err := parseServeArgs([]string{
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"--addr", ":7777",
|
"--addr", ":7777",
|
||||||
"--artifact-root", "./from-cli/artifacts",
|
"--artifact-root", "./from-cli/artifacts",
|
||||||
|
"--max-request-bytes", "0",
|
||||||
|
"--max-artifact-bytes", "8192",
|
||||||
|
"--max-response-bytes", "16384",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected valid args, got %v", err)
|
t.Fatalf("expected valid args, got %v", err)
|
||||||
@@ -462,6 +468,15 @@ server:
|
|||||||
if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") {
|
if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") {
|
||||||
t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot)
|
t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot)
|
||||||
}
|
}
|
||||||
|
if cfg.maxRequestBytes != 0 {
|
||||||
|
t.Fatalf("expected CLI max request bytes override, got %d", cfg.maxRequestBytes)
|
||||||
|
}
|
||||||
|
if cfg.maxArtifactBytes != 8192 {
|
||||||
|
t.Fatalf("expected CLI max artifact bytes override, got %d", cfg.maxArtifactBytes)
|
||||||
|
}
|
||||||
|
if cfg.maxResponseBytes != 16384 {
|
||||||
|
t.Fatalf("expected CLI max response bytes override, got %d", cfg.maxResponseBytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
|
func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) {
|
||||||
@@ -472,6 +487,9 @@ schema_dir: ./from-config/schemas
|
|||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9000
|
addr: 127.0.0.1:9000
|
||||||
artifact_root: ./from-config/artifacts
|
artifact_root: ./from-config/artifacts
|
||||||
|
max_request_bytes: 1024
|
||||||
|
max_artifact_bytes: 2048
|
||||||
|
max_response_bytes: 4096
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := parseServeArgs([]string{
|
cfg, err := parseServeArgs([]string{
|
||||||
@@ -496,6 +514,72 @@ server:
|
|||||||
if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") {
|
if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") {
|
||||||
t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot)
|
t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot)
|
||||||
}
|
}
|
||||||
|
if cfg.maxRequestBytes != 1024 {
|
||||||
|
t.Fatalf("expected max request bytes from config, got %d", cfg.maxRequestBytes)
|
||||||
|
}
|
||||||
|
if cfg.maxArtifactBytes != 2048 {
|
||||||
|
t.Fatalf("expected max artifact bytes from config, got %d", cfg.maxArtifactBytes)
|
||||||
|
}
|
||||||
|
if cfg.maxResponseBytes != 4096 {
|
||||||
|
t.Fatalf("expected max response bytes from config, got %d", cfg.maxResponseBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseServeArgsRejectsNegativeSizeLimits(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flag string
|
||||||
|
}{
|
||||||
|
{name: "request", flag: "--max-request-bytes"},
|
||||||
|
{name: "artifact", flag: "--max-artifact-bytes"},
|
||||||
|
{name: "response", flag: "--max-response-bytes"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := parseServeArgs([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
tc.flag, "-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected negative size limit error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAndRenderRejectServeSizeLimitFlags(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
parse func([]string) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "run",
|
||||||
|
parse: func(args []string) error {
|
||||||
|
_, err := parseRunArgs(args)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render",
|
||||||
|
parse: func(args []string) error {
|
||||||
|
_, err := parseRenderArgs(args)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := tc.parse([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "a=b",
|
||||||
|
"--max-request-bytes", "1024",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unsupported flag error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {
|
func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
||||||
@@ -19,11 +21,24 @@ type Runner interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
runner Runner
|
runner Runner
|
||||||
|
options HandlerOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandlerOptions struct {
|
||||||
|
MaxRequestBytes int64
|
||||||
|
MaxResponseBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(runner Runner) *Handler {
|
func NewHandler(runner Runner) *Handler {
|
||||||
return &Handler{runner: runner}
|
return NewHandlerWithOptions(runner, HandlerOptions{
|
||||||
|
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
|
||||||
|
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlerWithOptions(runner Runner, options HandlerOptions) *Handler {
|
||||||
|
return &Handler{runner: runner, options: options}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -37,9 +52,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req runRequestDTO
|
var req runRequestDTO
|
||||||
dec := json.NewDecoder(r.Body)
|
body := r.Body
|
||||||
|
if h.options.MaxRequestBytes > 0 {
|
||||||
|
body = http.MaxBytesReader(w, r.Body, h.options.MaxRequestBytes)
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(body)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&req); err != nil {
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
if isRequestTooLarge(err) {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := dec.Decode(&trailing); err != io.EOF {
|
||||||
|
if isRequestTooLarge(err) {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body")
|
writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -121,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
raw := res.RawOutput
|
raw := res.RawOutput
|
||||||
resp.RawModelOutput = &raw
|
resp.RawModelOutput = &raw
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
|
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
|
||||||
@@ -190,6 +222,8 @@ func mapRunError(err error) (int, string, string) {
|
|||||||
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, artifact.ErrFileNotAllowed), errors.Is(err, artifact.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):
|
||||||
|
return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large"
|
||||||
case errors.Is(err, usecase.ErrArtifactLoad):
|
case errors.Is(err, usecase.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, usecase.ErrPromptRender):
|
||||||
@@ -204,9 +238,23 @@ func mapRunError(err error) (int, string, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
writeLimitedJSON(w, status, v, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLimitedJSON(w http.ResponseWriter, status int, v any, maxBytes int64) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if maxBytes > 0 && int64(len(data)) > maxBytes {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "response_too_large", "response body is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
@@ -217,3 +265,8 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRequestTooLarge(err error) bool {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
return errors.As(err, &maxBytesErr)
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,6 +243,24 @@ func TestHandlerFileRefsUnderArtifactRootWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerFileRefsAboveArtifactLimitAreRejected(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte("123456"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := newArtifactRootHandlerWithLimit(t, root, 5)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":"large.txt"}}
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "artifact_too_large")
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
@@ -576,6 +594,69 @@ func TestHandlerInvalidJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerRejectsTrailingJSON(t *testing.T) {
|
||||||
|
h := NewHandler(&fakeRunner{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}} {}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRequestTooLarge(t *testing.T) {
|
||||||
|
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 12})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
|
||||||
|
h := NewHandlerWithOptions(&fakeRunner{}, HandlerOptions{MaxRequestBytes: 1024})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusBadRequest, "invalid_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerResponseTooLarge(t *testing.T) {
|
||||||
|
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
||||||
|
Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
||||||
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
||||||
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
||||||
|
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
||||||
|
Artifact: domain.Artifact{Body: []byte("ok")},
|
||||||
|
RawOutput: strings.Repeat("raw", 80),
|
||||||
|
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
||||||
|
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
|
"prompt_id":"p",
|
||||||
|
"inputs":{"x":{"type":"file","uri":"a"}},
|
||||||
|
"include_raw_output":true
|
||||||
|
}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assertHTTPErrorCode(t, w, http.StatusRequestEntityTooLarge, "response_too_large")
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerMissingPromptID(t *testing.T) {
|
func TestHandlerMissingPromptID(t *testing.T) {
|
||||||
h := NewHandler(&fakeRunner{})
|
h := NewHandler(&fakeRunner{})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`))
|
||||||
@@ -770,7 +851,13 @@ func wrap(stage error, cause error) error {
|
|||||||
func newArtifactRootHandler(t *testing.T, root string) *Handler {
|
func newArtifactRootHandler(t *testing.T, root string) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
reader, err := artifact.NewRestrictedCompositeReader(root)
|
return newArtifactRootHandlerWithLimit(t, root, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected restricted artifact reader: %v", err)
|
t.Fatalf("expected restricted artifact reader: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"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/domain"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,6 +20,7 @@ var (
|
|||||||
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")
|
ErrFileNotAllowed = errors.New("file artifact references are not allowed")
|
||||||
ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root")
|
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.
|
||||||
@@ -40,7 +42,11 @@ func NewCompositeReader() Reader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRestrictedCompositeReader(root string) (Reader, error) {
|
func NewRestrictedCompositeReader(root string) (Reader, error) {
|
||||||
fileReader, err := newRestrictedFileReader(root)
|
return NewRestrictedCompositeReaderWithLimit(root, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRestrictedCompositeReaderWithLimit(root string, maxBytes int64) (Reader, error) {
|
||||||
|
fileReader, err := newRestrictedFileReader(root, maxBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -122,10 +128,14 @@ func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*do
|
|||||||
}
|
}
|
||||||
|
|
||||||
type restrictedFileReader struct {
|
type restrictedFileReader struct {
|
||||||
root string
|
root string
|
||||||
|
maxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRestrictedFileReader(root string) (Reader, error) {
|
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)
|
cleanRoot := strings.TrimSpace(root)
|
||||||
if cleanRoot == "" {
|
if cleanRoot == "" {
|
||||||
return deniedFileReader{}, nil
|
return deniedFileReader{}, nil
|
||||||
@@ -134,7 +144,7 @@ func newRestrictedFileReader(root string) (Reader, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
return nil, fmt.Errorf("resolve artifact root: %w", err)
|
||||||
}
|
}
|
||||||
return &restrictedFileReader{root: absRoot}, nil
|
return &restrictedFileReader{root: absRoot, maxBytes: maxBytes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
@@ -152,7 +162,7 @@ func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return readFileArtifact(path)
|
return readFileArtifactWithLimit(path, r.maxBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *restrictedFileReader) resolve(rawPath string) (string, error) {
|
func (r *restrictedFileReader) resolve(rawPath string) (string, error) {
|
||||||
@@ -181,10 +191,39 @@ func (r *restrictedFileReader) resolve(rawPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readFileArtifact(path string) (*domain.Artifact, error) {
|
func readFileArtifact(path string) (*domain.Artifact, error) {
|
||||||
data, err := os.ReadFile(path)
|
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)
|
||||||
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
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 == "" {
|
||||||
|
|||||||
@@ -132,6 +132,54 @@ func TestRestrictedCompositeReaderWithoutRootDeniesFileRefs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Addr string `yaml:"addr"`
|
Addr string `yaml:"addr"`
|
||||||
ArtifactRoot string `yaml:"artifact_root"`
|
ArtifactRoot string `yaml:"artifact_root"`
|
||||||
|
MaxRequestBytes *int64 `yaml:"max_request_bytes"`
|
||||||
|
MaxArtifactBytes *int64 `yaml:"max_artifact_bytes"`
|
||||||
|
MaxResponseBytes *int64 `yaml:"max_response_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultsConfig struct {
|
type DefaultsConfig struct {
|
||||||
@@ -55,17 +58,23 @@ type AppSettings struct {
|
|||||||
SchemaDir string
|
SchemaDir string
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
ArtifactRoot string
|
ArtifactRoot string
|
||||||
|
MaxRequestBytes int64
|
||||||
|
MaxArtifactBytes int64
|
||||||
|
MaxResponseBytes int64
|
||||||
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLIOverrides can be applied after config load to enforce precedence.
|
// CLIOverrides can be applied after config load to enforce precedence.
|
||||||
type CLIOverrides struct {
|
type CLIOverrides struct {
|
||||||
PromptDir string
|
PromptDir string
|
||||||
ProfileDir string
|
ProfileDir string
|
||||||
SchemaDir string
|
SchemaDir string
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
ArtifactRoot string
|
ArtifactRoot string
|
||||||
RenderFormat string
|
MaxRequestBytes *int64
|
||||||
|
MaxArtifactBytes *int64
|
||||||
|
MaxResponseBytes *int64
|
||||||
|
RenderFormat string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuiltInDefaults returns compile-time application defaults.
|
// BuiltInDefaults returns compile-time application defaults.
|
||||||
@@ -73,6 +82,9 @@ func BuiltInDefaults() AppSettings {
|
|||||||
return AppSettings{
|
return AppSettings{
|
||||||
SchemaDir: defaults.SchemaDirDefault,
|
SchemaDir: defaults.SchemaDirDefault,
|
||||||
ServerAddr: defaults.HTTPAddrDefault,
|
ServerAddr: defaults.HTTPAddrDefault,
|
||||||
|
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
|
||||||
|
MaxArtifactBytes: defaults.HTTPMaxArtifactBytesDefault,
|
||||||
|
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
|
||||||
DefaultRenderFormat: renderformat.DefaultPreparedRunOutputFormat,
|
DefaultRenderFormat: renderformat.DefaultPreparedRunOutputFormat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +160,24 @@ func ApplyCLIOverrides(base AppSettings, overrides CLIOverrides) (AppSettings, e
|
|||||||
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
|
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
|
||||||
out.ArtifactRoot = filepath.Clean(v)
|
out.ArtifactRoot = filepath.Clean(v)
|
||||||
}
|
}
|
||||||
|
if overrides.MaxRequestBytes != nil {
|
||||||
|
if *overrides.MaxRequestBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxRequestBytes = *overrides.MaxRequestBytes
|
||||||
|
}
|
||||||
|
if overrides.MaxArtifactBytes != nil {
|
||||||
|
if *overrides.MaxArtifactBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxArtifactBytes = *overrides.MaxArtifactBytes
|
||||||
|
}
|
||||||
|
if overrides.MaxResponseBytes != nil {
|
||||||
|
if *overrides.MaxResponseBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxResponseBytes = *overrides.MaxResponseBytes
|
||||||
|
}
|
||||||
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
|
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,6 +220,24 @@ func applyConfig(base AppSettings, cfg Config) (AppSettings, error) {
|
|||||||
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
|
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
|
||||||
out.ArtifactRoot = filepath.Clean(v)
|
out.ArtifactRoot = filepath.Clean(v)
|
||||||
}
|
}
|
||||||
|
if cfg.Server.MaxRequestBytes != nil {
|
||||||
|
if *cfg.Server.MaxRequestBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxRequestBytes = *cfg.Server.MaxRequestBytes
|
||||||
|
}
|
||||||
|
if cfg.Server.MaxArtifactBytes != nil {
|
||||||
|
if *cfg.Server.MaxArtifactBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxArtifactBytes = *cfg.Server.MaxArtifactBytes
|
||||||
|
}
|
||||||
|
if cfg.Server.MaxResponseBytes != nil {
|
||||||
|
if *cfg.Server.MaxResponseBytes < 0 {
|
||||||
|
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
out.MaxResponseBytes = *cfg.Server.MaxResponseBytes
|
||||||
|
}
|
||||||
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
|
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +25,20 @@ func TestLoadConfigMissingImplicitPathUsesBuiltInDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuiltInDefaultsIncludeHTTPSizeLimits(t *testing.T) {
|
||||||
|
got := BuiltInDefaults()
|
||||||
|
|
||||||
|
if got.MaxRequestBytes != defaults.HTTPMaxRequestBytesDefault {
|
||||||
|
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
|
||||||
|
}
|
||||||
|
if got.MaxArtifactBytes != defaults.HTTPMaxArtifactBytesDefault {
|
||||||
|
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
|
||||||
|
}
|
||||||
|
if got.MaxResponseBytes != defaults.HTTPMaxResponseBytesDefault {
|
||||||
|
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) {
|
func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
missing := filepath.Join(tmp, "missing.yml")
|
missing := filepath.Join(tmp, "missing.yml")
|
||||||
@@ -93,6 +108,9 @@ schema_dir: ./schemas
|
|||||||
server:
|
server:
|
||||||
addr: 127.0.0.1:9090
|
addr: 127.0.0.1:9090
|
||||||
artifact_root: ./artifacts
|
artifact_root: ./artifacts
|
||||||
|
max_request_bytes: 1024
|
||||||
|
max_artifact_bytes: 2048
|
||||||
|
max_response_bytes: 4096
|
||||||
defaults:
|
defaults:
|
||||||
render_format: json
|
render_format: json
|
||||||
`)
|
`)
|
||||||
@@ -117,11 +135,58 @@ defaults:
|
|||||||
if got.ArtifactRoot != filepath.Clean("./artifacts") {
|
if got.ArtifactRoot != filepath.Clean("./artifacts") {
|
||||||
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
|
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
|
||||||
}
|
}
|
||||||
|
if got.MaxRequestBytes != 1024 {
|
||||||
|
t.Fatalf("unexpected server.max_request_bytes: %d", got.MaxRequestBytes)
|
||||||
|
}
|
||||||
|
if got.MaxArtifactBytes != 2048 {
|
||||||
|
t.Fatalf("unexpected server.max_artifact_bytes: %d", got.MaxArtifactBytes)
|
||||||
|
}
|
||||||
|
if got.MaxResponseBytes != 4096 {
|
||||||
|
t.Fatalf("unexpected server.max_response_bytes: %d", got.MaxResponseBytes)
|
||||||
|
}
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
|
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
|
||||||
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
|
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigAcceptsZeroHTTPSizeLimits(t *testing.T) {
|
||||||
|
path := writeConfigFile(t, "config.yml", `
|
||||||
|
server:
|
||||||
|
max_request_bytes: 0
|
||||||
|
max_artifact_bytes: 0
|
||||||
|
max_response_bytes: 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
got, err := LoadConfig(path, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got.MaxRequestBytes != 0 || got.MaxArtifactBytes != 0 || got.MaxResponseBytes != 0 {
|
||||||
|
t.Fatalf("expected zero limits to be preserved, got request=%d artifact=%d response=%d", got.MaxRequestBytes, got.MaxArtifactBytes, got.MaxResponseBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsNegativeHTTPSizeLimits(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "request", body: "server:\n max_request_bytes: -1\n"},
|
||||||
|
{name: "artifact", body: "server:\n max_artifact_bytes: -1\n"},
|
||||||
|
{name: "response", body: "server:\n max_response_bytes: -1\n"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
path := writeConfigFile(t, "config.yml", tc.body)
|
||||||
|
_, err := LoadConfig(path, true)
|
||||||
|
if !errors.Is(err, ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) {
|
func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) {
|
||||||
path := writeConfigFile(t, "config.yml", "")
|
path := writeConfigFile(t, "config.yml", "")
|
||||||
|
|
||||||
@@ -190,16 +255,25 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|||||||
SchemaDir: "/from/config/schemas",
|
SchemaDir: "/from/config/schemas",
|
||||||
ServerAddr: ":1234",
|
ServerAddr: ":1234",
|
||||||
ArtifactRoot: "/from/config/artifacts",
|
ArtifactRoot: "/from/config/artifacts",
|
||||||
|
MaxRequestBytes: 111,
|
||||||
|
MaxArtifactBytes: 222,
|
||||||
|
MaxResponseBytes: 333,
|
||||||
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
|
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
|
||||||
}
|
}
|
||||||
|
maxRequestBytes := int64(0)
|
||||||
|
maxArtifactBytes := int64(444)
|
||||||
|
maxResponseBytes := int64(555)
|
||||||
|
|
||||||
got, err := ApplyCLIOverrides(base, CLIOverrides{
|
got, err := ApplyCLIOverrides(base, CLIOverrides{
|
||||||
PromptDir: "./prompts-cli",
|
PromptDir: "./prompts-cli",
|
||||||
ProfileDir: "./profiles-cli",
|
ProfileDir: "./profiles-cli",
|
||||||
SchemaDir: "./schemas-cli",
|
SchemaDir: "./schemas-cli",
|
||||||
ServerAddr: ":8081",
|
ServerAddr: ":8081",
|
||||||
ArtifactRoot: "./artifacts-cli",
|
ArtifactRoot: "./artifacts-cli",
|
||||||
RenderFormat: "text",
|
MaxRequestBytes: &maxRequestBytes,
|
||||||
|
MaxArtifactBytes: &maxArtifactBytes,
|
||||||
|
MaxResponseBytes: &maxResponseBytes,
|
||||||
|
RenderFormat: "text",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -220,11 +294,42 @@ func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|||||||
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
|
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
|
||||||
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
|
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
|
||||||
}
|
}
|
||||||
|
if got.MaxRequestBytes != 0 {
|
||||||
|
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
|
||||||
|
}
|
||||||
|
if got.MaxArtifactBytes != 444 {
|
||||||
|
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
|
||||||
|
}
|
||||||
|
if got.MaxResponseBytes != 555 {
|
||||||
|
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
|
||||||
|
}
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
|
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
|
||||||
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
|
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCLIOverridesRejectsNegativeHTTPSizeLimits(t *testing.T) {
|
||||||
|
negative := int64(-1)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
overrides CLIOverrides
|
||||||
|
}{
|
||||||
|
{name: "request", overrides: CLIOverrides{MaxRequestBytes: &negative}},
|
||||||
|
{name: "artifact", overrides: CLIOverrides{MaxArtifactBytes: &negative}},
|
||||||
|
{name: "response", overrides: CLIOverrides{MaxResponseBytes: &negative}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := ApplyCLIOverrides(BuiltInDefaults(), tc.overrides)
|
||||||
|
if !errors.Is(err, ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) {
|
func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) {
|
||||||
_, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"})
|
_, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HTTPAddrDefault = ":8080"
|
HTTPAddrDefault = ":8080"
|
||||||
SchemaDirDefault = "."
|
SchemaDirDefault = "."
|
||||||
OutputArtifactName = "output"
|
OutputArtifactName = "output"
|
||||||
ContentTypeTextPlain = "text/plain"
|
ContentTypeTextPlain = "text/plain"
|
||||||
ContentTypeTextMarkdown = "text/markdown"
|
ContentTypeTextMarkdown = "text/markdown"
|
||||||
ContentTypeApplicationJSON = "application/json"
|
ContentTypeApplicationJSON = "application/json"
|
||||||
OpenAIChatCompletionsPath = "/chat/completions"
|
OpenAIChatCompletionsPath = "/chat/completions"
|
||||||
|
HTTPMaxRequestBytesDefault = 16 * 1024 * 1024
|
||||||
|
HTTPMaxArtifactBytesDefault = 16 * 1024 * 1024
|
||||||
|
HTTPMaxResponseBytesDefault = 16 * 1024 * 1024
|
||||||
|
|
||||||
ExecutionDefaultTemperature = 0.0
|
ExecutionDefaultTemperature = 0.0
|
||||||
ExecutionDefaultMaxTokens = 0
|
ExecutionDefaultMaxTokens = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user