Skip to content
Draft
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ Helm chart to deploy VLLM and envoy on Kubernetes
The chart only creates custom resources that rely on these systems being installed on the cluster:

- [Gateway API](https://gateway-api.sigs.k8s.io/) CRDs (`gateway.networking.k8s.io`)
- [Envoy Gateway](https://gateway.envoyproxy.io/) with the [Envoy AI Gateway](https://aigateway.envoyproxy.io/) extension (controller in `envoy-gateway-system`)
- [Envoy Gateway](https://gateway.envoyproxy.io/) (>=v1.4.0) with the [Envoy AI Gateway](https://aigateway.envoyproxy.io/) extension.
- [Knative Serving](https://knative.dev/docs/serving/) (scale-to-zero model services)
- [cert-manager](https://cert-manager.io/) with a `ClusterIssuer` matching `envoy.clusterissuer`
- A PostgreSQL server, with roles and databases created up front. See [postgresql.md](docs/postgresql.md)

## Usage
## Installation

The repository contains a [`justfile`](justfile) to automate routine commands.
You may use it as reference, or run it with `just` (by default, just will list available recipes).
Expand Down Expand Up @@ -74,6 +74,10 @@ models:
enableTools: false # whether to allow tool calls or not
logRequests: false # whether to log all requests in the vllm pod
scaleDownDelaySeconds: 3600 # the number of seconds before the model is torn down if there is no traffic
rateLimit: # optional: token-based quota, counted per caller identity
enabled: false
requests: 500000 # tokens per caller per unit
unit: Hour
chatTemplate: # if you want to use a custom chat template. Usually left empty. The template must exist in the docker image to work
repository:
tag:
Expand All @@ -97,3 +101,31 @@ just to make sure the user is created in openwebui.
click on the user icon in the bottom left, go to "Admin Panel" -> "Settings" -> "Models". For each model, click on the
Pen icon to edit, then the "Access" button in the top right. Set to "Public", close and "save". This has to be done each
time models are changed.

A model that has scaled to zero takes a minute or two to answer the first message.

## Usage

### Web interface

Open `https://openwebui.<baseDomain>` and sign in with the "authentik" button, which
delegates to GitLab. The first sign-in creates the account.

Members of the `gateway admins` group in authentik become OpenWebUI admins.

### API access

Set `openwebui.forwardUserJwtSecret` to any high-entropy string. OpenWebUI then signs a
short-lived per-user JWT into an `X-OpenWebUI-User-Jwt` header on every request it makes to
the gateway, and the gateway is configured to accept it as a second JWT provider. Users can
then treat OpenWebUI as an OpenAI-compatible endpoint:

```bash
# Settings -> Account -> API Keys in the OpenWebUI UI
export OPENAI_API_KEY=sk-...
export OPENAI_BASE_URL=https://openwebui.<baseDomain>/api

curl "$OPENAI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \
-d '{"model":"<models.*.fullName>","messages":[{"role":"user","content":"hello"}]}'
```
175 changes: 91 additions & 84 deletions src/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"os"
"strings"
"time"

"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/env/v2"
Expand All @@ -25,8 +26,14 @@ type OpenWebUi struct {
AdminEmail string `koanf:"admin_email"`
AdminPassword string `koanf:"admin_password"`
ModelIds []string `koanf:"model_ids"`
// Enable minting of API keys. Requires the gateway accepting the
// identity JWT OpenWebUI forwards.
EnableApiKeys bool `koanf:"enable_api_keys"`
}

// url builds an absolute OpenWebUI API URL. Plain http: the call is in-cluster.
func (o OpenWebUi) url(path string) string { return "http://" + o.Host + path }

type Config struct {
Host string `koanf:"host"`
OpenWebui OpenWebUi `koanf:"open_webui"`
Expand Down Expand Up @@ -89,27 +96,32 @@ func initOpenWebui(conf Config) error {
fmt.Println("creating admin user")
adminToken, err := createOpenWebuiAdmin(conf)
if err != nil {
if errors.Is(err, ErrUserExists) {
// On upgrade
fmt.Println("admin already exists, signing in to refresh model config")
adminToken, err = signinOpenWebuiAdmin(conf)
if err != nil {
return err
}
return setupOpenaiConfig(conf, adminToken)
if !errors.Is(err, ErrUserExists) {
return err
}
// On upgrade
fmt.Println("admin already exists, signing in to refresh config")
adminToken, err = signinOpenWebuiAdmin(conf)
if err != nil {
return err
}
return err
}

// Runs on upgrades too: Stored in OpenWebUI db, env vars cannot
// reach existing instances. Each step is a fetch-mutate-post round
// trip, idempotent on repeat.
fmt.Println("configuring openwebui")
err = setupOpenWebuiConfig(conf, adminToken)
if err != nil {
if err := setupOpenWebuiConfig(conf, adminToken); err != nil {
return err
}

fmt.Println("granting users the api_keys feature")
if err := setupUserPermissions(conf, adminToken); err != nil {
return err
}

fmt.Println("setting up oauth and models")
err = setupOpenaiConfig(conf, adminToken)
if err != nil {
if err := setupOpenaiConfig(conf, adminToken); err != nil {
return err
}

Expand All @@ -125,7 +137,7 @@ func createOpenWebuiAdmin(conf Config) (string, error) {
return "", fmt.Errorf("admin password not set")
}

signupURL := fmt.Sprintf("http://%s/api/v1/auths/signup", conf.OpenWebui.Host)
signupURL := conf.OpenWebui.url("/api/v1/auths/signup")
res, err := postAuth(signupURL, map[string]string{
"name": conf.OpenWebui.AdminUser,
"email": conf.OpenWebui.AdminEmail,
Expand All @@ -148,7 +160,7 @@ func createOpenWebuiAdmin(conf Config) (string, error) {
}

func signinOpenWebuiAdmin(conf Config) (string, error) {
signinURL := fmt.Sprintf("http://%s/api/v1/auths/signin", conf.OpenWebui.Host)
signinURL := conf.OpenWebui.url("/api/v1/auths/signin")
res, err := postAuth(signinURL, map[string]string{
"email": conf.OpenWebui.AdminEmail,
"password": conf.OpenWebui.AdminPassword,
Expand Down Expand Up @@ -187,15 +199,20 @@ func tokenFromResponse(res *http.Response) (string, error) {
return userdata.Token, nil
}

func setupOpenWebuiConfig(conf Config, adminToken string) error {
configURL := fmt.Sprintf("http://%s/api/v1/auths/admin/config", conf.OpenWebui.Host)
getReq, err := http.NewRequest("GET", configURL, nil)
// configRoundTrip fetches a JSON config document, applies mutate and posts the
// result back. OpenWebUI's config endpoints replace the whole document.
func configRoundTrip(getURL, postURL, adminToken string, mutate func(map[string]any) error) error {
// Without a timeout an unresponsive OpenWebUI wedges the init Job forever, and
// the Job has no activeDeadlineSeconds to cut it short.
client := http.Client{Timeout: 30 * time.Second}
auth := fmt.Sprintf("Bearer %s", adminToken)

getReq, err := http.NewRequest("GET", getURL, nil)
if err != nil {
return fmt.Errorf("GET request creation failed: %w", err)
}
getReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken))
getReq.Header.Set("Authorization", auth)

client := http.Client{}
getResp, err := client.Do(getReq)
if err != nil {
return fmt.Errorf("config fetch failed: %w", err)
Expand All @@ -212,19 +229,20 @@ func setupOpenWebuiConfig(conf Config, adminToken string) error {
return fmt.Errorf("config parse failed: %w", err)
}

config["DEFAULT_USER_ROLE"] = "user"
if err := mutate(config); err != nil {
return fmt.Errorf("config mutation failed: %w", err)
}

updateURL := fmt.Sprintf("http://%s/api/v1/auths/admin/config", conf.OpenWebui.Host)
payload, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("config marshal failed: %w", err)
}

updateReq, err := http.NewRequest("POST", updateURL, bytes.NewBuffer(payload))
updateReq, err := http.NewRequest("POST", postURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("UPDATE request creation failed: %w", err)
}
updateReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken))
updateReq.Header.Set("Authorization", auth)
updateReq.Header.Set("Content-Type", "application/json")

updateResp, err := client.Do(updateReq)
Expand All @@ -241,66 +259,55 @@ func setupOpenWebuiConfig(conf Config, adminToken string) error {
return nil
}

func setupOpenaiConfig(conf Config, adminToken string) error {
configURL := fmt.Sprintf("http://%s/openai/config", conf.OpenWebui.Host)
getReq, err := http.NewRequest("GET", configURL, nil)
if err != nil {
return fmt.Errorf("GET request creation failed: %w", err)
}
getReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken))

client := http.Client{}
getResp, err := client.Do(getReq)
if err != nil {
return fmt.Errorf("config fetch failed: %w", err)
}
defer getResp.Body.Close()

if getResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(getResp.Body)
return fmt.Errorf("config fetch failed (status %d): %s", getResp.StatusCode, string(body))
}

var config map[string]any
if err := json.NewDecoder(getResp.Body).Decode(&config); err != nil {
return fmt.Errorf("config parse failed: %w", err)
}

// Configure OpenWebUI to use OAuth authentication for the gateway
fmt.Println("setting provider auth type")
config["OPENAI_API_CONFIGS"] = make(map[int]any)
api_conf := config["OPENAI_API_CONFIGS"].(map[int]any)
c := make(map[string]any)

c["auth_type"] = "system_oauth"
c["model_ids"] = conf.OpenWebui.ModelIds
c["enabled"] = true
c["connection_type"] = "external"
api_conf[0] = c

updateURL := fmt.Sprintf("http://%s/openai/config/update", conf.OpenWebui.Host)
payload, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("config marshal failed: %w", err)
}

updateReq, err := http.NewRequest("POST", updateURL, bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("UPDATE request creation failed: %w", err)
}
updateReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", adminToken))
updateReq.Header.Set("Content-Type", "application/json")

updateResp, err := client.Do(updateReq)
if err != nil {
return fmt.Errorf("config update failed: %w", err)
}
defer updateResp.Body.Close()
func setupOpenWebuiConfig(conf Config, adminToken string) error {
configURL := conf.OpenWebui.url("/api/v1/auths/admin/config")
return configRoundTrip(configURL, configURL, adminToken, func(config map[string]any) error {
config["DEFAULT_USER_ROLE"] = "user"
// Gates both minting and presenting an sk- key, and defaults to off.
config["ENABLE_API_KEYS"] = conf.OpenWebui.EnableApiKeys
return nil
})
}

if updateResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(updateResp.Body)
return fmt.Errorf("config update failed (status %d): %s", updateResp.StatusCode, string(body))
}
// setupUserPermissions grants non-admin users the api_keys feature. Admins bypass
// the permission check, so without this only the admin account could use a key.
func setupUserPermissions(conf Config, adminToken string) error {
permsURL := conf.OpenWebui.url("/api/v1/users/default/permissions")
return configRoundTrip(permsURL, permsURL, adminToken, func(perms map[string]any) error {
features, found := perms["features"]
if !found {
features = make(map[string]any)
perms["features"] = features
}
// Replacing a features map we failed to recognise would silently drop every
// other permission in it, so refuse rather than guess.
grants, ok := features.(map[string]any)
if !ok {
return fmt.Errorf("features permission is %T, want an object", features)
}
grants["api_keys"] = conf.OpenWebui.EnableApiKeys
return nil
})
}

return nil
func setupOpenaiConfig(conf Config, adminToken string) error {
return configRoundTrip(
conf.OpenWebui.url("/openai/config"),
conf.OpenWebui.url("/openai/config/update"),
adminToken,
func(config map[string]any) error {
// No Authorization header upstream: the gateway identifies the caller from
// the signed per-user JWT OpenWebUI forwards alongside the request.
// Keys must be the connection's index as a string; others are dropped.
config["OPENAI_API_CONFIGS"] = map[string]any{
"0": map[string]any{
"auth_type": "none",
"model_ids": conf.OpenWebui.ModelIds,
"enabled": true,
"connection_type": "external",
},
}
return nil
},
)
}
Loading
Loading