Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions ext/ext.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Package ext is the public extension API for building custom gateway
// binaries on top of GoModel. External modules register request rewriters,
// HTTP middleware, extra routes, and a route selector on a Registry (usually
// ext.Default) before starting the gateway; core consumes an immutable
// snapshot of the registry at server construction. An empty registry adds
// zero request overhead.
// HTTP middleware, extra routes, runtime settings, and a route selector on a
// Registry (usually ext.Default) before starting the gateway; core consumes
// an immutable snapshot of the registry at server construction. An empty
// registry adds zero request overhead.
package ext

import (
Expand Down Expand Up @@ -81,6 +81,38 @@ type RequestRewriter interface {
Rewrite(ctx context.Context, in Input) (*Result, error)
}

// SettingOption is one allowed value for a dashboard-editable extension
// setting. Label and Description are safe to expose in the admin UI.
type SettingOption struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}

// SettingDescriptor describes one deployment-wide extension setting.
// Locked settings are controlled by an environment variable and remain
// visible, but cannot be changed through the admin API. Options must list
// every accepted value for an unlocked setting, including Value; registration
// fails when that contract is not met.
type SettingDescriptor struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Value string `json:"value"`
Locked bool `json:"locked"`
ManagedBy string `json:"managed_by,omitempty"`
Options []SettingOption `json:"options"`
}

// RuntimeSetting is a mutable, deployment-wide extension setting. Core owns
// persistence and the admin API; the extension owns validation and applying
// the value to its live runtime state. Apply receives only a value advertised
// in Descriptor.Options. Implementations must be safe for concurrent use.
type RuntimeSetting interface {
Descriptor() SettingDescriptor
Apply(value string) error
}

// RejectionError rejects the request with a client-visible status code and
// machine-readable error code, rendered in the endpoint's native error
// dialect (OpenAI or Anthropic envelope).
Expand Down
21 changes: 20 additions & 1 deletion ext/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ import (

// Registry collects extensions to be consumed by the gateway at startup.
// Register everything before the server is constructed (before run.Run or
// app.New); core snapshots the registry once and never consults it again.
// app.New); core snapshots each registration list during initialization.
type Registry struct {
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
settings []RuntimeSetting
}

// RegisterSetting adds a deployment-wide setting exposed through the generic
// admin settings API.
func (r *Registry) RegisterSetting(setting RuntimeSetting) {
r.mu.Lock()
defer r.mu.Unlock()
r.settings = append(r.settings, setting)
}

// RegisterRewriter adds a request rewriter. Rewriters run in registration
Expand Down Expand Up @@ -96,6 +105,13 @@ func (r *Registry) RouteSelector() RouteSelector {
return r.routeSelector
}

// Settings returns a defensive copy of the registered runtime settings.
func (r *Registry) Settings() []RuntimeSetting {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.settings)
}

// Default is the process-wide registry used by package-level helpers and, by
// default, by run.Run.
var Default = &Registry{}
Expand All @@ -114,3 +130,6 @@ func AddPublicPaths(paths ...string) { Default.AddPublicPaths(paths...) }

// RegisterRouteSelector installs a route selector on the Default registry.
func RegisterRouteSelector(sel RouteSelector) { Default.RegisterRouteSelector(sel) }

