config: [serve] section for the OpenAI-compatible front door
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-<class> 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
This commit is contained in:
@@ -153,3 +153,15 @@ secret_ref = "env:HARNESS_DISCOURSE_WEBHOOK_SECRET"
|
|||||||
secret_ref = "env:HARNESS_GITEA_WEBHOOK_SECRET"
|
secret_ref = "env:HARNESS_GITEA_WEBHOOK_SECRET"
|
||||||
# Gitea always verifies via HMAC-SHA256 in X-Gitea-Signature; the
|
# Gitea always verifies via HMAC-SHA256 in X-Gitea-Signature; the
|
||||||
# secret_header override does not apply to it.
|
# 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-<class> (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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Config struct {
|
|||||||
Bash BashConfig
|
Bash BashConfig
|
||||||
Demo DemoConfig
|
Demo DemoConfig
|
||||||
Events EventsConfig
|
Events EventsConfig
|
||||||
|
Serve ServeConfig
|
||||||
KeyProxy KeyProxyConfig
|
KeyProxy KeyProxyConfig
|
||||||
Gitea GiteaConfig
|
Gitea GiteaConfig
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,14 @@ type DemoConfig struct {
|
|||||||
Class string
|
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-<class>); empty = all classes
|
||||||
|
}
|
||||||
|
|
||||||
// KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs
|
// KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs
|
||||||
// POST here. All hosts/paths live in this config, never in code.
|
// POST here. All hosts/paths live in this config, never in code.
|
||||||
type KeyProxyConfig struct {
|
type KeyProxyConfig struct {
|
||||||
@@ -146,6 +155,7 @@ func Default() *Config {
|
|||||||
SecretHeader: "X-Discourse-Webhook-Secret",
|
SecretHeader: "X-Discourse-Webhook-Secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Serve: ServeConfig{Listen: ":8090"},
|
||||||
KeyProxy: KeyProxyConfig{CacheTTLSecs: 60},
|
KeyProxy: KeyProxyConfig{CacheTTLSecs: 60},
|
||||||
Demo: DemoConfig{
|
Demo: DemoConfig{
|
||||||
ID: "demo-1",
|
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.Redmine, doc.Table("events", "redmine"))
|
||||||
applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse"))
|
applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse"))
|
||||||
applyEventSource(&c.Events.Gitea, doc.Table("events", "gitea"))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +431,24 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("[events]: listen and state_dir must not be empty")
|
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-<class>) 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-<class> from [models.classes])", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Serve.Listen == "" {
|
||||||
|
return fmt.Errorf("[serve]: listen must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
if c.Loop.PollIntervalSecs < 1 {
|
if c.Loop.PollIntervalSecs < 1 {
|
||||||
return fmt.Errorf("[loop]: poll_interval_secs must be >= 1")
|
return fmt.Errorf("[loop]: poll_interval_secs must be >= 1")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -430,3 +431,75 @@ func TestCheckKeyRefMPK(t *testing.T) {
|
|||||||
t.Errorf("local ResolveKeyRef on mpk: should name [keyproxy], got %v", err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user