Add LiteLLM dynamic secrets engine implementation

Populate the repo with the Vault/OpenBao dynamic secrets engine that mints
LiteLLM virtual keys scoped by model, spending limit, and lease TTL.

- Secrets backend: config, roles, creds paths and a revocable litellm_key type
- LiteLLM API client (generate/update/delete/info) with master-key auth
- Unit tests (mock LiteLLM) and a docker-compose e2e against both Vault and
  OpenBao proving the same binary works on each
- Makefile, woodpecker CI (build/test/pre-commit), pre-commit config
This commit is contained in:
2026-07-02 23:22:18 +10:00
parent aa6914cd97
commit ab3b02a48e
26 changed files with 2613 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
/dist/
/vault-plugin-secrets-litellm
*.out
*.test
.env
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
+8
View File
@@ -0,0 +1,8 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+13
View File
@@ -0,0 +1,13 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
- name: test
image: golang:1.25
commands:
- make test
+73
View File
@@ -0,0 +1,73 @@
.PHONY: build install test lint fmt clean tidy e2e e2e-vault e2e-openbao e2e-up e2e-down patch minor major check-go
BINARY := vault-plugin-secrets-litellm
PKG := ./cmd/vault-plugin-secrets-litellm
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
PLUGIN_DIR ?= ./dist
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
install: build
@echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256=<sha> secret $(BINARY))"
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
tidy:
go mod tidy
clean:
rm -rf $(PLUGIN_DIR)
# End-to-end tests spin up LiteLLM + Postgres and both Vault and OpenBao in
# Docker, then exercise the full lifecycle (configure, create role, generate,
# use, revoke) against each engine using the same plugin binary.
e2e:
./scripts/e2e.sh
e2e-vault:
ENGINES=vault ./scripts/e2e.sh
e2e-openbao:
ENGINES=openbao ./scripts/e2e.sh
e2e-up:
docker compose -f test/docker-compose.yml up -d --build
e2e-down:
docker compose -f test/docker-compose.yml down -v
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+113 -1
View File
@@ -1,3 +1,115 @@
# vault-plugin-secrets-litellm
HashiCorp Vault / OpenBao dynamic secrets engine for LiteLLM virtual keys
A dynamic secrets engine for [LiteLLM](https://github.com/BerriAI/litellm) that
runs on both **HashiCorp Vault** and **[OpenBao](https://openbao.org)**.
It generates **virtual API keys** on a LiteLLM proxy that are:
- **scoped to specific models** (`models`)
- capped by a **spending limit** (`max_budget`)
- bound to a **lease TTL** — revoking the lease revokes the key in LiteLLM
## Vault and OpenBao
OpenBao is a fork of Vault and keeps its plugin protocol compatible, so the
**same plugin binary** registers and runs on either engine unchanged — the CLI
commands below are identical apart from `vault` vs `bao`. The end-to-end test
suite exercises the full lifecycle against both to prove it.
## How it works
The backend authenticates to the LiteLLM proxy with the master key and manages
virtual keys through the proxy's key-management API (`/key/generate`,
`/key/update`, `/key/delete`, `/key/info`). Each generated key is wrapped in a
Vault lease, so Vault owns the key's lifecycle: renew extends it, revoke deletes
it.
```
┌─────────┐ read creds/<role> ┌──────────────────────┐ /key/generate ┌──────────┐
│ client │ ───────────────────► │ vault + litellm plugin│ ───────────────► │ litellm │
└─────────┘ ◄─── virtual key ── └──────────────────────┘ ◄── sk-... ───── └──────────┘
```
## Usage
```sh
# 1. Enable the engine
vault secrets enable -path=litellm vault-plugin-secrets-litellm
# 2. Configure the connection to the LiteLLM proxy
vault write litellm/config \
base_url=http://litellm:4000 \
master_key=sk-master-...
# 3. Define a role: which models, how much budget, what TTL
vault write litellm/roles/team-a \
models="gpt-3.5-turbo,gpt-4" \
max_budget=50 \
ttl=1h \
max_ttl=24h
# 4. Generate a scoped, budgeted, time-limited virtual key
vault read litellm/creds/team-a
# Key key
# --- ---
# lease_id litellm/creds/team-a/AbC...
# lease_duration 1h
# key sk-...
# max_budget 50
# models [gpt-3.5-turbo gpt-4]
# 5. Revoking the lease revokes the key in LiteLLM
vault lease revoke litellm/creds/team-a/AbC...
```
## Paths
| Path | Ops | Description |
| ------------------------ | ------------------ | -------------------------------------------------- |
| `config` | read/write/delete | LiteLLM connection (`base_url`, `master_key`) |
| `roles/<name>` | read/write/delete | Constraints for generated keys |
| `roles/` | list | List configured roles |
| `creds/<name>` | read | Generate a virtual key for the role |
### Role fields
| Field | Type | Description |
| ------------------ | -------- | ------------------------------------------------------ |
| `models` | list | Allowed models; empty means unrestricted |
| `max_budget` | float | Spending limit per key; 0 means unlimited |
| `key_alias_prefix` | string | Prefix for the generated key alias (default `vault`) |
| `metadata` | kv pairs | Metadata attached to each generated key |
| `ttl` | duration | Default lease TTL |
| `max_ttl` | duration | Maximum lease TTL |
## Development
```sh
make build # build the plugin into ./dist
make test # unit tests (race-enabled)
make lint # go vet
make fmt # gofmt
make e2e # full end-to-end test in Docker against Vault AND OpenBao
make e2e-vault # e2e against Vault only
make e2e-openbao # e2e against OpenBao only
```
### End-to-end tests
`make e2e` builds the plugin, spins up a LiteLLM proxy with its Postgres key
store, plus both a Vault and an OpenBao dev server with the plugin mounted, then
exercises the whole lifecycle against **each engine**: configure → create role →
generate key → call an allowed model → verify a disallowed model is rejected →
revoke → verify the key stops working.
Requires Docker. Bind mounts use the `:z` flag so the stack works under SELinux
(Fedora/RHEL). Select engines with `ENGINES="vault openbao"`.
### Releasing
Versioning is tag-driven:
```sh
make patch # v0.1.0 -> v0.1.1
make minor # v0.1.1 -> v0.2.0
make major # v0.2.0 -> v1.0.0
```
+118
View File
@@ -0,0 +1,118 @@
package litellm
import (
"context"
"strings"
"sync"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// litellmBackend is the Vault secrets backend that manages LiteLLM virtual keys.
type litellmBackend struct {
*framework.Backend
lock sync.RWMutex
client *litellmClient
}
// Factory returns a configured LiteLLM secrets backend.
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
b := backend()
if err := b.Setup(ctx, conf); err != nil {
return nil, err
}
return b, nil
}
func backend() *litellmBackend {
b := &litellmBackend{}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
BackendType: logical.TypeLogical,
PathsSpecial: &logical.Paths{
LocalStorage: []string{},
SealWrapStorage: []string{
configStoragePath,
},
},
Paths: framework.PathAppend(
[]*framework.Path{
pathConfig(b),
pathRole(b),
pathRolesList(b),
pathCredentials(b),
},
),
Secrets: []*framework.Secret{
b.litellmKey(),
},
Invalidate: b.invalidate,
WALRollback: nil,
}
return b
}
// reset drops the cached LiteLLM client so it is rebuilt from storage on the
// next request. Called when the config changes.
func (b *litellmBackend) reset() {
b.lock.Lock()
defer b.lock.Unlock()
b.client = nil
}
// invalidate clears the cached client when the config is written from another
// cluster node.
func (b *litellmBackend) invalidate(_ context.Context, key string) {
if key == configStoragePath {
b.reset()
}
}
// getClient returns a cached LiteLLM client, building one from stored config if
// necessary.
func (b *litellmBackend) getClient(ctx context.Context, s logical.Storage) (*litellmClient, error) {
b.lock.RLock()
if b.client != nil {
defer b.lock.RUnlock()
return b.client, nil
}
b.lock.RUnlock()
b.lock.Lock()
defer b.lock.Unlock()
// Re-check after acquiring the write lock.
if b.client != nil {
return b.client, nil
}
config, err := getConfig(ctx, s)
if err != nil {
return nil, err
}
if config == nil {
return nil, errBackendNotConfigured
}
client, err := newClient(config)
if err != nil {
return nil, err
}
b.client = client
return b.client, nil
}
const backendHelp = `
The LiteLLM secrets backend dynamically generates LiteLLM virtual API keys
scoped to specific models, with a spending limit and a lease-bound TTL.
After mounting this backend, configure it with the connection details for the
LiteLLM proxy using the "config" path, then define one or more roles that
constrain the generated keys. Reading from the "creds/<role>" path issues a new
virtual key whose lifetime is managed by Vault: revoking the lease revokes the
key in LiteLLM.
`
+216
View File
@@ -0,0 +1,216 @@
package litellm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
// getTestBackend returns a configured backend backed by in-memory storage.
func getTestBackend(t *testing.T) (*litellmBackend, logical.Storage) {
t.Helper()
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = logical.TestSystemView()
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("unexpected error creating backend: %v", err)
}
return b.(*litellmBackend), config.StorageView
}
// mockLiteLLM is an in-memory fake of the LiteLLM key-management API.
type mockLiteLLM struct {
server *httptest.Server
mu sync.Mutex
keys map[string]mockKey // key value -> key
counter int
masterKey string
// generateErr, when set, makes /key/generate return 500.
generateErr bool
lastRequest map[string]interface{}
}
type mockKey struct {
Alias string
Models []string
MaxBudget *float64
Duration string
}
func newMockLiteLLM(t *testing.T) *mockLiteLLM {
t.Helper()
m := &mockLiteLLM{
keys: make(map[string]mockKey),
masterKey: "sk-master-1234",
}
mux := http.NewServeMux()
mux.HandleFunc("/key/generate", m.handleGenerate)
mux.HandleFunc("/key/delete", m.handleDelete)
mux.HandleFunc("/key/update", m.handleUpdate)
mux.HandleFunc("/key/info", m.handleInfo)
m.server = httptest.NewServer(m.authMiddleware(mux))
t.Cleanup(m.server.Close)
return m
}
func (m *mockLiteLLM) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+m.masterKey {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (m *mockLiteLLM) keyCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.keys)
}
func (m *mockLiteLLM) handleGenerate(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
if m.generateErr {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
m.lastRequest = body
m.counter++
value := "sk-generated-" + strconv.Itoa(m.counter)
mk := mockKey{}
if alias, ok := body["key_alias"].(string); ok {
mk.Alias = alias
}
if models, ok := body["models"].([]interface{}); ok {
for _, mdl := range models {
if s, ok := mdl.(string); ok {
mk.Models = append(mk.Models, s)
}
}
}
if budget, ok := body["max_budget"].(float64); ok {
mk.MaxBudget = &budget
}
if dur, ok := body["duration"].(string); ok {
mk.Duration = dur
}
m.keys[value] = mk
writeJSON(w, map[string]interface{}{
"key": value,
"key_name": value,
"token_id": "tok-" + strconv.Itoa(m.counter),
"models": mk.Models,
"max_budget": mk.MaxBudget,
})
}
func (m *mockLiteLLM) handleDelete(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
var body struct {
Keys []string `json:"keys"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
return
}
deleted := 0
for _, k := range body.Keys {
if _, ok := m.keys[k]; ok {
delete(m.keys, k)
deleted++
}
}
writeJSON(w, map[string]interface{}{"deleted_keys": deleted})
}
func (m *mockLiteLLM) handleUpdate(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
return
}
key, _ := body["key"].(string)
mk, ok := m.keys[key]
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if dur, ok := body["duration"].(string); ok {
mk.Duration = dur
}
if budget, ok := body["max_budget"].(float64); ok {
mk.MaxBudget = &budget
}
m.keys[key] = mk
writeJSON(w, map[string]interface{}{"key": key})
}
func (m *mockLiteLLM) handleInfo(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
key := r.URL.Query().Get("key")
mk, ok := m.keys[key]
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"key": key,
"info": map[string]interface{}{
"models": mk.Models,
"max_budget": mk.MaxBudget,
"key_name": mk.Alias,
},
})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
// writeTestConfig stores a config pointing at the given base URL.
func writeTestConfig(t *testing.T, b *litellmBackend, s logical.Storage, baseURL, masterKey string) {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{
"base_url": baseURL,
"master_key": masterKey,
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("failed to write config: err=%v resp=%v", err, resp)
}
}
+172
View File
@@ -0,0 +1,172 @@
package litellm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// errBackendNotConfigured is returned when an operation needs the LiteLLM
// connection config but none has been written yet.
var errBackendNotConfigured = errors.New("litellm backend not configured: write connection details to config/ first")
const defaultHTTPTimeout = 30 * time.Second
// litellmClient talks to a LiteLLM proxy's key-management API using the master
// key for authentication.
type litellmClient struct {
baseURL string
masterKey string
httpClient *http.Client
}
func newClient(config *litellmConfig) (*litellmClient, error) {
if config == nil {
return nil, errors.New("litellm client configuration is nil")
}
if config.BaseURL == "" {
return nil, errors.New("base_url is required")
}
if config.MasterKey == "" {
return nil, errors.New("master_key is required")
}
timeout := defaultHTTPTimeout
if config.RequestTimeoutSeconds > 0 {
timeout = time.Duration(config.RequestTimeoutSeconds) * time.Second
}
return &litellmClient{
baseURL: strings.TrimRight(config.BaseURL, "/"),
masterKey: config.MasterKey,
httpClient: &http.Client{Timeout: timeout},
}, nil
}
// generateKeyRequest is the payload for POST /key/generate.
type generateKeyRequest struct {
Models []string `json:"models,omitempty"`
MaxBudget *float64 `json:"max_budget,omitempty"`
Duration string `json:"duration,omitempty"`
KeyAlias string `json:"key_alias,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// GenerateKeyResponse is the subset of the /key/generate response we consume.
type GenerateKeyResponse struct {
Key string `json:"key"`
KeyName string `json:"key_name"`
TokenID string `json:"token_id"`
Expires string `json:"expires"`
MaxBudget *float64 `json:"max_budget"`
Models []string `json:"models"`
}
// updateKeyRequest is the payload for POST /key/update.
type updateKeyRequest struct {
Key string `json:"key"`
Duration string `json:"duration,omitempty"`
MaxBudget *float64 `json:"max_budget,omitempty"`
Models []string `json:"models,omitempty"`
}
// KeyInfoResponse is the subset of the /key/info response we consume.
type KeyInfoResponse struct {
Key string `json:"key"`
Info struct {
Models []string `json:"models"`
MaxBudget *float64 `json:"max_budget"`
Spend float64 `json:"spend"`
Expires string `json:"expires"`
KeyName string `json:"key_name"`
} `json:"info"`
}
// GenerateKey creates a new virtual key on the LiteLLM proxy.
func (c *litellmClient) GenerateKey(ctx context.Context, req generateKeyRequest) (*GenerateKeyResponse, error) {
var out GenerateKeyResponse
if err := c.do(ctx, http.MethodPost, "/key/generate", req, &out); err != nil {
return nil, err
}
if out.Key == "" {
return nil, errors.New("litellm returned an empty key")
}
return &out, nil
}
// UpdateKey changes the TTL, budget, or model scope of an existing key.
func (c *litellmClient) UpdateKey(ctx context.Context, req updateKeyRequest) error {
if req.Key == "" {
return errors.New("key is required to update")
}
return c.do(ctx, http.MethodPost, "/key/update", req, nil)
}
// DeleteKey revokes a virtual key on the LiteLLM proxy.
func (c *litellmClient) DeleteKey(ctx context.Context, key string) error {
if key == "" {
return errors.New("key is required to delete")
}
body := map[string]interface{}{"keys": []string{key}}
return c.do(ctx, http.MethodPost, "/key/delete", body, nil)
}
// KeyInfo fetches metadata about an existing key.
func (c *litellmClient) KeyInfo(ctx context.Context, key string) (*KeyInfoResponse, error) {
path := "/key/info?" + url.Values{"key": {key}}.Encode()
var out KeyInfoResponse
if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// do performs an authenticated HTTP request against the LiteLLM proxy and
// decodes the JSON response into out (when non-nil).
func (c *litellmClient) do(ctx context.Context, method, path string, payload, out interface{}) error {
var bodyReader io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encoding request body: %w", err)
}
bodyReader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.masterKey)
req.Header.Set("Accept", "application/json")
if bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("calling litellm %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("litellm %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decoding litellm response: %w", err)
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package litellm
import (
"context"
"testing"
)
func TestClient_GenerateKey(t *testing.T) {
m := newMockLiteLLM(t)
client, err := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey})
if err != nil {
t.Fatalf("newClient: %v", err)
}
budget := 25.0
resp, err := client.GenerateKey(context.Background(), generateKeyRequest{
Models: []string{"gpt-4"},
MaxBudget: &budget,
Duration: "3600s",
KeyAlias: "vault-test",
})
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if resp.Key == "" {
t.Fatal("expected a non-empty key")
}
if m.keyCount() != 1 {
t.Fatalf("expected 1 key on server, got %d", m.keyCount())
}
if got := m.lastRequest["key_alias"]; got != "vault-test" {
t.Fatalf("expected alias forwarded, got %v", got)
}
if got := m.lastRequest["max_budget"]; got != 25.0 {
t.Fatalf("expected budget forwarded, got %v", got)
}
}
func TestClient_DeleteKey(t *testing.T) {
m := newMockLiteLLM(t)
client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey})
resp, err := client.GenerateKey(context.Background(), generateKeyRequest{KeyAlias: "vault-test"})
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if err := client.DeleteKey(context.Background(), resp.Key); err != nil {
t.Fatalf("DeleteKey: %v", err)
}
if m.keyCount() != 0 {
t.Fatalf("expected 0 keys after delete, got %d", m.keyCount())
}
}
func TestClient_UpdateKey(t *testing.T) {
m := newMockLiteLLM(t)
client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey})
resp, _ := client.GenerateKey(context.Background(), generateKeyRequest{KeyAlias: "vault-test"})
if err := client.UpdateKey(context.Background(), updateKeyRequest{Key: resp.Key, Duration: "7200s"}); err != nil {
t.Fatalf("UpdateKey: %v", err)
}
if got := m.keys[resp.Key].Duration; got != "7200s" {
t.Fatalf("expected duration updated to 7200s, got %q", got)
}
}
func TestClient_AuthFailure(t *testing.T) {
m := newMockLiteLLM(t)
client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: "wrong-key"})
_, err := client.GenerateKey(context.Background(), generateKeyRequest{})
if err == nil {
t.Fatal("expected an auth error, got nil")
}
}
func TestClient_ServerError(t *testing.T) {
m := newMockLiteLLM(t)
m.generateErr = true
client, _ := newClient(&litellmConfig{BaseURL: m.server.URL, MasterKey: m.masterKey})
if _, err := client.GenerateKey(context.Background(), generateKeyRequest{}); err == nil {
t.Fatal("expected a server error, got nil")
}
}
func TestNewClient_Validation(t *testing.T) {
cases := []struct {
name string
config *litellmConfig
wantErr bool
}{
{"nil config", nil, true},
{"missing base_url", &litellmConfig{MasterKey: "sk"}, true},
{"missing master_key", &litellmConfig{BaseURL: "http://x"}, true},
{"valid", &litellmConfig{BaseURL: "http://x", MasterKey: "sk"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := newClient(tc.config)
if (err != nil) != tc.wantErr {
t.Fatalf("newClient err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
client, err := newClient(&litellmConfig{BaseURL: "http://localhost:4000/", MasterKey: "sk"})
if err != nil {
t.Fatalf("newClient: %v", err)
}
if client.baseURL != "http://localhost:4000" {
t.Fatalf("expected trailing slash trimmed, got %q", client.baseURL)
}
}
func TestDurationToLiteLLM(t *testing.T) {
if got := durationToLiteLLM(90); got != "0s" {
t.Fatalf("sub-second duration should floor to 0s, got %q", got)
}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"os"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/plugin"
litellm "git.unkin.net/unkin/vault-plugin-secrets-litellm"
)
func main() {
apiClientMeta := &api.PluginAPIClientMeta{}
flags := apiClientMeta.FlagSet()
if err := flags.Parse(os.Args[1:]); err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("failed to parse flags", "error", err)
os.Exit(1)
}
tlsConfig := apiClientMeta.GetTLSConfig()
tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
err := plugin.ServeMultiplex(&plugin.ServeOpts{
BackendFactoryFunc: litellm.Factory,
TLSProviderFunc: tlsProviderFunc,
})
if err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("plugin shutting down", "error", err)
os.Exit(1)
}
}
+90
View File
@@ -0,0 +1,90 @@
module git.unkin.net/unkin/vault-plugin-secrets-litellm
go 1.25.0
require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/vault/api v1.15.0
github.com/hashicorp/vault/sdk v0.14.0
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v26.1.5+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.6.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sasha-s/go-deadlock v0.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+340
View File
@@ -0,0 +1,340 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8=
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 h1:9Q2lu1YbbmiAgvYZ7Pr31RdlVonUpX+mmDL7Z7qTA2U=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8/go.mod h1:qTCjxGig/kjuj3hk1z8pOUrzbse/GxB1tGfbrq8tGJg=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI=
github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 h1:7Yran48kl6X7jfUg3sfYDrFot1gD3LvzdC3oPu5l/qo=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0/go.mod h1:9WJFu7L3d+Z4ViZmwUf+6/73/Uy7YMY1NXrB9wdElYE=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM=
github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
github.com/hashicorp/vault/sdk v0.14.0 h1:8vagjlpLurkFTnKT9aFSGs4U1XnK2IFytnWSxgFrDo0=
github.com/hashicorp/vault/sdk v0.14.0/go.mod h1:3hnGK5yjx3CW2hFyk+Dw1jDgKxdBvUvjyxMHhq0oUFc=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4=
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8=
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y=
github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+166
View File
@@ -0,0 +1,166 @@
package litellm
import (
"context"
"errors"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const configStoragePath = "config"
// litellmConfig holds the connection details for the LiteLLM proxy.
type litellmConfig struct {
BaseURL string `json:"base_url"`
MasterKey string `json:"master_key"`
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
}
func pathConfig(b *litellmBackend) *framework.Path {
return &framework.Path{
Pattern: "config",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "litellm",
OperationSuffix: "config",
},
Fields: map[string]*framework.FieldSchema{
"base_url": {
Type: framework.TypeString,
Description: "Base URL of the LiteLLM proxy, e.g. http://litellm:4000.",
Required: true,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Base URL",
},
},
"master_key": {
Type: framework.TypeString,
Description: "LiteLLM master key (sk-...) used to manage virtual keys.",
Required: true,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Master Key",
Sensitive: true,
},
},
"request_timeout_seconds": {
Type: framework.TypeInt,
Description: "HTTP timeout in seconds for calls to the LiteLLM proxy (default 30).",
Default: 30,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathConfigRead,
},
logical.CreateOperation: &framework.PathOperation{
Callback: b.pathConfigWrite,
},
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathConfigWrite,
},
logical.DeleteOperation: &framework.PathOperation{
Callback: b.pathConfigDelete,
},
},
ExistenceCheck: b.pathConfigExistenceCheck,
HelpSynopsis: "Configure the connection to the LiteLLM proxy.",
HelpDescription: "Configure the base URL and master key that the backend uses to manage LiteLLM virtual keys.",
}
}
func (b *litellmBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return false, err
}
return config != nil, nil
}
func (b *litellmBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
// The master key is deliberately not returned.
return &logical.Response{
Data: map[string]interface{}{
"base_url": config.BaseURL,
"request_timeout_seconds": config.RequestTimeoutSeconds,
},
}, nil
}
func (b *litellmBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
if req.Operation == logical.UpdateOperation {
return nil, errors.New("config not found during update operation")
}
config = &litellmConfig{}
}
if v, ok := data.GetOk("base_url"); ok {
config.BaseURL = v.(string)
}
if v, ok := data.GetOk("master_key"); ok {
config.MasterKey = v.(string)
}
if v, ok := data.GetOk("request_timeout_seconds"); ok {
config.RequestTimeoutSeconds = v.(int)
} else if req.Operation == logical.CreateOperation {
config.RequestTimeoutSeconds = data.Get("request_timeout_seconds").(int)
}
if config.BaseURL == "" {
return logical.ErrorResponse("base_url is required"), nil
}
if config.MasterKey == "" {
return logical.ErrorResponse("master_key is required"), nil
}
entry, err := logical.StorageEntryJSON(configStoragePath, config)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
// Force the cached client to be rebuilt with the new config.
b.reset()
return nil, nil
}
func (b *litellmBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
if err := req.Storage.Delete(ctx, configStoragePath); err != nil {
return nil, err
}
b.reset()
return nil, nil
}
// getConfig reads and decodes the stored LiteLLM config, returning nil if none
// exists.
func getConfig(ctx context.Context, s logical.Storage) (*litellmConfig, error) {
entry, err := s.Get(ctx, configStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
config := &litellmConfig{}
if err := entry.DecodeJSON(config); err != nil {
return nil, err
}
return config, nil
}
+96
View File
@@ -0,0 +1,96 @@
package litellm
import (
"context"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func TestConfig_WriteReadDelete(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
// Write.
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{
"base_url": "http://litellm:4000",
"master_key": "sk-master-1234",
"request_timeout_seconds": 15,
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write config: err=%v resp=%v", err, resp)
}
// Read must not leak the master key.
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "config",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read config: err=%v resp=%v", err, resp)
}
if resp.Data["base_url"] != "http://litellm:4000" {
t.Fatalf("unexpected base_url: %v", resp.Data["base_url"])
}
if resp.Data["request_timeout_seconds"] != 15 {
t.Fatalf("unexpected timeout: %v", resp.Data["request_timeout_seconds"])
}
if _, ok := resp.Data["master_key"]; ok {
t.Fatal("master_key must not be returned on read")
}
// Delete.
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.DeleteOperation,
Path: "config",
Storage: s,
}); err != nil {
t.Fatalf("delete config: %v", err)
}
cfg, err := getConfig(ctx, s)
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if cfg != nil {
t.Fatal("expected config to be nil after delete")
}
}
func TestConfig_RequiredFields(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{"base_url": "http://litellm:4000"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error response when master_key is missing")
}
}
func TestConfig_DefaultTimeout(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
writeTestConfig(t, b, s, "http://litellm:4000", "sk-master-1234")
cfg, err := getConfig(ctx, s)
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if cfg.RequestTimeoutSeconds != 30 {
t.Fatalf("expected default timeout 30, got %d", cfg.RequestTimeoutSeconds)
}
}
+135
View File
@@ -0,0 +1,135 @@
package litellm
import (
"context"
"fmt"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathCredentials(b *litellmBackend) *framework.Path {
return &framework.Path{
Pattern: "creds/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "litellm",
OperationSuffix: "credentials",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role to generate a key for.",
Required: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathCredentialsRead,
},
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathCredentialsRead,
},
},
HelpSynopsis: "Generate a LiteLLM virtual key from a role.",
HelpDescription: "Reading from this path generates a new LiteLLM virtual key scoped by the named role's models, budget, and TTL.",
}
}
func (b *litellmBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
roleName := data.Get("name").(string)
role, err := b.getRole(ctx, req.Storage, roleName)
if err != nil {
return nil, err
}
if role == nil {
return logical.ErrorResponse("role %q does not exist", roleName), nil
}
return b.createKey(ctx, req, roleName, role)
}
// createKey issues a new LiteLLM virtual key for the given role and wraps it in
// a Vault lease.
func (b *litellmBackend) createKey(ctx context.Context, req *logical.Request, roleName string, role *litellmRole) (*logical.Response, error) {
client, err := b.getClient(ctx, req.Storage)
if err != nil {
return nil, err
}
// Resolve the lease TTL against the role and mount/system limits.
ttl, maxTTL := b.resolveTTLs(role)
suffix, err := uuid.GenerateUUID()
if err != nil {
return nil, fmt.Errorf("generating key alias suffix: %w", err)
}
alias := fmt.Sprintf("%s-%s-%s", role.KeyAliasPrefix, roleName, suffix[:8])
genReq := generateKeyRequest{
Models: role.Models,
KeyAlias: alias,
Metadata: role.Metadata,
}
if role.MaxBudget > 0 {
budget := role.MaxBudget
genReq.MaxBudget = &budget
}
if ttl > 0 {
genReq.Duration = durationToLiteLLM(ttl)
}
key, err := client.GenerateKey(ctx, genReq)
if err != nil {
return nil, fmt.Errorf("generating litellm key: %w", err)
}
internal := map[string]interface{}{
"key": key.Key,
"key_alias": alias,
"role": roleName,
}
external := map[string]interface{}{
"key": key.Key,
"key_alias": alias,
"models": role.Models,
"max_budget": role.MaxBudget,
"role": roleName,
}
resp := b.Secret(litellmKeyType).Response(external, internal)
resp.Secret.TTL = ttl
resp.Secret.MaxTTL = maxTTL
if ttl > 0 {
resp.Secret.Renewable = true
}
return resp, nil
}
// resolveTTLs clamps the role's TTL/MaxTTL against the mount and system limits.
func (b *litellmBackend) resolveTTLs(role *litellmRole) (ttl, maxTTL time.Duration) {
sysMaxTTL := b.System().MaxLeaseTTL()
maxTTL = role.MaxTTL
if maxTTL <= 0 || maxTTL > sysMaxTTL {
maxTTL = sysMaxTTL
}
ttl = role.TTL
if ttl <= 0 {
ttl = b.System().DefaultLeaseTTL()
}
if ttl > maxTTL {
ttl = maxTTL
}
return ttl, maxTTL
}
// durationToLiteLLM converts a Go duration to a LiteLLM duration string. LiteLLM
// accepts an integer with an s/m/h/d suffix; seconds is always representable.
func durationToLiteLLM(d time.Duration) string {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
+174
View File
@@ -0,0 +1,174 @@
package litellm
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
func createRole(t *testing.T, b *litellmBackend, s logical.Storage, name string, data map[string]interface{}) {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/" + name,
Storage: s,
Data: data,
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("create role %s: err=%v resp=%v", name, err, resp)
}
}
func TestCredentials_GenerateAndRevoke(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockLiteLLM(t)
writeTestConfig(t, b, s, m.server.URL, m.masterKey)
createRole(t, b, s, "team-a", map[string]interface{}{
"models": "gpt-4,gpt-3.5-turbo",
"max_budget": 50.0,
"ttl": "1h",
"max_ttl": "24h",
})
// Generate.
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/team-a",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("generate creds: err=%v resp=%v", err, resp)
}
if resp.Secret == nil {
t.Fatal("expected a secret in the response")
}
key, _ := resp.Data["key"].(string)
if key == "" {
t.Fatal("expected a non-empty key in response data")
}
if resp.Secret.TTL != time.Hour {
t.Fatalf("expected TTL 1h, got %s", resp.Secret.TTL)
}
if !resp.Secret.Renewable {
t.Fatal("expected the lease to be renewable")
}
if m.keyCount() != 1 {
t.Fatalf("expected 1 key on server, got %d", m.keyCount())
}
// The scope (models + budget) must have been forwarded to LiteLLM.
if got := m.lastRequest["max_budget"]; got != 50.0 {
t.Fatalf("expected budget forwarded, got %v", got)
}
if models, ok := m.lastRequest["models"].([]interface{}); !ok || len(models) != 2 {
t.Fatalf("expected 2 models forwarded, got %v", m.lastRequest["models"])
}
// Revoke via the secret's revoke callback.
revokeReq := &logical.Request{
Operation: logical.RevokeOperation,
Path: "creds/team-a",
Storage: s,
Secret: resp.Secret,
}
if _, err := b.HandleRequest(ctx, revokeReq); err != nil {
t.Fatalf("revoke: %v", err)
}
if m.keyCount() != 0 {
t.Fatalf("expected key deleted on revoke, got %d keys", m.keyCount())
}
}
func TestCredentials_UnknownRole(t *testing.T) {
b, s := getTestBackend(t)
m := newMockLiteLLM(t)
writeTestConfig(t, b, s, m.server.URL, m.masterKey)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/nope",
Storage: s,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error response for an unknown role")
}
}
func TestCredentials_NotConfigured(t *testing.T) {
b, s := getTestBackend(t)
createRole(t, b, s, "team-a", map[string]interface{}{"ttl": "1h"})
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/team-a",
Storage: s,
})
if err == nil {
t.Fatal("expected an error when backend is not configured")
}
}
func TestCredentials_TTLClampedToMaxTTL(t *testing.T) {
b, s := getTestBackend(t)
m := newMockLiteLLM(t)
writeTestConfig(t, b, s, m.server.URL, m.masterKey)
// TTL exceeds max_ttl in resolveTTLs (role stores equal here; test the
// clamp against the mount max via a very large ttl with small max_ttl is
// rejected at write time, so instead verify default-lease fallback).
createRole(t, b, s, "nolease", map[string]interface{}{"max_ttl": "2h"})
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/nolease",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("generate creds: err=%v resp=%v", err, resp)
}
// With no role TTL, the mount default lease TTL is used, clamped by max_ttl.
if resp.Secret.TTL <= 0 || resp.Secret.TTL > 2*time.Hour {
t.Fatalf("expected TTL within (0, 2h], got %s", resp.Secret.TTL)
}
}
func TestCredentials_Renew(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockLiteLLM(t)
writeTestConfig(t, b, s, m.server.URL, m.masterKey)
createRole(t, b, s, "team-a", map[string]interface{}{"ttl": "1h", "max_ttl": "24h"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/team-a",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("generate creds: err=%v resp=%v", err, resp)
}
renewReq := &logical.Request{
Operation: logical.RenewOperation,
Path: "creds/team-a",
Storage: s,
Secret: resp.Secret,
}
renewResp, err := b.HandleRequest(ctx, renewReq)
if err != nil {
t.Fatalf("renew: %v", err)
}
if renewResp == nil || renewResp.Secret == nil {
t.Fatal("expected a secret in the renew response")
}
if renewResp.Secret.TTL != time.Hour {
t.Fatalf("expected renewed TTL 1h, got %s", renewResp.Secret.TTL)
}
}
+230
View File
@@ -0,0 +1,230 @@
package litellm
import (
"context"
"fmt"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const roleStoragePrefix = "role/"
// litellmRole constrains the virtual keys generated from the creds/<name> path.
type litellmRole struct {
// Models is the list of model names a generated key is allowed to call.
// An empty list means the key is not restricted to specific models.
Models []string `json:"models"`
// MaxBudget is the spending limit (in the proxy's currency units) applied
// to each generated key. Zero means unlimited.
MaxBudget float64 `json:"max_budget"`
// KeyAliasPrefix is prepended to the auto-generated alias for each key.
KeyAliasPrefix string `json:"key_alias_prefix"`
// Metadata is attached to each generated key in LiteLLM.
Metadata map[string]interface{} `json:"metadata"`
// TTL is the default lease duration for keys issued from this role.
TTL time.Duration `json:"ttl"`
// MaxTTL is the maximum lease duration for keys issued from this role.
MaxTTL time.Duration `json:"max_ttl"`
}
func (r *litellmRole) toResponseData() map[string]interface{} {
return map[string]interface{}{
"models": r.Models,
"max_budget": r.MaxBudget,
"key_alias_prefix": r.KeyAliasPrefix,
"metadata": r.Metadata,
"ttl": int64(r.TTL.Seconds()),
"max_ttl": int64(r.MaxTTL.Seconds()),
}
}
func pathRole(b *litellmBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "litellm",
OperationSuffix: "role",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role.",
Required: true,
},
"models": {
Type: framework.TypeCommaStringSlice,
Description: "Comma-separated list of models a generated key may access. Empty means unrestricted.",
},
"max_budget": {
Type: framework.TypeFloat,
Description: "Spending limit applied to each generated key. 0 means unlimited.",
},
"key_alias_prefix": {
Type: framework.TypeString,
Description: "Prefix for the auto-generated key alias.",
Default: "vault",
},
"metadata": {
Type: framework.TypeKVPairs,
Description: "Arbitrary key=value metadata attached to each generated key.",
},
"ttl": {
Type: framework.TypeDurationSecond,
Description: "Default lease TTL for keys generated from this role.",
},
"max_ttl": {
Type: framework.TypeDurationSecond,
Description: "Maximum lease TTL for keys generated from this role.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathRoleRead,
},
logical.CreateOperation: &framework.PathOperation{
Callback: b.pathRoleWrite,
},
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathRoleWrite,
},
logical.DeleteOperation: &framework.PathOperation{
Callback: b.pathRoleDelete,
},
},
ExistenceCheck: b.pathRoleExistenceCheck,
HelpSynopsis: "Manage roles that constrain generated LiteLLM keys.",
HelpDescription: "Roles define the allowed models, spending limit, and TTLs applied to virtual keys issued from creds/<name>.",
}
}
func pathRolesList(b *litellmBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "litellm",
OperationSuffix: "roles",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{
Callback: b.pathRolesList,
},
},
HelpSynopsis: "List the configured roles.",
HelpDescription: "List the roles configured on this LiteLLM backend.",
}
}
func (b *litellmBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return false, err
}
return role != nil, nil
}
func (b *litellmBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
entries, err := req.Storage.List(ctx, roleStoragePrefix)
if err != nil {
return nil, err
}
return logical.ListResponse(entries), nil
}
func (b *litellmBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return nil, err
}
if role == nil {
return nil, nil
}
return &logical.Response{Data: role.toResponseData()}, nil
}
func (b *litellmBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
if name == "" {
return logical.ErrorResponse("role name is required"), nil
}
role, err := b.getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if role == nil {
role = &litellmRole{KeyAliasPrefix: "vault"}
}
if v, ok := data.GetOk("models"); ok {
role.Models = v.([]string)
}
if v, ok := data.GetOk("max_budget"); ok {
role.MaxBudget = v.(float64)
}
if v, ok := data.GetOk("key_alias_prefix"); ok {
role.KeyAliasPrefix = v.(string)
}
if v, ok := data.GetOk("metadata"); ok {
md := make(map[string]interface{})
for k, val := range v.(map[string]string) {
md[k] = val
}
role.Metadata = md
}
if v, ok := data.GetOk("ttl"); ok {
role.TTL = time.Duration(v.(int)) * time.Second
}
if v, ok := data.GetOk("max_ttl"); ok {
role.MaxTTL = time.Duration(v.(int)) * time.Second
}
if role.MaxBudget < 0 {
return logical.ErrorResponse("max_budget must not be negative"), nil
}
if role.MaxTTL != 0 && role.TTL > role.MaxTTL {
return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil
}
if err := setRole(ctx, req.Storage, name, role); err != nil {
return nil, err
}
return nil, nil
}
func (b *litellmBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil {
return nil, fmt.Errorf("error deleting litellm role: %w", err)
}
return nil, nil
}
func (b *litellmBackend) getRole(ctx context.Context, s logical.Storage, name string) (*litellmRole, error) {
if name == "" {
return nil, fmt.Errorf("missing role name")
}
entry, err := s.Get(ctx, roleStoragePrefix+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
role := &litellmRole{}
if err := entry.DecodeJSON(role); err != nil {
return nil, err
}
return role, nil
}
func setRole(ctx context.Context, s logical.Storage, name string, role *litellmRole) error {
entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role)
if err != nil {
return err
}
if entry == nil {
return fmt.Errorf("failed to create storage entry for role %q", name)
}
return s.Put(ctx, entry)
}
+122
View File
@@ -0,0 +1,122 @@
package litellm
import (
"context"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func TestRole_WriteReadListDelete(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/team-a",
Storage: s,
Data: map[string]interface{}{
"models": "gpt-4,gpt-3.5-turbo",
"max_budget": 50.5,
"key_alias_prefix": "team-a",
"ttl": "1h",
"max_ttl": "24h",
"metadata": "env=prod,team=a",
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "roles/team-a",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read role: err=%v resp=%v", err, resp)
}
models := resp.Data["models"].([]string)
if len(models) != 2 || models[0] != "gpt-4" {
t.Fatalf("unexpected models: %v", models)
}
if resp.Data["max_budget"].(float64) != 50.5 {
t.Fatalf("unexpected budget: %v", resp.Data["max_budget"])
}
if resp.Data["ttl"].(int64) != 3600 {
t.Fatalf("unexpected ttl: %v", resp.Data["ttl"])
}
if resp.Data["max_ttl"].(int64) != 86400 {
t.Fatalf("unexpected max_ttl: %v", resp.Data["max_ttl"])
}
// List.
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ListOperation,
Path: "roles/",
Storage: s,
})
if err != nil {
t.Fatalf("list roles: %v", err)
}
keys := resp.Data["keys"].([]string)
if len(keys) != 1 || keys[0] != "team-a" {
t.Fatalf("unexpected role list: %v", keys)
}
// Delete.
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.DeleteOperation,
Path: "roles/team-a",
Storage: s,
}); err != nil {
t.Fatalf("delete role: %v", err)
}
role, _ := b.getRole(ctx, s, "team-a")
if role != nil {
t.Fatal("expected role to be gone after delete")
}
}
func TestRole_TTLGreaterThanMaxTTLRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{
"ttl": "48h",
"max_ttl": "1h",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when ttl > max_ttl")
}
}
func TestRole_UnrestrictedModels(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/open",
Storage: s,
Data: map[string]interface{}{"max_budget": 10},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
role, err := b.getRole(ctx, s, "open")
if err != nil {
t.Fatalf("getRole: %v", err)
}
if len(role.Models) != 0 {
t.Fatalf("expected no model restriction, got %v", role.Models)
}
}
Executable
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# End-to-end test for vault-plugin-secrets-litellm.
#
# Builds the plugin, brings up LiteLLM (+Postgres) plus both Vault and OpenBao,
# then drives the identical lifecycle against each engine to prove the same
# binary works on both:
# configure -> create role -> generate key -> use key (scoped) -> revoke key.
#
# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml"
COMPOSE="docker compose -f ${COMPOSE_FILE}"
BINARY="vault-plugin-secrets-litellm"
MASTER_KEY="sk-master-e2e-1234"
LITELLM_ADDR="http://127.0.0.1:4000"
MOUNT="litellm"
ENGINES="${ENGINES:-vault openbao}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
cleanup() {
blue "Tearing down containers"
${COMPOSE} down -v >/dev/null 2>&1 || true
}
trap cleanup EXIT
fail() { red "FAIL: $*"; exit 1; }
wait_for() {
local desc="$1"; shift
local retries="${WAIT_RETRIES:-90}"
local i=0
until "$@" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -ge "$retries" ]; then
fail "timed out waiting for ${desc}"
fi
sleep 2
done
green "ready: ${desc}"
}
# chat_completion KEY MODEL -> prints the HTTP status code of a mock completion.
chat_completion() {
local key="$1" model="$2"
curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${key}" -H 'Content-Type: application/json' \
-d "{\"model\":\"${model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \
"${LITELLM_ADDR}/chat/completions"
}
# run_engine ENGINE_NAME CONTAINER CLI
run_engine() {
local engine="$1" container="$2" cli="$3"
blue "[${engine}] exercising the plugin"
# ex runs the engine CLI inside its container (env already holds ADDR+TOKEN).
ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; }
local sha
sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')"
ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true
ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true
ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null
green "[${engine}] plugin registered and mounted"
# The plugin runs inside the engine container, so it reaches litellm by name.
ex write "${MOUNT}/config" base_url="http://litellm:4000" master_key="${MASTER_KEY}" >/dev/null
ex write "${MOUNT}/roles/team-a" \
models="gpt-3.5-turbo" max_budget=10 ttl=1h max_ttl=24h >/dev/null
green "[${engine}] configured + role created (models=gpt-3.5-turbo, budget=\$10)"
# Generate a key, capturing both the key and its lease id.
local json key lease
json="$(ex read -format=json "${MOUNT}/creds/team-a")"
key="$(printf '%s' "${json}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["key"])')"
lease="$(printf '%s' "${json}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["lease_id"])')"
[ -n "${key}" ] || fail "[${engine}] no key returned"
green "[${engine}] issued key ${key:0:12}... (lease ${lease})"
# Present in litellm?
curl -fsS -H "Authorization: Bearer ${MASTER_KEY}" \
"${LITELLM_ADDR}/key/info?key=${key}" >/dev/null \
|| fail "[${engine}] generated key not found in litellm"
# Allowed model -> success.
local code
code="$(chat_completion "${key}" gpt-3.5-turbo)"
[ "${code}" = "200" ] || fail "[${engine}] allowed model returned HTTP ${code}, expected 200"
green "[${engine}] allowed model (gpt-3.5-turbo) accepted"
# Disallowed model -> rejected.
code="$(chat_completion "${key}" gpt-4)"
[ "${code}" != "200" ] || fail "[${engine}] disallowed model unexpectedly succeeded"
green "[${engine}] disallowed model (gpt-4) rejected (HTTP ${code})"
# Revoke the lease -> key deleted from litellm.
ex lease revoke "${lease}" >/dev/null
sleep 2
code="$(chat_completion "${key}" gpt-3.5-turbo)"
[ "${code}" != "200" ] || fail "[${engine}] revoked key still works (HTTP ${code})"
green "[${engine}] revoked key rejected (HTTP ${code})"
green "[${engine}] PASSED"
}
# ---------------------------------------------------------------------------
blue "Building plugin for linux/amd64"
OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build
blue "Starting Docker stack (postgres + litellm + vault + openbao)"
${COMPOSE} up -d --build
wait_for "litellm" curl -fsS "${LITELLM_ADDR}/health/liveliness"
for engine in ${ENGINES}; do
case "${engine}" in
vault)
wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200
run_engine vault vault vault
;;
openbao)
wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200
run_engine openbao openbao bao
;;
*)
fail "unknown engine: ${engine}"
;;
esac
done
green "ALL END-TO-END CHECKS PASSED (${ENGINES})"
+101
View File
@@ -0,0 +1,101 @@
package litellm
import (
"context"
"errors"
"fmt"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// litellmKeyType is the identifier for the dynamic secret produced by this
// backend.
const litellmKeyType = "litellm_key"
func (b *litellmBackend) litellmKey() *framework.Secret {
return &framework.Secret{
Type: litellmKeyType,
Fields: map[string]*framework.FieldSchema{
"key": {
Type: framework.TypeString,
Description: "The LiteLLM virtual key.",
},
"key_alias": {
Type: framework.TypeString,
Description: "The alias assigned to the virtual key.",
},
},
Revoke: b.keyRevoke,
Renew: b.keyRenew,
}
}
// keyRevoke deletes the virtual key from the LiteLLM proxy when the lease is
// revoked.
func (b *litellmBackend) keyRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
rawKey, ok := req.Secret.InternalData["key"]
if !ok {
return nil, errors.New("secret is missing internal key data")
}
key, ok := rawKey.(string)
if !ok {
return nil, errors.New("secret internal key data is not a string")
}
client, err := b.getClient(ctx, req.Storage)
if err != nil {
return nil, err
}
if err := client.DeleteKey(ctx, key); err != nil {
return nil, fmt.Errorf("revoking litellm key: %w", err)
}
return nil, nil
}
// keyRenew extends the lease and, when a TTL is set, pushes the new expiry to
// LiteLLM so the virtual key and the Vault lease stay in sync.
func (b *litellmBackend) keyRenew(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
rawRole, ok := req.Secret.InternalData["role"]
if !ok {
return nil, errors.New("secret is missing internal role data")
}
roleName, ok := rawRole.(string)
if !ok {
return nil, errors.New("secret internal role data is not a string")
}
role, err := b.getRole(ctx, req.Storage, roleName)
if err != nil {
return nil, err
}
if role == nil {
return nil, fmt.Errorf("role %q no longer exists; cannot renew", roleName)
}
resp := &logical.Response{Secret: req.Secret}
if role.TTL > 0 {
resp.Secret.TTL = role.TTL
}
if role.MaxTTL > 0 {
resp.Secret.MaxTTL = role.MaxTTL
}
// Best-effort: extend the key's own expiry in LiteLLM to match the renewed
// lease. A failure here should not fail the renew, since the authoritative
// lifetime is the Vault lease.
if role.TTL > 0 {
if rawKey, ok := req.Secret.InternalData["key"].(string); ok {
client, err := b.getClient(ctx, req.Storage)
if err == nil {
_ = client.UpdateKey(ctx, updateKeyRequest{
Key: rawKey,
Duration: durationToLiteLLM(role.TTL),
})
}
}
}
return resp, nil
}
+81
View File
@@ -0,0 +1,81 @@
# End-to-end test stack. LiteLLM (backed by Postgres) plus two secrets-engine
# hosts running the exact same plugin binary: HashiCorp Vault and OpenBao. Bind
# mounts use the ":z" flag so they work under SELinux (Fedora/RHEL).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: litellm
POSTGRES_PASSWORD: litellm
POSTGRES_DB: litellm
healthcheck:
test: ["CMD-SHELL", "pg_isready -U litellm"]
interval: 3s
timeout: 3s
retries: 20
litellm:
image: ghcr.io/berriai/litellm:main-stable
depends_on:
postgres:
condition: service_healthy
environment:
LITELLM_MASTER_KEY: sk-master-e2e-1234
DATABASE_URL: postgresql://litellm:litellm@postgres:5432/litellm
STORE_MODEL_IN_DB: "True"
command: ["--config", "/app/config.yaml", "--port", "4000"]
volumes:
- ./litellm/config.yaml:/app/config.yaml:ro,z
ports:
- "4000:4000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:4000/health/liveliness').status==200 else 1)"]
interval: 5s
timeout: 5s
retries: 40
vault:
image: hashicorp/vault:1.18
depends_on:
litellm:
condition: service_healthy
cap_add:
- IPC_LOCK
environment:
VAULT_DEV_ROOT_TOKEN_ID: root
VAULT_ADDR: http://127.0.0.1:8200
VAULT_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"]
volumes:
- ../dist:/vault/plugins:ro,z
- ./vault/vault.hcl:/vault/vault.hcl:ro,z
ports:
- "8200:8200"
healthcheck:
test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
openbao:
image: openbao/openbao:latest
depends_on:
litellm:
condition: service_healthy
cap_add:
- IPC_LOCK
environment:
BAO_DEV_ROOT_TOKEN_ID: root
BAO_ADDR: http://127.0.0.1:8200
BAO_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"]
volumes:
- ../dist:/openbao/plugins:ro,z
- ./openbao/bao.hcl:/openbao/bao.hcl:ro,z
ports:
- "8300:8200"
healthcheck:
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
+22
View File
@@ -0,0 +1,22 @@
model_list:
- model_name: gpt-3.5-turbo
litellm_params:
model: openai/gpt-3.5-turbo
api_key: sk-fake-openai-key
mock_response: "hello from the mock model"
- model_name: gpt-4
litellm_params:
model: openai/gpt-4
api_key: sk-fake-openai-key
mock_response: "hello from the mock model"
- model_name: claude-3-sonnet
litellm_params:
model: anthropic/claude-3-sonnet
api_key: sk-fake-anthropic-key
mock_response: "hello from the mock model"
general_settings:
master_key: sk-master-e2e-1234
litellm_settings:
drop_params: true
+4
View File
@@ -0,0 +1,4 @@
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
plugin_directory = "/openbao/plugins"
api_addr = "http://127.0.0.1:8200"
+4
View File
@@ -0,0 +1,4 @@
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
plugin_directory = "/vault/plugins"
api_addr = "http://127.0.0.1:8200"