// RegisterSetting registers a runtime setting on the Default registry.
func RegisterSetting(setting RuntimeSetting) { Default.RegisterSetting(setting) }
22 changes: 22 additions & 0 deletions ext/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import (

type namedRewriter struct{ name string }

type testRuntimeSetting struct{ value string }

func (s *testRuntimeSetting) Descriptor() SettingDescriptor {
return SettingDescriptor{Key: "test.setting", Value: s.value}
}

func (s *testRuntimeSetting) Apply(value string) error {
s.value = value
return nil
}

func (r *namedRewriter) Name() string { return r.name }

func (r *namedRewriter) Rewrite(_ context.Context, _ Input) (*Result, error) {
Expand Down Expand Up @@ -58,6 +69,17 @@ func TestRegistryCollectsMiddlewareAndRoutes(t *testing.T) {
assert.Len(t, reg.Routes(), 1)
}

func TestRegistryCollectsRuntimeSettings(t *testing.T) {
reg := &Registry{}
reg.RegisterSetting(&testRuntimeSetting{value: "high"})

snapshot := reg.Settings()
require.Len(t, snapshot, 1)
reg.RegisterSetting(&testRuntimeSetting{value: "low"})
assert.Len(t, snapshot, 1, "earlier snapshot must not grow")
assert.Len(t, reg.Settings(), 2)
}

func TestRegistryConcurrentRegistration(t *testing.T) {
reg := &Registry{}
const workers = 16
Expand Down
68 changes: 0 additions & 68 deletions internal/admin/dashboard/static/dist/assets/index-B-Rv4AUL.js

This file was deleted.

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-D6HB9TkL.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/enterpilot/gomodel/internal/providers"
"github.com/enterpilot/gomodel/internal/providers/health"
"github.com/enterpilot/gomodel/internal/ratelimit"
"github.com/enterpilot/gomodel/internal/runtimesettings"
"github.com/enterpilot/gomodel/internal/tagging"
"github.com/enterpilot/gomodel/internal/usage"
"github.com/enterpilot/gomodel/internal/virtualmodels"
Expand All @@ -46,6 +47,7 @@ type Handler struct {
budgets *budget.Service
rateLimits *ratelimit.Service
tagging *tagging.Service
runtimeSettings *runtimesettings.Service
guardrails guardrails.Catalog
guardrailDefs *guardrails.Service
liveBroker *live.Broker
Expand Down Expand Up @@ -343,6 +345,13 @@ func WithRuntimeRefresher(refresher RuntimeRefresher) Option {
}
}

// WithRuntimeSettings enables deployment-wide extension settings.
func WithRuntimeSettings(settings *runtimesettings.Service) Option {
return func(h *Handler) {
h.runtimeSettings = settings
}
}

// WithConfiguredProviders enables the admin-safe provider inventory endpoint.
func WithConfiguredProviders(configs []providers.SanitizedProviderConfig) Option {
return func(h *Handler) {
Expand Down
56 changes: 56 additions & 0 deletions internal/admin/handler_runtime_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package admin

import (
"errors"
"net/http"
"strings"

"github.com/labstack/echo/v5"

"github.com/enterpilot/gomodel/ext"
"github.com/enterpilot/gomodel/internal/core"
"github.com/enterpilot/gomodel/internal/runtimesettings"
)

type runtimeSettingsResponse struct {
Settings []ext.SettingDescriptor `json:"settings"`
}

type updateRuntimeSettingRequest struct {
Value string `json:"value"`
}

// RuntimeSettings lists extension-defined settings for the Dashboard.
func (h *Handler) RuntimeSettings(c *echo.Context) error {
settings := []ext.SettingDescriptor{}
if h.runtimeSettings != nil {
settings = h.runtimeSettings.List()
}
return c.JSON(http.StatusOK, runtimeSettingsResponse{Settings: settings})
}

// UpdateRuntimeSetting validates and persists one extension-defined setting.
func (h *Handler) UpdateRuntimeSetting(c *echo.Context) error {
if h.runtimeSettings == nil {
return handleError(c, featureUnavailableError("runtime settings are unavailable"))
}
var req updateRuntimeSettingRequest
if err := c.Bind(&req); err != nil {
return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err))
}
key := strings.TrimSpace(c.Param("key"))
setting, err := h.runtimeSettings.Update(c.Request().Context(), key, req.Value)
if err != nil {
switch {
case errors.Is(err, runtimesettings.ErrNotFound):
return handleError(c, core.NewNotFoundError("runtime setting not found").WithCode("runtime_setting_not_found"))
case errors.Is(err, runtimesettings.ErrLocked):
return handleError(c, core.NewInvalidRequestError("runtime setting is managed by an environment variable", err))
case errors.Is(err, runtimesettings.ErrInvalid):
return handleError(c, core.NewInvalidRequestError(err.Error(), err))
default:
return handleError(c, featureUnavailableError("failed to save runtime setting: "+err.Error()))
}
}
return c.JSON(http.StatusOK, setting)
}
152 changes: 152 additions & 0 deletions internal/admin/handler_runtime_settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package admin

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"

"github.com/labstack/echo/v5"

"github.com/enterpilot/gomodel/ext"
"github.com/enterpilot/gomodel/internal/runtimesettings"
"github.com/enterpilot/gomodel/internal/storage"
)

type adminTestRuntimeSetting struct {
mu sync.Mutex
value string
locked bool
}

func (s *adminTestRuntimeSetting) Descriptor() ext.SettingDescriptor {
s.mu.Lock()
defer s.mu.Unlock()
return ext.SettingDescriptor{
Key: "pro.compression.level",
Label: "Prompt compression level",
Value: s.value,
Locked: s.locked,
Options: []ext.SettingOption{
{Value: "none", Label: "None"},
{Value: "high", Label: "High"},
},
}
}

func (s *adminTestRuntimeSetting) Apply(value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if value != "none" && value != "high" {
return fmt.Errorf("invalid level")
}
s.value = value
return nil
}

func (s *adminTestRuntimeSetting) currentValue() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.value
}

func newAdminRuntimeSettingsService(t *testing.T, setting ext.RuntimeSetting) *runtimesettings.Service {
t.Helper()
backend, err := storage.NewSQLite(storage.SQLiteConfig{Path: filepath.Join(t.TempDir(), "admin-settings.db")})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = backend.Close() })
service, err := runtimesettings.New(context.Background(), backend, []ext.RuntimeSetting{setting})
if err != nil {
t.Fatalf("create runtime settings service: %v", err)
}
t.Cleanup(func() { _ = service.Close() })
return service
}

