diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 00af7af..6079252 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -206,7 +206,7 @@ func serveCommand(args []string, stderr io.Writer) int { srv := &http.Server{ Addr: cfg.addr, Handler: h, - ReadHeaderTimeout: 10 * time.Second, + ReadHeaderTimeout: defaults.HTTPReadHeaderTimeoutDefault, } fmt.Fprintf(stderr, "serving on %s\n", cfg.addr) @@ -397,5 +397,5 @@ func printSummary(stderr io.Writer, res *domain.RunResult) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: scriptorium ...") fmt.Fprintln(w, " run: scriptorium run --prompt-dir DIR --profile-dir DIR --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]") - fmt.Fprintln(w, " serve: scriptorium serve --addr :8080 --prompt-dir DIR --profile-dir DIR [--llm-base-url URL] [--schema-dir DIR] [--model NAME] [--timeout 10m]") + fmt.Fprintf(w, " serve: scriptorium serve --addr %s --prompt-dir DIR --profile-dir DIR [--llm-base-url URL] [--schema-dir DIR] [--model NAME] [--timeout 10m]\n", defaults.HTTPAddrDefault) } diff --git a/internal/artifact/reader.go b/internal/artifact/reader.go index 1337359..5863316 100644 --- a/internal/artifact/reader.go +++ b/internal/artifact/reader.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" + "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "mime" "os" @@ -67,7 +68,7 @@ func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domai body := []byte(ref.Body) return &domain.Artifact{ - ContentType: "text/plain", + ContentType: defaults.ContentTypeTextPlain, Body: body, Size: int64(len(body)), Hash: fmt.Sprintf("%x", sha256.Sum256(body)), @@ -95,7 +96,7 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain. contentType := mime.TypeByExtension(filepath.Ext(ref.URI)) if contentType == "" { - contentType = "text/plain" // Default + contentType = defaults.ContentTypeTextPlain } return &domain.Artifact{ diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go index 9dc8803..c0d8c85 100644 --- a/internal/defaults/defaults.go +++ b/internal/defaults/defaults.go @@ -13,6 +13,7 @@ const ( ContentTypeTextPlain = "text/plain" ContentTypeTextMarkdown = "text/markdown" ContentTypeApplicationJSON = "application/json" + OpenAIChatCompletionsPath = "/chat/completions" ExecutionDefaultTemperature = 0.0 ExecutionDefaultMaxTokens = 0 @@ -21,7 +22,8 @@ const ( ) var ( - LLMRequestTimeoutDefault = 10 * time.Minute + LLMRequestTimeoutDefault = 10 * time.Minute + HTTPReadHeaderTimeoutDefault = 10 * time.Second ) func ExecutionTargetDefault() domain.ExecutionTarget { diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 5e98ffe..c80bb2d 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -90,7 +90,7 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera if endpoint == "" { return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest) } - endpoint = strings.TrimRight(endpoint, "/") + "/chat/completions" + endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath wireReq := openAIChatRequest{ Model: model, diff --git a/internal/usecase/runner_test.go b/internal/usecase/runner_test.go index 1dc37fe..a6d9a40 100644 --- a/internal/usecase/runner_test.go +++ b/internal/usecase/runner_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/validate" ) @@ -164,6 +165,12 @@ func TestRunnerRunSuccessful(t *testing.T) { if res.EffectiveModelParams.Model != "m" || res.Endpoint != "http://override/v1" { t.Fatalf("unexpected model params: %+v", res.EffectiveModelParams) } + if res.Artifact.Name != defaults.OutputArtifactName { + t.Fatalf("expected default output artifact name %q, got %q", defaults.OutputArtifactName, res.Artifact.Name) + } + if res.Artifact.ContentType != defaults.ContentTypeTextMarkdown { + t.Fatalf("expected markdown content type %q, got %q", defaults.ContentTypeTextMarkdown, res.Artifact.ContentType) + } if res.RawOutput != "# recap" { t.Fatalf("expected raw output, got %q", res.RawOutput) } @@ -323,6 +330,36 @@ func TestRunnerRunSelectedProfileBeatsBuiltInDefault(t *testing.T) { } } +func TestRunnerRunBuiltInDefaultsUsedWhenProfileOmitsOptionalFields(t *testing.T) { + promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)} + execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{ + "exec": {ID: "exec", Endpoint: "http://profile/v1", Model: "profile-model"}, + }} + llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "ok"}} + runner := NewRunner(promptRepo, execRepo, defaultArtifactReader(), defaultRenderer(), llmClient, nil) + + res, err := runner.Run(context.Background(), domain.RunRequest{ + PromptID: "p", + ProfileID: "exec", + Inputs: singleInputRef(), + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.EffectiveModelParams.Temperature != defaults.ExecutionDefaultTemperature { + t.Fatalf("expected default temperature %v, got %v", defaults.ExecutionDefaultTemperature, res.EffectiveModelParams.Temperature) + } + if res.EffectiveModelParams.TopP != defaults.ExecutionDefaultTopP { + t.Fatalf("expected default top_p %v, got %v", defaults.ExecutionDefaultTopP, res.EffectiveModelParams.TopP) + } + if res.EffectiveModelParams.MaxTokens != defaults.ExecutionDefaultMaxTokens { + t.Fatalf("expected default max_tokens %d, got %d", defaults.ExecutionDefaultMaxTokens, res.EffectiveModelParams.MaxTokens) + } + if res.EffectiveModelParams.TimeoutSeconds != defaults.ExecutionDefaultTimeoutSeconds { + t.Fatalf("expected default timeout_seconds %d, got %d", defaults.ExecutionDefaultTimeoutSeconds, res.EffectiveModelParams.TimeoutSeconds) + } +} + func TestRunnerRunAPIKeyEnvResolvesFromEnvironment(t *testing.T) { t.Setenv("SCRIPTORIUM_TEST_API_KEY", "secret") promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)} @@ -506,6 +543,30 @@ func TestRunnerRunStructuredRepairRemainsBoundedAndUsesEffectiveModelSettings(t } } +func TestBuildOutputArtifactDefaults(t *testing.T) { + tests := []struct { + name string + format domain.OutputFormat + contentType string + }{ + {name: "text", format: domain.FormatText, contentType: defaults.ContentTypeTextPlain}, + {name: "markdown", format: domain.FormatMarkdown, contentType: defaults.ContentTypeTextMarkdown}, + {name: "json", format: domain.FormatJSON, contentType: defaults.ContentTypeApplicationJSON}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + art := buildOutputArtifact("body", tc.format) + if art.Name != defaults.OutputArtifactName { + t.Fatalf("expected artifact name %q, got %q", defaults.OutputArtifactName, art.Name) + } + if art.ContentType != tc.contentType { + t.Fatalf("expected content type %q, got %q", tc.contentType, art.ContentType) + } + }) + } +} + func promptDef(format domain.OutputFormat, mode domain.ValidationMode, attempts int) *domain.PromptDefinition { return &domain.PromptDefinition{ ID: "p",