Switch config to Scriptorium profiles

This commit is contained in:
2026-07-05 18:03:23 +00:00
parent 49d94cc2e9
commit 0fc740470f
22 changed files with 319 additions and 797 deletions

View File

@@ -1,9 +1,4 @@
version: 1 version: 2
llm_profiles:
default:
provider: openai-compatible
base_url: http://127.0.0.1:1
model: fake-model
pipelines: pipelines:
dnd-session: dnd-session:
input: seriatim input: seriatim

View File

@@ -8,7 +8,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/generic" "gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/generic"
@@ -130,47 +129,5 @@ func productionLLMClientFactory(ctx context.Context, cfg config.Config, profileI
return nil, nil, err return nil, nil, err
} }
trimmedID := strings.TrimSpace(profileID) trimmedID := strings.TrimSpace(profileID)
if trimmedID == "" { return nil, nil, fmt.Errorf("create Scriptorium-backed LLM client for profile %q: not implemented yet", trimmedID)
trimmedID = pipeline.DefaultLLMProfile
}
profile, ok := cfg.LLMProfile(trimmedID)
if !ok {
return nil, nil, fmt.Errorf("LLM profile %q is not configured", trimmedID)
}
clientCfg, err := cfg.OpenAICompatibleClientConfig(trimmedID)
if err != nil {
return nil, nil, err
}
client, err := llm.NewOpenAICompatibleClient(clientCfg)
if err != nil {
return nil, nil, fmt.Errorf("create LLM client for profile %q: %w", trimmedID, err)
}
scheduler, err := llm.NewScheduler(effectiveLLMConcurrency(cfg, profile))
if err != nil {
return nil, nil, fmt.Errorf("create LLM scheduler for profile %q: %w", trimmedID, err)
}
provider := strings.TrimSpace(profile.Provider)
if provider == "" {
provider = "openai-compatible"
}
metadata := []artifacts.LLMProfileManifest{
{
ID: trimmedID,
Provider: provider,
Model: strings.TrimSpace(profile.Model),
},
}
return llm.NewScheduledClient(client, scheduler), metadata, nil
}
func effectiveLLMConcurrency(cfg config.Config, profile config.LLMProfile) int {
if profile.MaxConcurrency > 0 {
return profile.MaxConcurrency
}
if cfg.Concurrency.TotalLLM > 0 {
return cfg.Concurrency.TotalLLM
}
return 1
} }

View File

@@ -194,6 +194,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
} }
profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline)
if err := validateExplicitScriptoriumProfiles(context.Background(), effective.Config, profileIDs); err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
}
workingDir, err := os.Getwd() workingDir, err := os.Getwd()
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("resolve working directory: %w", err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("resolve working directory: %w", err))
@@ -220,11 +224,6 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err))
} }
profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline)
if len(profileIDs) != 1 {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s", pipelineID, len(profileIDs), strings.Join(profileIDs, ", ")))
}
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath)) rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
@@ -236,9 +235,13 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
} }
ctx := context.Background() ctx := context.Background()
llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, profileIDs[0]) factoryProfileID := ""
if len(profileIDs) == 1 {
factoryProfileID = profileIDs[0]
}
llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, factoryProfileID)
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", profileIDs[0], err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", factoryProfileID, err))
} }
output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{ output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{
@@ -571,11 +574,16 @@ func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) in
fmt.Fprintf(stderr, "notarius: %v\n", err) fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1 return 1
} }
if _, err := cfg.Resolve(config.ResolveInput{ effective, err := cfg.Resolve(config.ResolveInput{
PipelineID: *pipelineID, PipelineID: *pipelineID,
Only: only, Only: only,
Catalog: catalog, Catalog: catalog,
}); err != nil { })
if err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1
}
if err := validateExplicitScriptoriumProfiles(context.Background(), effective.Config, effectiveLLMProfileIDs(effective.ResolvedPipeline)); err != nil {
fmt.Fprintf(stderr, "notarius: %v\n", err) fmt.Fprintf(stderr, "notarius: %v\n", err)
return 1 return 1
} }

View File

