diff --git a/bundled_openrouter_contract_test.go b/bundled_openrouter_contract_test.go new file mode 100644 index 0000000..7036279 --- /dev/null +++ b/bundled_openrouter_contract_test.go @@ -0,0 +1,124 @@ +package promptkit_test + +import ( + "context" + "errors" + "net/http" + "reflect" + "strings" + "testing" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit" +) + +type rejectBundleTransport struct{} + +func (rejectBundleTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("unexpected HTTP request") +} + +func TestBundledOpenRouterOptionsAndInventory(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "") + for _, test := range []struct { + name string + options []promptkit.Option + enabled bool + }{ + {"default", nil, true}, {"enabled", []promptkit.Option{promptkit.WithBundledOpenRouter(true)}, true}, + {"disabled", []promptkit.Option{promptkit.WithBundledOpenRouter(false)}, false}, + {"last disables", []promptkit.Option{promptkit.WithBundledOpenRouter(true), promptkit.WithBundledOpenRouter(false)}, false}, + {"last enables", []promptkit.Option{promptkit.WithBundledOpenRouter(false), promptkit.WithBundledOpenRouter(true)}, true}, + } { + t.Run(test.name, func(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: rejectBundleTransport{}}}, test.options...) + if err != nil { + t.Fatal(err) + } + profiles, err := engine.ListProfiles(context.Background()) + if err != nil { + t.Fatal(err) + } + backends, err := engine.ListBackends(context.Background()) + if err != nil { + t.Fatal(err) + } + prompts, err := engine.ListPrompts(context.Background()) + if err != nil { + t.Fatal(err) + } + collections, err := engine.ListCollections(context.Background()) + if err != nil { + t.Fatal(err) + } + want := 0 + if test.enabled { + want = 17 + } + if len(profiles) != want || len(backends) != boolCount(test.enabled) || len(prompts) != 0 || len(collections) != 0 { + t.Fatalf("inventories: %v %v %v %v", profiles, backends, prompts, collections) + } + if test.enabled { + wantProfiles := []string{"auto", "auto-high", "auto-low", "auto-max", "auto-medium", "auto-xhigh", "claude-fable-latest", "claude-haiku-latest", "claude-opus-latest", "claude-sonnet-latest", "free", "gemini-flash-latest", "gemini-pro-latest", "gpt-astra-latest", "gpt-luna-latest", "gpt-sol-latest", "gpt-terra-latest"} + if !reflect.DeepEqual(profiles, wantProfiles) || !reflect.DeepEqual(backends, []string{"openrouter"}) { + t.Fatalf("bundle inventory: %v %v", profiles, backends) + } + for id, model := range map[string]string{"auto": "openrouter/auto", "auto-max": "openrouter/auto", "gpt-luna-latest": "~openai/gpt-luna-latest", "free": "openrouter/free"} { + profile, err := engine.InspectProfile(context.Background(), id) + if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.BackendID != "openrouter" { + t.Fatalf("profile %s = %+v, %v", id, profile, err) + } + } + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(fmtSources(view.Sources()), "Promptkit bundled OpenRouter") { + t.Fatal("bundle source missing") + } + if explanation, found := view.Explain(configkit.Keys("backends").Index(0).Key("endpoint")); !found || !strings.Contains(explanation.Effective.YAML, "https://openrouter.ai/api/v1") || len(explanation.History) == 0 { + t.Fatalf("bundle explanation = %+v", explanation) + } + } + }) + } +} + +func boolCount(value bool) int { + if value { + return 1 + } + return 0 +} +func fmtSources(sources []configkit.SourceInfo) string { + var b strings.Builder + for _, source := range sources { + b.WriteString(source.Label) + } + return b.String() +} + +func TestBundlePreservesCallerUnderlayBackingArray(t *testing.T) { + source := configkit.Mapping("caller", map[string]any{"profiles": []any{map[string]any{"id": "caller", "model": "model"}}}) + groups := make([]configkit.Underlay, 1, 2) + groups[0] = configkit.Underlay{Inputs: []configkit.Input{{Source: source}}} + before := append([]configkit.Underlay(nil), groups...) + if _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: groups}}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(groups, before) || !reflect.DeepEqual(groups[:2][1], configkit.Underlay{}) { + t.Fatal("caller underlays were modified") + } +} + +func TestDisabledBundleKeepsCallerOpenRouter(t *testing.T) { + input := configkit.Input{Source: configkit.Mapping("caller", map[string]any{"backends": []any{map[string]any{"id": "openrouter", "endpoint": "https://local.invalid/v1"}}})} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false)) + if err != nil { + t.Fatal(err) + } + backends, err := engine.ListBackends(context.Background()) + if err != nil || !reflect.DeepEqual(backends, []string{"openrouter"}) { + t.Fatalf("backends = %v, %v", backends, err) + } +} diff --git a/configuration_inspection_contract_test.go b/configuration_inspection_contract_test.go index a2d94bf..2b848d3 100644 --- a/configuration_inspection_contract_test.go +++ b/configuration_inspection_contract_test.go @@ -42,7 +42,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. }}, })}}}, }, - }}) + }}, promptkit.WithBundledOpenRouter(false)) if err != nil { t.Fatalf("construct layered engine: %v", err) } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4745221..00672e4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -149,6 +149,10 @@ Scope: facade/internal options, assembly, and default/disabled contract tests. Validation: focused construction/listing/inspection tests, then `go test ./internal/engine ./...` when intermediate wiring is coherent. +Completion: engine construction installs the bundle by default, with an +ordered opt-out option and a copied underlay slice. Facade contracts and the +full Go suite pass with Go 1.27.0. + ## Stage 3: Protect Composition And Application Control Scope: focused real-loader integration tests through the facade. diff --git a/engine.go b/engine.go index f51b375..eb6d794 100644 --- a/engine.go +++ b/engine.go @@ -154,6 +154,15 @@ type engineOptions struct { internal internalengine.Options } +// WithBundledOpenRouter enables or disables the built-in OpenRouter underlay. +// It is enabled by default; when repeated, the last option wins. +func WithBundledOpenRouter(enabled bool) Option { + return optionFunc(func(options *engineOptions) error { + options.internal.SetBundledOpenRouter(enabled) + return nil + }) +} + // WithLLMClient replaces the built-in model client used by [Engine.Run] and // [Engine.RunPrepared]. // diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e32a601..03472db 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -41,13 +41,17 @@ type ConfigurationSources struct { // Options collects construction choices from the public facade. type Options struct { - llmClient llm.Client - artifactReader artifact.Reader - validator validate.Validator - validatorSource bool - artifactSource bool + llmClient llm.Client + artifactReader artifact.Reader + validator validate.Validator + validatorSource bool + artifactSource bool + disableBundledOpenRouter bool } +// SetBundledOpenRouter selects whether the built-in OpenRouter source is installed. +func (o *Options) SetBundledOpenRouter(enabled bool) { o.disableBundledOpenRouter = !enabled } + // SetLLMClient selects the client collaborator for one engine. func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client } @@ -124,6 +128,11 @@ func New(cfg Config, options Options) (*Engine, error) { return nil, err } underlays := cfg.Sources.Underlays + if !options.disableBundledOpenRouter { + underlays = make([]configkit.Underlay, 1, len(cfg.Sources.Underlays)+1) + underlays[0] = configuration.BundledOpenRouterUnderlay() + underlays = append(underlays, cfg.Sources.Underlays...) + } main := configkit.Input{Source: configkit.NoSource()} if cfg.Sources.Main != nil { main = *cfg.Sources.Main diff --git a/listing_contract_test.go b/listing_contract_test.go index 16d09de..f1c329a 100644 --- a/listing_contract_test.go +++ b/listing_contract_test.go @@ -226,7 +226,7 @@ func newNamespaceListingEngine(t *testing.T) *promptkit.Engine { "profiles": []any{map[string]any{"id": "root-profile", "backend": "root-backend", "model": "root-model"}}, "prompts": []any{listingContractPrompt("root-prompt")}, })} - engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}) + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithBundledOpenRouter(false)) if err != nil { var loadErr *configkit.LoadError if errors.As(err, &loadErr) { @@ -464,7 +464,7 @@ func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) { } func TestListIdentitiesEmptyEffectiveConfiguration(t *testing.T) { - engine, err := promptkit.NewEngine(promptkit.Config{}) + engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithBundledOpenRouter(false)) if err != nil { t.Fatal(err) } diff --git a/sources_contract_test.go b/sources_contract_test.go index 1df7ad1..10143ee 100644 --- a/sources_contract_test.go +++ b/sources_contract_test.go @@ -23,7 +23,7 @@ func TestProgrammaticSourcesPreserveOrderAndRejectDuplicates(t *testing.T) { params["visible"] = "changed" engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: profileSource}}}}, - }}) + }}, promptkit.WithBundledOpenRouter(false)) if err != nil { t.Fatal(err) }