From c54a5a4f82d0c3562abd9d8f62c595ec4461c3f7 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 01:10:35 -0500 Subject: [PATCH] config: [serve] section for the OpenAI-compatible front door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listen (default :8090, own port so it coexists with [events]), vkey_ref (the bearer key OpenWebUI connections present; refs only, resolved at startup), and an optional enabled_models subset of the catalog. Entries must name a real servable model (mopac- from [models.classes]) so a typo fails at config load, not mid-request. Example file extended; once/loop configs without [serve] stay valid. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- harness.toml.example | 12 ++++++ internal/config/config.go | 39 ++++++++++++++++++ internal/config/config_test.go | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/harness.toml.example b/harness.toml.example index c06d412..e10d232 100644 --- a/harness.toml.example +++ b/harness.toml.example @@ -153,3 +153,15 @@ secret_ref = "env:HARNESS_DISCOURSE_WEBHOOK_SECRET" secret_ref = "env:HARNESS_GITEA_WEBHOOK_SECRET" # Gitea always verifies via HMAC-SHA256 in X-Gitea-Signature; the # secret_header override does not apply to it. + +# SERVE (optional): the `harness serve` OpenAI-compatible front door for +# OpenWebUI (DESIGN "OWUI front door"). Each [models.classes] class is +# exposed as a servable model named mopac- (mopac-study, +# mopac-code, ...); POST /v1/chat/completions runs ONE bounded stateless +# conductor turn over the conversation history OWUI sends (tools off, +# v0; non-streaming). Bearer vkey auth — the same value goes into the +# OWUI connection config. Own port: coexists with [events]. +[serve] +listen = ":8090" # publish on the LAN via docker -p +vkey_ref = "env:HARNESS_SERVE_VKEY" +# enabled_models = ["mopac-primary", "mopac-study"] # optional subset diff --git a/internal/config/config.go b/internal/config/config.go index 9a0ac54..78ed1e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { Bash BashConfig Demo DemoConfig Events EventsConfig + Serve ServeConfig KeyProxy KeyProxyConfig Gitea GiteaConfig } @@ -76,6 +77,14 @@ type DemoConfig struct { Class string } +// ServeConfig is the `harness serve` OpenAI-compatible front door (the +// OpenWebUI connection). Own port — coexists with [events]. +type ServeConfig struct { + Listen string // bind address (default ":8090"; publish via docker -p) + VKeyRef string // bearer vkey ref; the key OWUI connections present + EnabledModels []string // optional subset of the catalog (mopac-); empty = all classes +} + // KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs // POST here. All hosts/paths live in this config, never in code. type KeyProxyConfig struct { @@ -146,6 +155,7 @@ func Default() *Config { SecretHeader: "X-Discourse-Webhook-Secret", }, }, + Serve: ServeConfig{Listen: ":8090"}, KeyProxy: KeyProxyConfig{CacheTTLSecs: 60}, Demo: DemoConfig{ ID: "demo-1", @@ -322,6 +332,17 @@ func (c *Config) apply(doc TOMLDoc) error { applyEventSource(&c.Events.Redmine, doc.Table("events", "redmine")) applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse")) applyEventSource(&c.Events.Gitea, doc.Table("events", "gitea")) + + sv := doc.Table("serve") + if v, ok := sv.String("listen"); ok { + c.Serve.Listen = v + } + if v, ok := sv.String("vkey_ref"); ok { + c.Serve.VKeyRef = v + } + if v, ok := sv.StringList("enabled_models"); ok { + c.Serve.EnabledModels = v + } return nil } @@ -410,6 +431,24 @@ func (c *Config) Validate() error { return fmt.Errorf("[events]: listen and state_dir must not be empty") } + // Serve is optional (`once`/`loop` configs need none); a vkey that IS + // set must be well-formed, and enabled_models entries must name real + // catalog models (mopac-) so the front door fails at startup. + if c.Serve.VKeyRef != "" { + if err := CheckKeyRef(c.Serve.VKeyRef); err != nil { + return fmt.Errorf("[serve]: %w", err) + } + } + for _, m := range c.Serve.EnabledModels { + class := strings.TrimPrefix(m, "mopac-") + if !strings.HasPrefix(m, "mopac-") || class == "" || c.Models.Classes[class] == "" { + return fmt.Errorf("[serve]: enabled_models entry %q is not servable (must be mopac- from [models.classes])", m) + } + } + if c.Serve.Listen == "" { + return fmt.Errorf("[serve]: listen must not be empty") + } + if c.Loop.PollIntervalSecs < 1 { return fmt.Errorf("[loop]: poll_interval_secs must be >= 1") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f3f03a9..c85e7c6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -430,3 +431,75 @@ func TestCheckKeyRefMPK(t *testing.T) { t.Errorf("local ResolveKeyRef on mpk: should name [keyproxy], got %v", err) } } + +const serveCfg = testCfg + ` +[serve] +listen = "127.0.0.1:9099" +vkey_ref = "file:/run/secrets/owui-vkey" +enabled_models = ["mopac-study", "mopac-primary"] +` + +func TestServeSectionParsed(t *testing.T) { + cfg, err := Load(writeTemp(t, serveCfg)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Serve.Listen != "127.0.0.1:9099" { + t.Errorf("serve listen = %q", cfg.Serve.Listen) + } + if cfg.Serve.VKeyRef != "file:/run/secrets/owui-vkey" { + t.Errorf("serve vkey_ref = %q", cfg.Serve.VKeyRef) + } + if fmt.Sprint(cfg.Serve.EnabledModels) != "[mopac-study mopac-primary]" { + t.Errorf("serve enabled_models = %v", cfg.Serve.EnabledModels) + } +} + +func TestServeDefaults(t *testing.T) { + // No [serve] section: once/loop configs stay valid, default port 8090. + cfg, err := Load(writeTemp(t, testCfg)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Serve.Listen != ":8090" { + t.Errorf("default serve listen = %q, want :8090", cfg.Serve.Listen) + } + if cfg.Serve.VKeyRef != "" || len(cfg.Serve.EnabledModels) != 0 { + t.Errorf("serve must default empty: %+v", cfg.Serve) + } +} + +func TestServeValidation(t *testing.T) { + cases := []struct { + name string + mut func(string) string + want string + }{ + { + name: "bare vkey value", + mut: func(s string) string { return strings.Replace(s, `vkey_ref = "file:/run/secrets/owui-vkey"`, `vkey_ref = "sk-owui"`, 1) }, + want: "[serve]:", + }, + { + name: "enabled model not mopac-prefixed", + mut: func(s string) string { return strings.Replace(s, `"mopac-study", "mopac-primary"`, `"glm-5.2", "mopac-primary"`, 1) }, + want: `enabled_models entry "glm-5.2" is not servable`, + }, + { + name: "enabled model names no class", + mut: func(s string) string { return strings.Replace(s, `"mopac-study", "mopac-primary"`, `"mopac-urgent", "mopac-primary"`, 1) }, + want: `enabled_models entry "mopac-urgent" is not servable`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Load(writeTemp(t, tc.mut(serveCfg))) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error %q does not contain %q", err, tc.want) + } + }) + } +}