@@ -228,7 +228,7 @@ func TestRunConfigValidateUnknownProductionModuleIncludesContext(t *testing.T) {
} }
func TestRunConfigValidateReportsParseErrors(t *testing.T) { func TestRunConfigValidateReportsParseErrors(t *testing.T) {
configPath := writeFile(t, "config.yml", "version: 2\n") configPath := writeFile(t, "config.yml", "version: 1\n")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -372,11 +372,10 @@ func TestRunUsesNotariusConfigWhenConfigFlagAbsent(t *testing.T) {
} }
} }
func TestRunConfigValidateResolvesAPIKeyEnvThroughOptions(t *testing.T) { func TestRunConfigValidateRejectsStaleLLMProfiles(t *testing.T) {
configPath := writeTestConfig(t, `version: 1 configPath := writeTestConfig(t, `version: 2
llm_profiles: llm_profiles:
default: default: {}
api_key_env: NOTARIUS_TEST_API_KEY
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -387,12 +386,13 @@ pipelines:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{ code := RunWithOptions([]string{"config", "validate", "--config", configPath}, &stdout, &stderr, Options{})
LookupEnv: mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"}),
})
if code != 0 { if code != 1 {
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String()) t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "llm_profiles") {
t.Fatalf("stderr = %q, want stale llm_profiles error", stderr.String())
} }
} }
@@ -445,80 +445,15 @@ func TestRunInvalidFlagsExitTwo(t *testing.T) {
} }
} }
func TestProductionLLMClientFactoryRejectsMissingProfile(t *testing.T) { func TestProductionLLMClientFactoryReportsPendingScriptoriumRuntime(t *testing.T) {
cfg := config.Default() cfg := config.Default()
_, _, err := productionLLMClientFactory(context.Background(), cfg, "missing") _, _, err := productionLLMClientFactory(context.Background(), cfg, "mistral-small-3")
if err == nil { if err == nil {
t.Fatal("productionLLMClientFactory() error = nil, want error") t.Fatal("productionLLMClientFactory() error = nil, want error")
} }
if !strings.Contains(err.Error(), "LLM profile") || !strings.Contains(err.Error(), "missing") { if !strings.Contains(err.Error(), "Scriptorium-backed LLM client") || !strings.Contains(err.Error(), "not implemented yet") {
t.Fatalf("error = %q, want missing profile context", err.Error()) t.Fatalf("error = %q, want pending Scriptorium runtime context", err.Error())
}
}
func TestProductionLLMClientFactoryRejectsInvalidProfile(t *testing.T) {
tests := []struct {
name string
profile config.LLMProfile
want string
}{
{
name: "unsupported provider",
profile: config.LLMProfile{Provider: "other", BaseURL: "https://example.test", Model: "model"},
want: "not supported",
},
{
name: "missing base url",
profile: config.LLMProfile{Provider: "openai-compatible", Model: "model"},
want: "base URL",
},
{
name: "missing model",
profile: config.LLMProfile{Provider: "openai-compatible", BaseURL: "https://example.test"},
want: "model",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := config.Default()
cfg.LLMProfiles = map[string]config.LLMProfile{"default": test.profile}
_, _, err := productionLLMClientFactory(context.Background(), cfg, "default")
if err == nil {
t.Fatal("productionLLMClientFactory() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %q, want substring %q", err.Error(), test.want)
}
})
}
}
func TestProductionLLMClientFactoryReturnsScheduledClientAndManifestMetadata(t *testing.T) {
cfg := config.Default()
cfg.LLMProfiles = map[string]config.LLMProfile{
"default": {
Provider: "openai-compatible",
BaseURL: "https://example.test",
Model: "model-a",
MaxConcurrency: 2,
},
}
client, metadata, err := productionLLMClientFactory(context.Background(), cfg, "default")
if err != nil {
t.Fatalf("productionLLMClientFactory() error = %v, want nil", err)
}
if client == nil {
t.Fatal("client = nil, want scheduled client")
}
if len(metadata) != 1 {
t.Fatalf("len(metadata) = %d, want 1", len(metadata))
}
if metadata[0].ID != "default" || metadata[0].Provider != "openai-compatible" || metadata[0].Model != "model-a" {
t.Fatalf("metadata = %#v, want profile-safe model metadata", metadata)
} }
} }
@@ -718,7 +653,8 @@ func TestRunPipelineValidationRejectionCompletesSuccessfully(t *testing.T) {
} }
func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) { func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAMLWithProfiles("dnd-session")) profilePath := writeScriptoriumProfileFile(t, "runtime", "http://profile.test/v1", "test-model")
configPath := writeTestConfig(t, mvpConfigYAMLWithProfileFile("dnd-session", profilePath))
inputPath := writeSeriatimInput(t) inputPath := writeSeriatimInput(t)
outputDir := t.TempDir() outputDir := t.TempDir()
diagnosticsDir := t.TempDir() diagnosticsDir := t.TempDir()
@@ -739,6 +675,35 @@ func TestRunPipelineLLMProfileOverrideSelectsFactoryProfile(t *testing.T) {
} }
} }
func TestRunConfigValidateRejectsUnknownExplicitScriptoriumProfile(t *testing.T) {
profilePath := writeScriptoriumProfileFile(t, "known", "http://profile.test/v1", "test-model")
configPath := writeTestConfig(t, `version: 2
scriptorium:
profile_file: `+profilePath+`
pipelines:
example:
input: fake/input
artifacts:
events:
extract:
module: fake/extract
llm_profile: missing
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{
Catalog: fakeCatalog(t),
})
if code != 1 {
t.Fatalf("RunWithOptions() code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "Scriptorium profile") || !strings.Contains(stderr.String(), "missing") {
t.Fatalf("stderr = %q, want unknown Scriptorium profile", stderr.String())
}
}
func TestRunPipelineSessionIDFlagRecordsExplicitTrimmedValue(t *testing.T) { func TestRunPipelineSessionIDFlagRecordsExplicitTrimmedValue(t *testing.T) {
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells")) configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
inputPath := writeSeriatimInput(t) inputPath := writeSeriatimInput(t)
@@ -2117,7 +2082,7 @@ func testConfigYAML(pipelineID string, laneIDs ...string) string {
func testConfigYAMLForPipelines(pipelines map[string][]string) string { func testConfigYAMLForPipelines(pipelines map[string][]string) string {
var b strings.Builder var b strings.Builder
b.WriteString("version: 1\n") b.WriteString("version: 2\n")
b.WriteString("pipelines:\n") b.WriteString("pipelines:\n")
for pipelineID, laneIDs := range pipelines { for pipelineID, laneIDs := range pipelines {
b.WriteString(" " + pipelineID + ":\n") b.WriteString(" " + pipelineID + ":\n")
@@ -2133,7 +2098,7 @@ func testConfigYAMLForPipelines(pipelines map[string][]string) string {
func testConfigYAMLWithReferences(pipelineID string, laneID string, references map[string]string) string { func testConfigYAMLWithReferences(pipelineID string, laneID string, references map[string]string) string {
var b strings.Builder var b strings.Builder
b.WriteString("version: 1\n") b.WriteString("version: 2\n")
b.WriteString("pipelines:\n") b.WriteString("pipelines:\n")
b.WriteString(" " + pipelineID + ":\n") b.WriteString(" " + pipelineID + ":\n")
b.WriteString(" input: fake/input\n") b.WriteString(" input: fake/input\n")
@@ -2154,7 +2119,7 @@ func testConfigYAMLWithReferences(pipelineID string, laneID string, references m
func testConfigYAMLWithPipelineReferences(pipelineID string, laneID string, references map[string]string) string { func testConfigYAMLWithPipelineReferences(pipelineID string, laneID string, references map[string]string) string {
var b strings.Builder var b strings.Builder
b.WriteString("version: 1\n") b.WriteString("version: 2\n")
b.WriteString("pipelines:\n") b.WriteString("pipelines:\n")
b.WriteString(" " + pipelineID + ":\n") b.WriteString(" " + pipelineID + ":\n")
b.WriteString(" input: fake/input\n") b.WriteString(" input: fake/input\n")
@@ -2175,7 +2140,7 @@ func testConfigYAMLWithPipelineReferences(pipelineID string, laneID string, refe
func testConfigYAMLWithReferencesAndDiagnostics(pipelineID string, laneID string, diagnosticsDir string, references map[string]string) string { func testConfigYAMLWithReferencesAndDiagnostics(pipelineID string, laneID string, diagnosticsDir string, references map[string]string) string {
var b strings.Builder var b strings.Builder
b.WriteString("version: 1\n") b.WriteString("version: 2\n")
b.WriteString("diagnostics:\n") b.WriteString("diagnostics:\n")
b.WriteString(" work_dir: " + diagnosticsDir + "\n") b.WriteString(" work_dir: " + diagnosticsDir + "\n")
b.WriteString(" retention: always\n") b.WriteString(" retention: always\n")
@@ -2198,7 +2163,7 @@ func testConfigYAMLWithReferencesAndDiagnostics(pipelineID string, laneID string
} }
func mvpConfigYAML(pipelineID string, extractor string) string { func mvpConfigYAML(pipelineID string, extractor string) string {
return `version: 1 return `version: 2
pipelines: pipelines:
` + pipelineID + `: ` + pipelineID + `:
input: seriatim input: seriatim
@@ -2209,7 +2174,7 @@ pipelines:
} }
func mvpConfigYAMLWithChunk(pipelineID string, chunker string, extractor string) string { func mvpConfigYAMLWithChunk(pipelineID string, chunker string, extractor string) string {
return `version: 1 return `version: 2
pipelines: pipelines:
` + pipelineID + `: ` + pipelineID + `:
input: seriatim input: seriatim
@@ -2222,7 +2187,7 @@ pipelines:
func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string { func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string {
var b strings.Builder var b strings.Builder
b.WriteString("version: 1\n") b.WriteString("version: 2\n")
b.WriteString("pipelines:\n") b.WriteString("pipelines:\n")
b.WriteString(" " + pipelineID + ":\n") b.WriteString(" " + pipelineID + ":\n")
b.WriteString(" input: seriatim\n") b.WriteString(" input: seriatim\n")
@@ -2234,13 +2199,10 @@ func mvpConfigYAMLForLanes(pipelineID string, laneIDs ...string) string {
return b.String() return b.String()
} }
func mvpConfigYAMLWithProfiles(pipelineID string) string { func mvpConfigYAMLWithProfileFile(pipelineID string, profileFile string) string {
return `version: 1 return `version: 2
llm_profiles: scriptorium:
default: profile_file: ` + profileFile + `
provider: openai-compatible
runtime:
provider: openai-compatible
pipelines: pipelines:
` + pipelineID + `: ` + pipelineID + `:
input: seriatim input: seriatim
@@ -2250,8 +2212,16 @@ pipelines:
` `
} }
func writeScriptoriumProfileFile(t *testing.T, id string, endpoint string, model string) string {
t.Helper()
return writeFile(t, id+".profile.yml", `id: `+id+`
endpoint: `+endpoint+`
model: `+model+`
`)
}
func mvpConfigYAMLWithDiagnostics(pipelineID, diagnosticsDir, retention string) string { func mvpConfigYAMLWithDiagnostics(pipelineID, diagnosticsDir, retention string) string {
return `version: 1 return `version: 2
diagnostics: diagnostics:
work_dir: ` + diagnosticsDir + ` work_dir: ` + diagnosticsDir + `
retention: ` + retention + ` retention: ` + retention + `

View File

@@ -0,0 +1,68 @@
package cli
import (
"context"
"errors"
"fmt"
"testing/fstest"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/scriptorium"
)
const profileCheckPromptID = "notarius.profile.check"
var profileCheckPromptFS = fstest.MapFS{
"prompts/profile-check.yaml": &fstest.MapFile{Data: []byte(`id: notarius.profile.check
version: "1.0.0"
default_profile: mistral-small-3
inputs:
- name: transcript
required: true
messages:
- role: user
content: "{{input \"transcript\"}}"
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
}
func validateExplicitScriptoriumProfiles(ctx context.Context, cfg config.Config, profileIDs []string) error {
if len(profileIDs) == 0 {
return nil
}
engine, err := newProfileValidationEngine(cfg)
if err != nil {
return fmt.Errorf("load Scriptorium profiles: %w", err)
}
for _, profileID := range profileIDs {
if _, err := engine.Prepare(ctx, scriptorium.RunRequest{
PromptID: profileCheckPromptID,
ProfileID: profileID,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("profile check"),
},
}); err != nil {
if errors.Is(err, scriptorium.ErrProfileNotFound) {
return fmt.Errorf("Scriptorium profile %q is not configured", profileID)
}
return fmt.Errorf("validate Scriptorium profile %q: %w", profileID, err)
}
}
return nil
}
func newProfileValidationEngine(cfg config.Config) (*scriptorium.Engine, error) {
opts := []scriptorium.Option{
scriptorium.WithPromptFS(profileCheckPromptFS, "prompts"),
}
if cfg.Scriptorium.ProfileFile != "" {
opts = append(opts, scriptorium.WithProfileFile(cfg.Scriptorium.ProfileFile))
}
return scriptorium.NewEngine(scriptorium.Config{
PromptDir: "unused",
ProfileDir: cfg.Scriptorium.ProfileDir,
}, opts...)
}