func runtimeSettingsRequest(e *echo.Echo, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}

func TestRuntimeSettingsListAndUpdate(t *testing.T) {
setting := &adminTestRuntimeSetting{value: "high"}
h := NewHandler(nil, nil, WithRuntimeSettings(newAdminRuntimeSettingsService(t, setting)))

e := echo.New()
h.RegisterRoutes(e.Group("/admin"))
listRec := runtimeSettingsRequest(e, http.MethodGet, "/admin/runtime/settings", "")
var list runtimeSettingsResponse
if err := json.Unmarshal(listRec.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list.Settings) != 1 || list.Settings[0].Value != "high" {
t.Fatalf("settings = %+v", list.Settings)
}

updateRec := runtimeSettingsRequest(e, http.MethodPut, "/admin/runtime/settings/pro.compression.level", `{"value":"none"}`)
if updateRec.Code != http.StatusOK || setting.currentValue() != "none" {
t.Fatalf("update status=%d value=%q body=%s", updateRec.Code, setting.currentValue(), updateRec.Body.String())
}
}

func TestRuntimeSettingManagedByEnvironmentIsReadOnly(t *testing.T) {
setting := &adminTestRuntimeSetting{value: "high", locked: true}
h := NewHandler(nil, nil, WithRuntimeSettings(newAdminRuntimeSettingsService(t, setting)))

e := echo.New()
h.RegisterRoutes(e.Group("/admin"))
rec := runtimeSettingsRequest(e, http.MethodPut, "/admin/runtime/settings/pro.compression.level", `{"value":"none"}`)
if rec.Code != http.StatusBadRequest || setting.currentValue() != "high" {
t.Fatalf("locked update status=%d value=%q body=%s", rec.Code, setting.currentValue(), rec.Body.String())
}
}

func TestUpdateRuntimeSettingRejectsUnknownKeyAndInvalidValue(t *testing.T) {
setting := &adminTestRuntimeSetting{value: "high"}
h := NewHandler(nil, nil, WithRuntimeSettings(newAdminRuntimeSettingsService(t, setting)))
e := echo.New()
h.RegisterRoutes(e.Group("/admin"))

unknown := runtimeSettingsRequest(e, http.MethodPut, "/admin/runtime/settings/missing", `{"value":"none"}`)
if unknown.Code != http.StatusNotFound || !strings.Contains(unknown.Body.String(), `"code":"runtime_setting_not_found"`) {
t.Fatalf("unknown update status=%d body=%s", unknown.Code, unknown.Body.String())
}
invalid := runtimeSettingsRequest(e, http.MethodPut, "/admin/runtime/settings/pro.compression.level", `{"value":"turbo"}`)
if invalid.Code != http.StatusBadRequest {
t.Fatalf("invalid update status=%d body=%s", invalid.Code, invalid.Body.String())
}
if setting.currentValue() != "high" {
t.Fatalf("rejected updates changed value to %q", setting.currentValue())
}
}

func TestRuntimeSettingsWithoutRegisteredExtensions(t *testing.T) {
h := NewHandler(nil, nil)
e := echo.New()
h.RegisterRoutes(e.Group("/admin"))

list := runtimeSettingsRequest(e, http.MethodGet, "/admin/runtime/settings", "")
var response runtimeSettingsResponse
if err := json.Unmarshal(list.Body.Bytes(), &response); err != nil {
t.Fatalf("decode empty list: %v", err)
}
if list.Code != http.StatusOK || len(response.Settings) != 0 {
t.Fatalf("empty list status=%d body=%s", list.Code, list.Body.String())
}
update := runtimeSettingsRequest(e, http.MethodPut, "/admin/runtime/settings/pro.compression.level", `{"value":"high"}`)
if update.Code != http.StatusServiceUnavailable || !strings.Contains(update.Body.String(), `"code":"feature_unavailable"`) {
t.Fatalf("unavailable update status=%d body=%s", update.Code, update.Body.String())
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions internal/admin/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type RouteRegistrar interface {
// Callers typically pass an *echo.Group rooted at /admin.
func (h *Handler) RegisterRoutes(g RouteRegistrar) {
g.GET("/runtime/config", h.DashboardConfig)
g.GET("/runtime/settings", h.RuntimeSettings)
g.PUT("/runtime/settings/:key", h.UpdateRuntimeSetting)
g.GET("/cache/overview", h.CacheOverview)
g.GET("/live/logs", h.LiveLogs)

Expand Down
Loading