View File

@@ -5,24 +5,18 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
const SupportedFileConfigVersion = 1 const SupportedFileConfigVersion = 2
type Config struct { type Config struct {
LLMProfiles map[string]LLMProfile `json:"llm_profiles"` Scriptorium ScriptoriumConfig `json:"scriptorium,omitempty"`
Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"` Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"`
Concurrency ConcurrencyConfig `json:"concurrency"` Concurrency ConcurrencyConfig `json:"concurrency"`
Diagnostics DiagnosticsConfig `json:"diagnostics"` Diagnostics DiagnosticsConfig `json:"diagnostics"`
} }
type LLMProfile struct { type ScriptoriumConfig struct {
Provider string `json:"provider,omitempty"` ProfileDir string `json:"profile_dir,omitempty"`
BaseURL string `json:"base_url,omitempty"` ProfileFile string `json:"profile_file,omitempty"`
Model string `json:"model,omitempty"`
APIKey string `json:"api_key,omitempty"`
APIKeyEnv string `json:"api_key_env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
MaxConcurrency int `json:"max_concurrency,omitempty"`
} }
type ConcurrencyConfig struct { type ConcurrencyConfig struct {
@@ -36,14 +30,6 @@ type DiagnosticsConfig struct {
func Default() Config { func Default() Config {
return Config{ return Config{
LLMProfiles: map[string]LLMProfile{
pipeline.DefaultLLMProfile: {
Provider: "openai-compatible",
TimeoutSeconds: 600,
MaxRetries: 3,
MaxConcurrency: 1,
},
},
Pipelines: map[string]pipeline.PipelineProfile{}, Pipelines: map[string]pipeline.PipelineProfile{},
Concurrency: ConcurrencyConfig{ Concurrency: ConcurrencyConfig{
TotalLLM: 1, TotalLLM: 1,
@@ -57,10 +43,6 @@ func Default() Config {
func cloneConfig(in Config) Config { func cloneConfig(in Config) Config {
out := in out := in
out.LLMProfiles = make(map[string]LLMProfile, len(in.LLMProfiles))
for key, profile := range in.LLMProfiles {
out.LLMProfiles[key] = profile
}
out.Pipelines = make(map[string]pipeline.PipelineProfile, len(in.Pipelines)) out.Pipelines = make(map[string]pipeline.PipelineProfile, len(in.Pipelines))
for key, profile := range in.Pipelines { for key, profile := range in.Pipelines {
out.Pipelines[key] = clonePipelineProfile(profile) out.Pipelines[key] = clonePipelineProfile(profile)

View File

@@ -4,24 +4,13 @@ import (
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
func TestDefaultValues(t *testing.T) { func TestDefaultValues(t *testing.T) {
cfg := Default() cfg := Default()
defaultProfile, ok := cfg.LLMProfiles[pipeline.DefaultLLMProfile] if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
if !ok { t.Fatalf("unexpected Scriptorium profile source defaults: %+v", cfg.Scriptorium)
t.Fatalf("expected default LLM profile")
}
if defaultProfile.Provider != "openai-compatible" {
t.Fatalf("unexpected provider: %q", defaultProfile.Provider)
}
if defaultProfile.BaseURL != "" || defaultProfile.Model != "" {
t.Fatalf("default profile should not require base URL/model yet: %+v", defaultProfile)
}
if defaultProfile.TimeoutSeconds != 600 || defaultProfile.MaxRetries != 3 || defaultProfile.MaxConcurrency != 1 {
t.Fatalf("unexpected default LLM operational values: %+v", defaultProfile)
} }
if len(cfg.Pipelines) != 0 { if len(cfg.Pipelines) != 0 {
t.Fatalf("expected no built-in pipeline profiles, got %v", cfg.Pipelines) t.Fatalf("expected no built-in pipeline profiles, got %v", cfg.Pipelines)
@@ -39,10 +28,9 @@ func TestDefaultValues(t *testing.T) {
func TestApplyFileConfigMergesWithDefaults(t *testing.T) { func TestApplyFileConfigMergesWithDefaults(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(` fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
llm_profiles: scriptorium:
default: profile_dir: ./profiles
model: test-model
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -59,12 +47,8 @@ pipelines:
t.Fatalf("ApplyFileConfig: %v", err) t.Fatalf("ApplyFileConfig: %v", err)
} }
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile] if cfg.Scriptorium.ProfileDir != "./profiles" {
if profile.Model != "test-model" { t.Fatalf("expected Scriptorium profile dir, got %+v", cfg.Scriptorium)
t.Fatalf("expected file model, got %+v", profile)
}
if profile.Provider != "openai-compatible" || profile.TimeoutSeconds != 600 || profile.MaxRetries != 3 {
t.Fatalf("expected default LLM fields to be preserved, got %+v", profile)
} }
if cfg.Concurrency.TotalLLM != 1 { if cfg.Concurrency.TotalLLM != 1 {
t.Fatalf("expected default concurrency preserved, got %d", cfg.Concurrency.TotalLLM) t.Fatalf("expected default concurrency preserved, got %d", cfg.Concurrency.TotalLLM)

View File

@@ -3,9 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
@@ -44,9 +42,6 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
profile = clonePipelineProfile(profile) profile = clonePipelineProfile(profile)
profile.ID = pipelineID profile.ID = pipelineID
if override := strings.TrimSpace(input.LLMProfileOverride); override != "" { if override := strings.TrimSpace(input.LLMProfileOverride); override != "" {
if !hasLLMProfile(c.LLMProfiles, override) {
return EffectiveConfig{}, fmt.Errorf("LLM profile override %q is not configured", override)
}
applyLLMProfileOverride(&profile, override) applyLLMProfileOverride(&profile, override)
} }
@@ -93,36 +88,3 @@ func lookupPipelineProfile(profiles map[string]pipeline.PipelineProfile, pipelin
} }
return pipeline.PipelineProfile{}, false return pipeline.PipelineProfile{}, false
} }
func (c Config) OpenAICompatibleClientConfig(profileID string) (llm.OpenAICompatibleClientConfig, error) {
trimmedID := strings.TrimSpace(profileID)
profile, ok := c.LLMProfile(trimmedID)
if !ok {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q is not configured", trimmedID)
}
provider := strings.TrimSpace(profile.Provider)
if provider == "" {
provider = providerOpenAICompatible
}
if provider != providerOpenAICompatible {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q provider %q is not supported", trimmedID, provider)
}
baseURL := strings.TrimSpace(profile.BaseURL)
if baseURL == "" {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q base URL must not be empty", trimmedID)
}
model := strings.TrimSpace(profile.Model)
if model == "" {
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q model must not be empty", trimmedID)
}
return llm.OpenAICompatibleClientConfig{
BaseURL: baseURL,
Model: model,
APIKey: profile.APIKey,
MaxRetries: profile.MaxRetries,
RequestTimeout: time.Duration(profile.TimeoutSeconds) * time.Second,
}, nil
}

View File

@@ -3,7 +3,6 @@ package config
import ( import (
"strings" "strings"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
@@ -157,7 +156,6 @@ func TestResolveDigestChangesWhenEffectiveConfigChanges(t *testing.T) {
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) { func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
cfg := validConfig() cfg := validConfig()
cfg.LLMProfiles["runtime"] = LLMProfile{Provider: "openai-compatible"}
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)}) base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
if err != nil { if err != nil {
@@ -180,15 +178,6 @@ func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
t.Fatalf("binding profile = %q, want runtime", binding.LLMProfile) t.Fatalf("binding profile = %q, want runtime", binding.LLMProfile)
} }
} }
_, err = cfg.Resolve(ResolveInput{
PipelineID: "example",
Catalog: fakeCatalog(t),
LLMProfileOverride: "missing",
})
if err == nil || !strings.Contains(err.Error(), "LLM profile override") {
t.Fatalf("expected override profile error, got %v", err)
}
} }
func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding { func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
@@ -199,53 +188,3 @@ func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBindi
} }
return bindings return bindings
} }
func TestOpenAICompatibleClientConfigRejectsIncompleteDefaultProfile(t *testing.T) {
cfg := Default()
_, err := cfg.OpenAICompatibleClientConfig("default")
if err == nil || !strings.Contains(err.Error(), "base URL") {
t.Fatalf("expected incomplete profile error, got %v", err)
}
}
func TestOpenAICompatibleClientConfigSuccess(t *testing.T) {
cfg := validConfig()
profile := cfg.LLMProfiles["default"]
profile.APIKey = "secret"
profile.TimeoutSeconds = 45
profile.MaxRetries = 4
cfg.LLMProfiles["default"] = profile
llmCfg, err := cfg.OpenAICompatibleClientConfig(" default ")
if err != nil {
t.Fatalf("OpenAICompatibleClientConfig: %v", err)
}
if llmCfg.BaseURL != "https://example.invalid/v1" || llmCfg.Model != "test-model" || llmCfg.APIKey != "secret" {
t.Fatalf("unexpected client config strings: %+v", llmCfg)
}
if llmCfg.MaxRetries != 4 {
t.Fatalf("unexpected max retries: %d", llmCfg.MaxRetries)
}
if llmCfg.RequestTimeout != 45*time.Second {
t.Fatalf("unexpected timeout: %s", llmCfg.RequestTimeout)
}
}
func TestOpenAICompatibleClientConfigRejectsUnknownAndUnsupportedProfiles(t *testing.T) {
_, err := validConfig().OpenAICompatibleClientConfig("missing")
if err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected unknown profile error, got %v", err)
}
cfg := validConfig()
profile := cfg.LLMProfiles["default"]
profile.Provider = "unsupported"
cfg.LLMProfiles["default"] = profile
_, err = cfg.OpenAICompatibleClientConfig("default")
if err == nil || !strings.Contains(err.Error(), "provider") {
t.Fatalf("expected unsupported provider error, got %v", err)
}
}

View File

@@ -7,7 +7,6 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
func LoadFromEnv() (Config, error) { func LoadFromEnv() (Config, error) {
@@ -30,43 +29,6 @@ func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool))
if c == nil { if c == nil {
return fmt.Errorf("config must not be nil") return fmt.Errorf("config must not be nil")
} }
if c.LLMProfiles == nil {
c.LLMProfiles = map[string]LLMProfile{}
}
defaultProfile := c.LLMProfiles[pipeline.DefaultLLMProfile]
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_API_KEY"); ok {
defaultProfile.APIKey = raw
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_BASE_URL"); ok {
defaultProfile.BaseURL = strings.TrimSpace(raw)
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MODEL"); ok {
defaultProfile.Model = strings.TrimSpace(raw)
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS", raw)
if err != nil {
return err
}
defaultProfile.TimeoutSeconds = value
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_RETRIES"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_RETRIES", raw)
if err != nil {
return err
}
defaultProfile.MaxRetries = value
}
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY"); ok {
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY", raw)
if err != nil {
return err
}
defaultProfile.MaxConcurrency = value
}
c.LLMProfiles[pipeline.DefaultLLMProfile] = defaultProfile
if raw, ok := lookup("NOTARIUS_TOTAL_LLM_CONCURRENCY"); ok { if raw, ok := lookup("NOTARIUS_TOTAL_LLM_CONCURRENCY"); ok {
value, err := parseIntEnv("NOTARIUS_TOTAL_LLM_CONCURRENCY", raw) value, err := parseIntEnv("NOTARIUS_TOTAL_LLM_CONCURRENCY", raw)
if err != nil { if err != nil {

View File

@@ -8,32 +8,22 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
func TestApplyEnvOverridesOperationalAndLLMValues(t *testing.T) { func TestApplyEnvOverridesOperationalValues(t *testing.T) {
cfg := Default() cfg := Default()
cfg.Pipelines["example"] = pipeline.PipelineProfile{ID: "example", Input: pipeline.Binding("before")} cfg.Pipelines["example"] = pipeline.PipelineProfile{ID: "example", Input: pipeline.Binding("before")}
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{ err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
"NOTARIUS_LLM_DEFAULT_API_KEY": "secret", "NOTARIUS_TOTAL_LLM_CONCURRENCY": "3",
"NOTARIUS_LLM_DEFAULT_BASE_URL": "https://example.invalid/v1", "NOTARIUS_WORK_DIR": "/tmp/notarius-env",
"NOTARIUS_LLM_DEFAULT_MODEL": "test-model", "NOTARIUS_DIAGNOSTICS_RETENTION": "never",
"NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS": "120", "NOTARIUS_PIPELINE_INPUT": "after",
"NOTARIUS_LLM_DEFAULT_MAX_RETRIES": "5",
"NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY": "2",
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "3",
"NOTARIUS_WORK_DIR": "/tmp/notarius-env",
"NOTARIUS_DIAGNOSTICS_RETENTION": "never",
"NOTARIUS_PIPELINE_INPUT": "after",
})) }))
if err != nil { if err != nil {
t.Fatalf("ApplyEnvOverrides: %v", err) t.Fatalf("ApplyEnvOverrides: %v", err)
} }
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile] if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
if profile.APIKey != "secret" || profile.BaseURL != "https://example.invalid/v1" || profile.Model != "test-model" { t.Fatalf("LLM environment overrides must not change Scriptorium config: %+v", cfg.Scriptorium)
t.Fatalf("unexpected LLM profile strings: %+v", profile)
}
if profile.TimeoutSeconds != 120 || profile.MaxRetries != 5 || profile.MaxConcurrency != 2 {
t.Fatalf("unexpected LLM profile numeric values: %+v", profile)
} }
if cfg.Concurrency.TotalLLM != 3 { if cfg.Concurrency.TotalLLM != 3 {
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM) t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
@@ -57,13 +47,16 @@ func TestApplyEnvOverridesRejectsInvalidIntegers(t *testing.T) {
} }
func TestLoadFromEnvUsesDefaultConfig(t *testing.T) { func TestLoadFromEnvUsesDefaultConfig(t *testing.T) {
t.Setenv("NOTARIUS_LLM_DEFAULT_MODEL", "env-model") t.Setenv("NOTARIUS_TOTAL_LLM_CONCURRENCY", "2")
cfg, err := LoadFromEnv() cfg, err := LoadFromEnv()
if err != nil { if err != nil {
t.Fatalf("LoadFromEnv: %v", err) t.Fatalf("LoadFromEnv: %v", err)
} }
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].Model != "env-model" { if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
t.Fatalf("expected env model, got %+v", cfg.LLMProfiles[pipeline.DefaultLLMProfile]) t.Fatalf("unexpected Scriptorium config from env: %+v", cfg.Scriptorium)
}
if cfg.Concurrency.TotalLLM != 2 {
t.Fatalf("expected env concurrency override, got %+v", cfg.Concurrency)
} }
} }

View File

@@ -4,34 +4,25 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"regexp"
"sort" "sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type FileConfig struct { type FileConfig struct {
Version int `yaml:"version"` Version int `yaml:"version"`
LLMProfiles map[string]FileLLMProfile `yaml:"llm_profiles,omitempty"` Scriptorium *FileScriptoriumConfig `yaml:"scriptorium,omitempty"`
Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"` Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"`
Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"` Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"`
Diagnostics *FileDiagnosticsConfig `yaml:"diagnostics,omitempty"` Diagnostics *FileDiagnosticsConfig `yaml:"diagnostics,omitempty"`
} }
type FileLLMProfile struct { type FileScriptoriumConfig struct {
Provider *string `yaml:"provider,omitempty"` ProfileDir *string `yaml:"profile_dir,omitempty"`
BaseURL *string `yaml:"base_url,omitempty"` ProfileFile *string `yaml:"profile_file,omitempty"`
Model *string `yaml:"model,omitempty"`
APIKeyEnv *string `yaml:"api_key_env,omitempty"`
Timeout *fileDurationSeconds `yaml:"timeout,omitempty"`
MaxRetries *int `yaml:"max_retries,omitempty"`
MaxConcurrency *int `yaml:"max_concurrency,omitempty"`
} }
type FilePipelineProfile struct { type FilePipelineProfile struct {
@@ -59,42 +50,6 @@ type FileDiagnosticsConfig struct {
Retention *string `yaml:"retention,omitempty"` Retention *string `yaml:"retention,omitempty"`
} }
type fileDurationSeconds struct {
seconds int
}
func (d *fileDurationSeconds) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("must be an integer seconds value or duration string")
}
if node.Tag == "!!int" {
var seconds int
if err := node.Decode(&seconds); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
}
d.seconds = seconds
return nil
}
var raw string
if err := node.Decode(&raw); err != nil {
return fmt.Errorf("must be an integer seconds value or duration string")
}
duration, err := time.ParseDuration(strings.TrimSpace(raw))
if err != nil {
return fmt.Errorf("invalid duration %q", raw)
}
if duration%time.Second != 0 {
return fmt.Errorf("duration %q must resolve to whole seconds", raw)
}
d.seconds = int(duration / time.Second)
return nil
}
func (d fileDurationSeconds) Seconds() int {
return d.seconds
}
type fileModuleBinding struct { type fileModuleBinding struct {
Module string Module string
LLMProfile string LLMProfile string
@@ -196,23 +151,17 @@ func (c *Config) ApplyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
} }
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error { func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
_ = lookup
if c == nil { if c == nil {
return fmt.Errorf("config must not be nil") return fmt.Errorf("config must not be nil")
} }
if fileCfg.Version != SupportedFileConfigVersion { if fileCfg.Version != SupportedFileConfigVersion {
return fmt.Errorf("unsupported config version %d", fileCfg.Version) return fmt.Errorf("unsupported config version %d", fileCfg.Version)
} }
if c.LLMProfiles == nil {
c.LLMProfiles = map[string]LLMProfile{}
}
if c.Pipelines == nil { if c.Pipelines == nil {
c.Pipelines = map[string]pipeline.PipelineProfile{} c.Pipelines = map[string]pipeline.PipelineProfile{}
} }
profileIDs, rawLLMProfileIDs, err := normalizedMapKeys(fileCfg.LLMProfiles, "llm profile id")
if err != nil {
return err
}
pipelineIDs, rawPipelineIDs, err := normalizedMapKeys(fileCfg.Pipelines, "pipeline id") pipelineIDs, rawPipelineIDs, err := normalizedMapKeys(fileCfg.Pipelines, "pipeline id")
if err != nil { if err != nil {
return err return err
@@ -267,36 +216,21 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
} }
} }
for _, profileID := range profileIDs { if fileCfg.Scriptorium != nil {
fileProfile := fileCfg.LLMProfiles[rawLLMProfileIDs[profileID]] if fileCfg.Scriptorium.ProfileDir != nil {
profile := c.LLMProfiles[profileID] value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileDir)
if fileProfile.Provider != nil { if value == "" {
profile.Provider = strings.TrimSpace(*fileProfile.Provider) return fmt.Errorf("scriptorium.profile_dir must not be empty when set")
}
if fileProfile.BaseURL != nil {
profile.BaseURL = strings.TrimSpace(*fileProfile.BaseURL)
}
if fileProfile.Model != nil {
profile.Model = strings.TrimSpace(*fileProfile.Model)
}
if fileProfile.APIKeyEnv != nil {
apiKey, err := resolveAPIKeyEnv(*fileProfile.APIKeyEnv, lookup)
if err != nil {
return fmt.Errorf("llm_profiles.%s.api_key_env: %w", profileID, err)
} }
profile.APIKeyEnv = strings.TrimSpace(*fileProfile.APIKeyEnv) c.Scriptorium.ProfileDir = value
profile.APIKey = apiKey
} }
if fileProfile.Timeout != nil { if fileCfg.Scriptorium.ProfileFile != nil {
profile.TimeoutSeconds = fileProfile.Timeout.Seconds() value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileFile)
if value == "" {
return fmt.Errorf("scriptorium.profile_file must not be empty when set")
}
c.Scriptorium.ProfileFile = value
} }
if fileProfile.MaxRetries != nil {
profile.MaxRetries = *fileProfile.MaxRetries
}
if fileProfile.MaxConcurrency != nil {
profile.MaxConcurrency = *fileProfile.MaxConcurrency
}
c.LLMProfiles[profileID] = profile
} }
for _, pipelineID := range pipelineIDs { for _, pipelineID := range pipelineIDs {
@@ -408,21 +342,6 @@ func mergeStringMaps(base map[string]string, override map[string]string) map[str
return out return out
} }
func resolveAPIKeyEnv(envName string, lookup func(string) (string, bool)) (string, error) {
name := strings.TrimSpace(envName)
if name == "" {
return "", fmt.Errorf("must not be empty")
}
if !envVarNamePattern.MatchString(name) {
return "", fmt.Errorf("must be an environment variable name")
}
value, ok := lookup(name)
if !ok {
return "", fmt.Errorf("%s is not set", name)
}
return value, nil
}
func normalizeOptions(options map[string]any) map[string]any { func normalizeOptions(options map[string]any) map[string]any {
if len(options) == 0 { if len(options) == 0 {
return nil return nil

View File

@@ -12,7 +12,7 @@ import (
func TestParseMinimalValidConfig(t *testing.T) { func TestParseMinimalValidConfig(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(` fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
`)) `))
if err != nil { if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err) t.Fatalf("ParseFileConfigYAML: %v", err)
@@ -24,7 +24,7 @@ version: 1
func TestLoadFileConfig(t *testing.T) { func TestLoadFileConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil { if err := os.WriteFile(path, []byte("version: 2\n"), 0o644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
@@ -39,7 +39,7 @@ func TestLoadFileConfig(t *testing.T) {
func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) { func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(` _, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
unexpected: true unexpected: true
`)) `))
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") { if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
@@ -49,7 +49,7 @@ unexpected: true
func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) { func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(` _, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
pipelines: pipelines:
example: example:
input: input:
@@ -70,8 +70,8 @@ func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) {
data string data string
want string want string
}{ }{
{name: "missing", data: `llm_profiles: {}`, want: "version is required"}, {name: "missing", data: `scriptorium: {}`, want: "version is required"},
{name: "unsupported", data: `version: 2`, want: "unsupported config version"}, {name: "unsupported", data: `version: 1`, want: "unsupported config version"},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -84,9 +84,44 @@ func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) {
} }
} }
func TestParseFileConfigRejectsStaleLLMProfiles(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(`
version: 2
llm_profiles:
default: {}
`))
if err == nil || !strings.Contains(err.Error(), "llm_profiles") {
t.Fatalf("expected stale llm_profiles error, got %v", err)
}
}
func TestParseFileConfigScriptoriumProfileSources(t *testing.T) {
t.Run("profile dir", func(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 2
scriptorium:
profile_dir: ./profiles
`)
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
t.Fatalf("Scriptorium = %+v, want profile_dir", cfg.Scriptorium)
}
})
t.Run("profile file", func(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 2
scriptorium:
profile_file: ./profiles.yml
`)
if cfg.Scriptorium.ProfileFile != "./profiles.yml" || cfg.Scriptorium.ProfileDir != "" {
t.Fatalf("Scriptorium = %+v, want profile_file", cfg.Scriptorium)
}
})
}
func TestParseFileConfigModuleBindingForms(t *testing.T) { func TestParseFileConfigModuleBindingForms(t *testing.T) {
cfg := parseAndApplyConfig(t, ` cfg := parseAndApplyConfig(t, `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -146,7 +181,7 @@ pipelines:
func TestParseFileConfigReferenceMaps(t *testing.T) { func TestParseFileConfigReferenceMaps(t *testing.T) {
cfg := parseAndApplyConfig(t, ` cfg := parseAndApplyConfig(t, `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -171,7 +206,7 @@ pipelines:
func TestParseFileConfigStageLocalReferenceMaps(t *testing.T) { func TestParseFileConfigStageLocalReferenceMaps(t *testing.T) {
cfg := parseAndApplyConfig(t, ` cfg := parseAndApplyConfig(t, `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -218,7 +253,7 @@ pipelines:
func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) { func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) {
cfg := parseAndApplyConfig(t, ` cfg := parseAndApplyConfig(t, `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -248,87 +283,9 @@ pipelines:
} }
} }
func TestParseFileConfigDurationParsing(t *testing.T) {
tests := []struct {
name string
raw string
want int
}{
{name: "integer seconds", raw: "600", want: 600},
{name: "duration string", raw: "10m", want: 600},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
llm_profiles:
default:
timeout: `+tc.raw+`
`)
if got := cfg.LLMProfiles["default"].TimeoutSeconds; got != tc.want {
t.Fatalf("TimeoutSeconds = %d, want %d", got, tc.want)
}
})
}
}
func TestParseFileConfigRejectsSubsecondDuration(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
timeout: 1500ms
`))
if err == nil || !strings.Contains(err.Error(), "whole seconds") {
t.Fatalf("expected whole-seconds duration error, got %v", err)
}
}
func TestApplyFileConfigResolvesAPIKeyEnv(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
api_key_env: NOTARIUS_TEST_API_KEY
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"})); err != nil {
t.Fatalf("ApplyFileConfig: %v", err)
}
profile := cfg.LLMProfiles["default"]
if profile.APIKeyEnv != "NOTARIUS_TEST_API_KEY" || profile.APIKey != "secret" {
t.Fatalf("unexpected resolved API key: %+v", profile)
}
}
func TestApplyFileConfigRejectsDuplicateTrimmedLLMProfileIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1
llm_profiles:
default:
model: first
" default ":
model: second
`))
if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err)
}
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil || !strings.Contains(err.Error(), "llm profile id") || !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("expected duplicate LLM profile ID error, got %v", err)
}
}
func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) { func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(` fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -348,7 +305,7 @@ pipelines:
func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) { func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) {
fileCfg, err := ParseFileConfigYAML([]byte(` fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -378,7 +335,7 @@ func TestApplyFileConfigRejectsDuplicateTrimmedReferenceSlots(t *testing.T) {
{ {
name: "pipeline", name: "pipeline",
raw: ` raw: `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -391,7 +348,7 @@ pipelines:
{ {
name: "lane", name: "lane",
raw: ` raw: `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -407,7 +364,7 @@ pipelines:
{ {
name: "chunk", name: "chunk",
raw: ` raw: `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -422,7 +379,7 @@ pipelines:
{ {
name: "extract", name: "extract",
raw: ` raw: `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -439,7 +396,7 @@ pipelines:
{ {
name: "normalize", name: "normalize",
raw: ` raw: `
version: 1 version: 2
pipelines: pipelines:
example: example:
input: fake/input input: fake/input
@@ -471,49 +428,32 @@ pipelines:
} }
} }
func TestApplyFileConfigAllowsRetryOnlyLLMProfile(t *testing.T) { func TestApplyFileConfigRejectsInvalidScriptoriumSources(t *testing.T) {
cfg := parseAndApplyConfig(t, `
version: 1
llm_profiles:
retry-only:
max_retries: 3
`)
profile := cfg.LLMProfiles["retry-only"]
if profile.MaxRetries != 3 {
t.Fatalf("unexpected max retries: %d", profile.MaxRetries)
}
if profile.TimeoutSeconds != 0 {
t.Fatalf("expected unset timeout, got %d", profile.TimeoutSeconds)
}
if profile.MaxConcurrency != 0 {
t.Fatalf("expected unset max concurrency, got %d", profile.MaxConcurrency)
}
}
func TestApplyFileConfigRejectsInvalidAPIKeyEnv(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string raw string
want string want string
}{ }{
{name: "invalid name", env: "NOTARIUS-KEY", want: "environment variable name"}, {name: "empty profile dir", raw: "profile_dir: ' '", want: "profile_dir"},
{name: "not set", env: "NOTARIUS_TEST_API_KEY", want: "is not set"}, {name: "empty profile file", raw: "profile_file: ' '", want: "profile_file"},
{name: "both sources", raw: "profile_dir: ./profiles\n profile_file: ./profiles.yml", want: "mutually exclusive"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
cfg := Default()
fileCfg, err := ParseFileConfigYAML([]byte(` fileCfg, err := ParseFileConfigYAML([]byte(`
version: 1 version: 2
llm_profiles: scriptorium:
default: ` + tc.raw + `
api_key_env: ` + tc.env + `
`)) `))
if err != nil { if err != nil {
t.Fatalf("ParseFileConfigYAML: %v", err) t.Fatalf("ParseFileConfigYAML: %v", err)
} }
cfg := Default()
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
if err == nil {
err = cfg.Validate()
}
if err == nil || !strings.Contains(err.Error(), tc.want) { if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err) t.Fatalf("expected error containing %q, got %v", tc.want, err)
} }
@@ -523,7 +463,7 @@ llm_profiles:
func TestApplyFileConfigOperationalSections(t *testing.T) { func TestApplyFileConfigOperationalSections(t *testing.T) {
cfg := parseAndApplyConfig(t, ` cfg := parseAndApplyConfig(t, `
version: 1 version: 2
concurrency: concurrency:
total_llm: 4 total_llm: 4
diagnostics: diagnostics:

View File

@@ -2,17 +2,8 @@ package config
import "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" import "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
const redactedSecret = "[REDACTED]"
func (c Config) Redacted() Config { func (c Config) Redacted() Config {
redacted := cloneConfig(c) return cloneConfig(c)
for id, profile := range redacted.LLMProfiles {
if profile.APIKey != "" {
profile.APIKey = redactedSecret
}
redacted.LLMProfiles[id] = profile
}
return redacted
} }
func (c Config) RedactedDiagnosticsPayload() any { func (c Config) RedactedDiagnosticsPayload() any {

View File

@@ -7,63 +7,36 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
func TestRedactedConfigRemovesAPIKeyValues(t *testing.T) { func TestRedactedConfigCopiesScriptoriumConfig(t *testing.T) {
cfg := Default() cfg := Default()
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = LLMProfile{ cfg.Scriptorium.ProfileDir = "./profiles"
Provider: "openai-compatible",
BaseURL: "https://example.invalid/v1",
Model: "test-model",
APIKey: "secret",
APIKeyEnv: "NOTARIUS_TEST_API_KEY",
TimeoutSeconds: 600,
MaxRetries: 3,
MaxConcurrency: 1,
}
cfg.LLMProfiles["other"] = LLMProfile{APIKey: "other-secret", Model: "other-model"}
redacted := cfg.Redacted() redacted := cfg.Redacted()
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret { if redacted.Scriptorium.ProfileDir != "./profiles" {
t.Fatalf("expected default API key redacted, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile]) t.Fatalf("expected Scriptorium profile source preserved, got %+v", redacted.Scriptorium)
} }
if redacted.LLMProfiles["other"].APIKey != redactedSecret { redacted.Scriptorium.ProfileDir = "./changed"
t.Fatalf("expected other API key redacted, got %+v", redacted.LLMProfiles["other"]) if cfg.Scriptorium.ProfileDir != "./profiles" {
}
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
t.Fatalf("expected non-secret fields preserved, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile])
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redaction mutated original config") t.Fatalf("redaction mutated original config")
} }
} }
func TestConfigRedactedDiagnosticsPayloadRedactsAPIKeys(t *testing.T) { func TestConfigRedactedDiagnosticsPayloadCopiesConfig(t *testing.T) {
cfg := Default() cfg := Default()
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile] cfg.Scriptorium.ProfileFile = "./profiles.yml"
profile.APIKey = "secret"
profile.Model = "test-model"
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
payload, ok := cfg.RedactedDiagnosticsPayload().(Config) payload, ok := cfg.RedactedDiagnosticsPayload().(Config)
if !ok { if !ok {
t.Fatalf("expected Config payload, got %T", cfg.RedactedDiagnosticsPayload()) t.Fatalf("expected Config payload, got %T", cfg.RedactedDiagnosticsPayload())
} }
if payload.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret { if payload.Scriptorium.ProfileFile != "./profiles.yml" {
t.Fatalf("expected API key redacted, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile]) t.Fatalf("expected Scriptorium profile file preserved, got %+v", payload.Scriptorium)
}
if payload.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
t.Fatalf("expected non-secret fields preserved, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile])
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redacted diagnostics payload mutated original config")
} }
} }
func TestEffectiveConfigRedactedDiagnosticsPayloadRedactsAndCopies(t *testing.T) { func TestEffectiveConfigRedactedDiagnosticsPayloadCopies(t *testing.T) {
cfg := validConfig() cfg := validConfig()
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
profile.APIKey = "secret"
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
lane := cfg.Pipelines["example"].Artifacts["events"] lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract.Options = map[string]any{"temperature": 0.2} lane.Extract.Options = map[string]any{"temperature": 0.2}
lane.References = map[string]string{"roster": "./roster.yml"} lane.References = map[string]string{"roster": "./roster.yml"}
@@ -116,12 +89,6 @@ func TestEffectiveConfigRedactedDiagnosticsPayloadRedactsAndCopies(t *testing.T)
if !ok { if !ok {
t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedDiagnosticsPayload()) t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedDiagnosticsPayload())
} }
if payload.Config.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
t.Fatalf("expected nested API key redacted, got %+v", payload.Config.LLMProfiles[pipeline.DefaultLLMProfile])
}
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
t.Fatalf("redacted diagnostics payload mutated source config")
}
if payload.PipelineID != effective.PipelineID || payload.ResolvedPipeline.Digest != effective.ResolvedPipeline.Digest { if payload.PipelineID != effective.PipelineID || payload.ResolvedPipeline.Digest != effective.ResolvedPipeline.Digest {
t.Fatalf("expected pipeline metadata preserved, got %+v", payload) t.Fatalf("expected pipeline metadata preserved, got %+v", payload)
} }

View File

@@ -8,10 +8,8 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
const providerOpenAICompatible = "openai-compatible"
func (c Config) Validate() error { func (c Config) Validate() error {
if err := validateLLMProfiles(c.LLMProfiles); err != nil { if err := validateScriptorium(c.Scriptorium); err != nil {
return err return err
} }
if err := validateDiagnostics(c.Diagnostics); err != nil { if err := validateDiagnostics(c.Diagnostics); err != nil {
@@ -20,44 +18,12 @@ func (c Config) Validate() error {
if c.Concurrency.TotalLLM <= 0 { if c.Concurrency.TotalLLM <= 0 {
return fmt.Errorf("total LLM concurrency must be greater than zero") return fmt.Errorf("total LLM concurrency must be greater than zero")
} }
return validatePipelineProfiles(c.Pipelines, c.LLMProfiles) return validatePipelineProfiles(c.Pipelines)
} }
func (c Config) LLMProfile(id string) (LLMProfile, bool) { func validateScriptorium(cfg ScriptoriumConfig) error {
trimmedID := strings.TrimSpace(id) if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" {
for rawID, profile := range c.LLMProfiles { return fmt.Errorf("scriptorium profile_dir and profile_file are mutually exclusive")
if strings.TrimSpace(rawID) == trimmedID {
return profile, true
}
}
return LLMProfile{}, false
}
func validateLLMProfiles(profiles map[string]LLMProfile) error {
seen := make(map[string]struct{}, len(profiles))
for rawID, profile := range profiles {
id := strings.TrimSpace(rawID)
if id == "" {
return fmt.Errorf("LLM profile id must not be empty")
}
if _, ok := seen[id]; ok {
return fmt.Errorf("LLM profile id %q is duplicated after trimming", id)
}
seen[id] = struct{}{}
provider := strings.TrimSpace(profile.Provider)
if provider != "" && provider != providerOpenAICompatible {
return fmt.Errorf("LLM profile %q provider %q is not supported", id, provider)
}
if profile.TimeoutSeconds < 0 {
return fmt.Errorf("LLM profile %q timeout seconds must not be negative", id)
}
if profile.MaxRetries < 0 {
return fmt.Errorf("LLM profile %q max retries must not be negative", id)
}
if profile.MaxConcurrency < 0 {
return fmt.Errorf("LLM profile %q max concurrency must not be negative", id)
}
} }
return nil return nil
} }
@@ -74,7 +40,7 @@ func validateDiagnostics(cfg DiagnosticsConfig) error {
} }
} }
func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmProfiles map[string]LLMProfile) error { func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile) error {
seen := make(map[string]struct{}, len(profiles)) seen := make(map[string]struct{}, len(profiles))
for rawID, profile := range profiles { for rawID, profile := range profiles {
id := strings.TrimSpace(rawID) id := strings.TrimSpace(rawID)
@@ -89,13 +55,13 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmP
if profile.ID != "" && strings.TrimSpace(profile.ID) != id { if profile.ID != "" && strings.TrimSpace(profile.ID) != id {
return fmt.Errorf("pipeline %q profile id %q does not match map key", id, profile.ID) return fmt.Errorf("pipeline %q profile id %q does not match map key", id, profile.ID)
} }
if err := validateBinding(id, "", "input", profile.Input, llmProfiles, false); err != nil { if err := validateBinding(id, "", "input", profile.Input, false); err != nil {
return err return err
} }
if err := validateBinding(id, "", "chunk", profile.Chunk, llmProfiles, true); err != nil { if err := validateBinding(id, "", "chunk", profile.Chunk, true); err != nil {
return err return err
} }
if err := validateBinding(id, "", "output", profile.Output, llmProfiles, false); err != nil { if err := validateBinding(id, "", "output", profile.Output, false); err != nil {
return err return err
} }
if err := validateReferenceMap(id, "", profile.References); err != nil { if err := validateReferenceMap(id, "", profile.References); err != nil {
@@ -109,17 +75,17 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmP
if err := validateReferenceMap(id, laneID, lane.References); err != nil { if err := validateReferenceMap(id, laneID, lane.References); err != nil {
return err return err
} }
if err := validateBinding(id, laneID, "extract", lane.Extract, llmProfiles, true); err != nil { if err := validateBinding(id, laneID, "extract", lane.Extract, true); err != nil {
return err return err
} }
if err := validateBinding(id, laneID, "merge", lane.Merge, llmProfiles, false); err != nil { if err := validateBinding(id, laneID, "merge", lane.Merge, false); err != nil {
return err return err
} }
if err := validateBinding(id, laneID, "normalize", lane.Normalize, llmProfiles, true); err != nil { if err := validateBinding(id, laneID, "normalize", lane.Normalize, true); err != nil {
return err return err
} }
for i, validator := range lane.Validators { for i, validator := range lane.Validators {
if err := validateBinding(id, laneID, fmt.Sprintf("validator[%d]", i), validator, llmProfiles, false); err != nil { if err := validateBinding(id, laneID, fmt.Sprintf("validator[%d]", i), validator, false); err != nil {
return err return err
} }
} }
@@ -133,10 +99,9 @@ func validateBinding(
laneID string, laneID string,
slot string, slot string,
binding pipeline.ModuleBinding, binding pipeline.ModuleBinding,
profiles map[string]LLMProfile,
referencesAllowed bool, referencesAllowed bool,
) error { ) error {
if err := validateBindingLLMProfile(pipelineID, laneID, slot, binding, profiles); err != nil { if err := validateBindingLLMProfile(pipelineID, laneID, slot, binding); err != nil {
return err return err
} }
if len(binding.References) == 0 { if len(binding.References) == 0 {
@@ -191,27 +156,12 @@ func validateBindingLLMProfile(
laneID string, laneID string,
slot string, slot string,
binding pipeline.ModuleBinding, binding pipeline.ModuleBinding,
profiles map[string]LLMProfile,
) error { ) error {
profileID := strings.TrimSpace(binding.LLMProfile) if binding.LLMProfile != "" && strings.TrimSpace(binding.LLMProfile) == "" {
if profileID == "" { if laneID != "" {
profileID = pipeline.DefaultLLMProfile return fmt.Errorf("pipeline %q lane %q %s llm_profile must not be empty when set", pipelineID, laneID, slot)
}
if hasLLMProfile(profiles, profileID) {
return nil
}
if laneID != "" {
return fmt.Errorf("pipeline %q lane %q %s references unknown LLM profile %q", pipelineID, laneID, slot, profileID)
}
return fmt.Errorf("pipeline %q %s references unknown LLM profile %q", pipelineID, slot, profileID)
}
func hasLLMProfile(profiles map[string]LLMProfile, profileID string) bool {
profileID = strings.TrimSpace(profileID)
for rawID := range profiles {
if strings.TrimSpace(rawID) == profileID {
return true
} }
return fmt.Errorf("pipeline %q %s llm_profile must not be empty when set", pipelineID, slot)
} }
return false return nil
} }

View File

@@ -17,27 +17,26 @@ func TestValidateSuccessForValidConfig(t *testing.T) {
} }
} }
func TestValidateRejectsUnknownLLMProfileReferencedByBinding(t *testing.T) { func TestValidateAllowsExplicitScriptoriumProfileIDOnBinding(t *testing.T) {
cfg := validConfig() cfg := validConfig()
lane := cfg.Pipelines["example"].Artifacts["events"] lane := cfg.Pipelines["example"].Artifacts["events"]
lane.Extract.LLMProfile = "missing" lane.Extract.LLMProfile = "scriptorium-profile"
cfg.Pipelines["example"].Artifacts["events"] = lane cfg.Pipelines["example"].Artifacts["events"] = lane
err := cfg.Validate() if err := cfg.Validate(); err != nil {
if err == nil || !strings.Contains(err.Error(), "unknown LLM profile") || !strings.Contains(err.Error(), "events") { t.Fatalf("Validate() error = %v, want nil", err)
t.Fatalf("expected unknown LLM profile error with lane context, got %v", err)
} }
} }
func TestValidateRejectsInvalidProvider(t *testing.T) { func TestValidateRejectsWhitespaceOnlyExplicitLLMProfile(t *testing.T) {
cfg := validConfig() cfg := validConfig()
profile := cfg.LLMProfiles["default"] lane := cfg.Pipelines["example"].Artifacts["events"]
profile.Provider = "unsupported" lane.Extract.LLMProfile = " "
cfg.LLMProfiles["default"] = profile cfg.Pipelines["example"].Artifacts["events"] = lane
err := cfg.Validate() err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "provider") { if err == nil || !strings.Contains(err.Error(), "llm_profile") || !strings.Contains(err.Error(), "events") {
t.Fatalf("expected provider error, got %v", err) t.Fatalf("expected llm_profile error with lane context, got %v", err)
} }
} }
@@ -55,36 +54,6 @@ func TestValidateRejectsInvalidNumericFields(t *testing.T) {
}, },
want: "total LLM concurrency", want: "total LLM concurrency",
}, },
{
name: "timeout",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.TimeoutSeconds = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "timeout",
},
{
name: "max retries",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.MaxRetries = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "max retries",
},
{
name: "max concurrency",
mutate: func(cfg Config) Config {
profile := cfg.LLMProfiles["default"]
profile.MaxConcurrency = -1
cfg.LLMProfiles["default"] = profile
return cfg
},
want: "max concurrency",
},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -97,12 +66,14 @@ func TestValidateRejectsInvalidNumericFields(t *testing.T) {
} }
} }
func TestValidateAllowsPartialLLMProfileNumericConfig(t *testing.T) { func TestValidateRejectsMutuallyExclusiveScriptoriumProfileSources(t *testing.T) {
cfg := validConfig() cfg := validConfig()
cfg.LLMProfiles["retry-only"] = LLMProfile{MaxRetries: 3} cfg.Scriptorium.ProfileDir = "./profiles"
cfg.Scriptorium.ProfileFile = "./profiles.yml"
if err := cfg.Validate(); err != nil { err := cfg.Validate()
t.Fatalf("Validate: %v", err) if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected Scriptorium source conflict, got %v", err)
} }
} }
@@ -327,14 +298,6 @@ func TestValidateRejectsEmptyIDs(t *testing.T) {
mutate func(Config) Config mutate func(Config) Config
want string want string
}{ }{
{
name: "LLM profile",
mutate: func(cfg Config) Config {
cfg.LLMProfiles[" "] = LLMProfile{}
return cfg
},
want: "LLM profile id",
},
{ {
name: "pipeline", name: "pipeline",
mutate: func(cfg Config) Config { mutate: func(cfg Config) Config {
@@ -361,14 +324,6 @@ func TestValidateRejectsIDsDuplicatedAfterTrimming(t *testing.T) {
mutate func(Config) Config mutate func(Config) Config
want string want string
}{ }{
{
name: "LLM profile",
mutate: func(cfg Config) Config {
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
return cfg
},
want: "duplicated",
},
{ {
name: "pipeline", name: "pipeline",
mutate: func(cfg Config) Config { mutate: func(cfg Config) Config {
@@ -389,25 +344,8 @@ func TestValidateRejectsIDsDuplicatedAfterTrimming(t *testing.T) {
} }
} }
func TestValidateUsesTrimmedLLMProfileIDs(t *testing.T) {
cfg := validConfig()
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
delete(cfg.LLMProfiles, "default")
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
if _, ok := cfg.LLMProfile("default"); !ok {
t.Fatalf("expected trimmed LLM profile lookup to succeed")
}
}
func validConfig() Config { func validConfig() Config {
cfg := Default() cfg := Default()
profile := cfg.LLMProfiles["default"]
profile.BaseURL = "https://example.invalid/v1"
profile.Model = "test-model"
cfg.LLMProfiles["default"] = profile
cfg.Pipelines["example"] = pipeline.PipelineProfile{ cfg.Pipelines["example"] = pipeline.PipelineProfile{
Input: pipeline.Binding("fake/input"), Input: pipeline.Binding("fake/input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{ Artifacts: map[string]pipeline.ArtifactLaneProfile{

View File

@@ -597,9 +597,6 @@ func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding {
module = defaultModule module = defaultModule
} }
llmProfile := strings.TrimSpace(binding.LLMProfile) llmProfile := strings.TrimSpace(binding.LLMProfile)
if llmProfile == "" {
llmProfile = DefaultLLMProfile
}
return ModuleBinding{ return ModuleBinding{
Module: module, Module: module,
LLMProfile: llmProfile, LLMProfile: llmProfile,

View File

@@ -49,8 +49,8 @@ func TestResolvePipelineWithExplicitModules(t *testing.T) {
if !reflect.DeepEqual(resolved.Input, ModuleBinding{Module: "text", LLMProfile: "fast"}) { if !reflect.DeepEqual(resolved.Input, ModuleBinding{Module: "text", LLMProfile: "fast"}) {
t.Fatalf("Input = %#v, want trimmed explicit input", resolved.Input) t.Fatalf("Input = %#v, want trimmed explicit input", resolved.Input)
} }
if resolved.Chunk.Module != "window" || resolved.Chunk.LLMProfile != DefaultLLMProfile { if resolved.Chunk.Module != "window" || resolved.Chunk.LLMProfile != "" {
t.Fatalf("Chunk = %#v, want explicit module and default LLM profile", resolved.Chunk) t.Fatalf("Chunk = %#v, want explicit module and empty LLM profile", resolved.Chunk)
} }
if resolved.Chunk.Options["size"] != 10 { if resolved.Chunk.Options["size"] != 10 {
t.Fatalf("Chunk.Options = %#v, want size option", resolved.Chunk.Options) t.Fatalf("Chunk.Options = %#v, want size option", resolved.Chunk.Options)
@@ -91,24 +91,24 @@ func TestResolvePipelineAppliesDefaults(t *testing.T) {
t.Fatalf("ResolvePipeline() error = %v, want nil", err) t.Fatalf("ResolvePipeline() error = %v, want nil", err)
} }
if resolved.Input.LLMProfile != DefaultLLMProfile { if resolved.Input.LLMProfile != "" {
t.Fatalf("Input.LLMProfile = %q, want %q", resolved.Input.LLMProfile, DefaultLLMProfile) t.Fatalf("Input.LLMProfile = %q, want empty", resolved.Input.LLMProfile)
} }
if !reflect.DeepEqual(resolved.Chunk, ModuleBinding{Module: DefaultChunkModule, LLMProfile: DefaultLLMProfile}) { if !reflect.DeepEqual(resolved.Chunk, ModuleBinding{Module: DefaultChunkModule}) {
t.Fatalf("Chunk = %#v, want default chunk binding", resolved.Chunk) t.Fatalf("Chunk = %#v, want default chunk binding", resolved.Chunk)
} }
if !reflect.DeepEqual(resolved.Output, ModuleBinding{Module: DefaultOutputModule, LLMProfile: DefaultLLMProfile}) { if !reflect.DeepEqual(resolved.Output, ModuleBinding{Module: DefaultOutputModule}) {
t.Fatalf("Output = %#v, want default output binding", resolved.Output) t.Fatalf("Output = %#v, want default output binding", resolved.Output)
} }
lane := resolved.ArtifactLanes[0] lane := resolved.ArtifactLanes[0]
if !reflect.DeepEqual(lane.Merge, ModuleBinding{Module: DefaultMergeModule, LLMProfile: DefaultLLMProfile}) { if !reflect.DeepEqual(lane.Merge, ModuleBinding{Module: DefaultMergeModule}) {
t.Fatalf("Merge = %#v, want default merge binding", lane.Merge) t.Fatalf("Merge = %#v, want default merge binding", lane.Merge)
} }
if !reflect.DeepEqual(lane.Normalize, ModuleBinding{Module: DefaultNormalizeModule, LLMProfile: DefaultLLMProfile}) { if !reflect.DeepEqual(lane.Normalize, ModuleBinding{Module: DefaultNormalizeModule}) {
t.Fatalf("Normalize = %#v, want default normalize binding", lane.Normalize) t.Fatalf("Normalize = %#v, want default normalize binding", lane.Normalize)
} }
if lane.Extract.LLMProfile != DefaultLLMProfile { if lane.Extract.LLMProfile != "" {
t.Fatalf("Extract.LLMProfile = %q, want %q", lane.Extract.LLMProfile, DefaultLLMProfile) t.Fatalf("Extract.LLMProfile = %q, want empty", lane.Extract.LLMProfile)
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"manifest": { "manifest": {
"pipeline_id": "walking-skeleton", "pipeline_id": "walking-skeleton",
"pipeline_digest": "sha256:25084e39a0cadace375c896551d1752413755c1a6772f6b24cfe91c029ea2631", "pipeline_digest": "sha256:437d7ff486c336ddba38654ea00b7ef9dd6e49ce09f468698202ad8fc459bdcd",
"validation_status": "approved", "validation_status": "approved",
"artifact_lanes": [ "artifact_lanes": [
{ {

View File

@@ -1,4 +1,4 @@
version: 1 version: 2
pipelines: pipelines:
dnd-spells-fixture: dnd-spells-fixture:
input: seriatim input: seriatim

View File

@@ -1,4 +1,4 @@
version: 1 version: 2
pipelines: pipelines:
seriatim-fixture: seriatim-fixture:
input: seriatim input: seriatim