From 1b8dc67d1c8edf0fadd2906feeffe3a93fd16804 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:25:01 +0000 Subject: [PATCH 1/3] Initial plan From 9b8c9b9ba61d6a33057dc12af59b90e162f0e9f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:32:19 +0000 Subject: [PATCH 2/3] Implement encoding-format plugin with CI/CD workflow and tests Agent-Logs-Url: https://github.com/lukoerfer/bifrost-encoding-format-plugin/sessions/cccdd85f-d219-4c93-a069-b6f9bccf619f Co-authored-by: lukoerfer <10530659+lukoerfer@users.noreply.github.com> --- .github/workflows/build.yml | 58 +++++++++ Makefile | 2 +- README.md | 87 ++++++++++++- go.mod | 10 +- main.go | 116 ++++++++++------- main_test.go | 249 ++++++++++++++++++++++++++++++++++++ 6 files changed, 472 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 main_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d71ae5a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,58 @@ +name: Build and Release + +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./... + + - name: Build plugin (Linux AMD64) + run: | + mkdir -p build + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -buildmode=plugin -ldflags='-w -s' -trimpath \ + -o build/encoding-format.so main.go + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: encoding-format-linux-amd64 + path: build/encoding-format.so + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: encoding-format-linux-amd64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: encoding-format.so + generate_release_notes: true diff --git a/Makefile b/Makefile index cd6bc6a..8243663 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ COLOR_ERROR = \033[31m COLOR_BOLD = \033[1m # Plugin name -PLUGIN_NAME = hello-world +PLUGIN_NAME = encoding-format OUTPUT_DIR = build # Platform detection diff --git a/README.md b/README.md index cb264f4..5d05403 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ # bifrost-encoding-format-plugin -Plugin for bifrost to inject missing encoding_format parameters into embedding requests + +A [Bifrost](https://github.com/maximhq/bifrost) plugin that injects missing `encoding_format` parameters into embedding API requests. + +## Problem + +Some downstream LLM providers (e.g. older versions of LiteLLM) require the `encoding_format` parameter in embedding requests, but many upstream platforms like Open WebUI don't include it. This mismatch results in errors. This plugin bridges that gap by automatically adding the parameter when it's missing. + +## How It Works + +The plugin intercepts HTTP requests at the transport level before they reach the downstream provider. For any request targeting an embedding endpoint (path containing `embedding`): + +1. If `encoding_format` is **missing** from the request body → injects the configured value +2. If `encoding_format` is **null** → replaces it with the configured value +3. If `encoding_format` is **already set** to a non-null value → leaves the request unchanged +4. Non-embedding requests are passed through without modification + +## Configuration + +Add the plugin to your Bifrost configuration: + +```yaml +plugins: + - name: encoding-format + enabled: true + path: /path/to/encoding-format.so + config: + encoding_format: "float" # or "base64" +``` + +### Supported Values + +| Value | Description | +|----------|------------------------------------| +| `float` | Return embeddings as float arrays (default) | +| `base64` | Return embeddings as base64-encoded strings | + +If no configuration is provided, the plugin defaults to `float`. + +## Building + +### Prerequisites + +- Go 1.26.1+ +- CGO enabled (required for Go plugins) + +### Build locally + +```bash +make build +``` + +### Build for Linux AMD64 (cross-compilation via Docker) + +```bash +make build GOOS=linux GOARCH=amd64 +``` + +### Install to Bifrost plugins directory + +```bash +make install +``` + +## GitHub Actions + +The repository includes a GitHub Actions workflow that: + +- **On push/PR to main**: Builds and tests the plugin on Linux AMD64 +- **On tag push (`v*`)**: Builds the plugin and publishes it as a GitHub Release asset + +## Development + +```bash +# Download dependencies +make deps + +# Build for development (no optimizations) +make dev + +# Run tests +make test +``` + +## License + +[MIT](LICENSE) diff --git a/go.mod b/go.mod index 7f52fcc..c8b7e70 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ -module github.com/maximhq/bifrost/examples/plugins/hello-world +module github.com/lukoerfer/bifrost-encoding-format-plugin go 1.26.1 -require github.com/maximhq/bifrost/core v1.4.14 +require ( + github.com/maximhq/bifrost/core v1.4.14 + github.com/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 +) require ( github.com/andybalholm/brotli v1.2.0 // indirect @@ -20,10 +24,8 @@ require ( github.com/mailru/easyjson v0.9.1 // indirect github.com/mark3labs/mcp-go v0.43.2 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.68.0 // indirect diff --git a/main.go b/main.go index 4d6de86..0ec8468 100644 --- a/main.go +++ b/main.go @@ -1,71 +1,99 @@ package main import ( + "encoding/json" "fmt" + "strings" "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) +// supportedFormats lists the valid encoding_format values. +var supportedFormats = map[string]bool{ + "float": true, + "base64": true, +} + +// pluginConfig holds the parsed plugin configuration. +type pluginConfig struct { + EncodingFormat string `json:"encoding_format"` +} + +// activeConfig stores the plugin configuration after initialization. +var activeConfig pluginConfig + +// Init parses the plugin configuration and validates the encoding_format value. +// The config is expected to be a JSON object with an "encoding_format" field. +// If no config is provided, the default encoding_format "float" is used. func Init(config any) error { - fmt.Println("Init called") + activeConfig.EncodingFormat = "float" + + if config == nil { + fmt.Printf("[encoding-format] No config provided, using default encoding_format: %s\n", activeConfig.EncodingFormat) + return nil + } + + cfgBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("[encoding-format] failed to marshal config: %w", err) + } + + if err := json.Unmarshal(cfgBytes, &activeConfig); err != nil { + return fmt.Errorf("[encoding-format] failed to parse config: %w", err) + } + + if activeConfig.EncodingFormat == "" { + activeConfig.EncodingFormat = "float" + } + + if !supportedFormats[activeConfig.EncodingFormat] { + return fmt.Errorf("[encoding-format] unsupported encoding_format: %s (supported: float, base64)", activeConfig.EncodingFormat) + } + + fmt.Printf("[encoding-format] Initialized with encoding_format: %s\n", activeConfig.EncodingFormat) return nil } -// GetName returns the name of the plugin (required) -// This is the system identifier - not editable by users -// Users can set a custom display_name in the config for the UI +// GetName returns the system identifier for this plugin (required). func GetName() string { - return "hello-world" + return "encoding-format" } +// HTTPTransportPreHook intercepts HTTP requests before they are sent to the +// downstream provider. For embedding requests that are missing the +// encoding_format parameter (or have it set to null), it injects the +// configured encoding_format value into the request body. func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { - fmt.Println("HTTPTransportPreHook called") - // Modify request in-place - req.Headers["x-hello-world-plugin"] = "transport-pre-hook-value" - // Store value in context for PreLLMHook/PostLLMHook - ctx.SetValue(schemas.BifrostContextKey("hello-world-plugin-transport-pre-hook"), "transport-pre-hook-value") - // Return nil to continue processing, or return &schemas.HTTPResponse{} to short-circuit - return nil, nil -} + if !isEmbeddingRequest(req) { + return nil, nil + } -func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { - fmt.Println("HTTPTransportPostHook called") - // Modify response in-place - resp.Headers["x-hello-world-plugin"] = "transport-post-hook-value" - // Store value in context - ctx.SetValue(schemas.BifrostContextKey("hello-world-plugin-transport-post-hook"), "transport-post-hook-value") - // Return nil to continue processing - return nil -} + if len(req.Body) == 0 { + return nil, nil + } -func HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, chunk *schemas.BifrostStreamChunk) (*schemas.BifrostStreamChunk, error) { - fmt.Println("HTTPTransportStreamChunkHook called") - // Modify chunk in-place - if chunk.BifrostChatResponse != nil && chunk.BifrostChatResponse.Choices != nil && len(chunk.BifrostChatResponse.Choices) > 0 && chunk.BifrostChatResponse.Choices[0].ChatStreamResponseChoice != nil && chunk.BifrostChatResponse.Choices[0].ChatStreamResponseChoice.Delta != nil && chunk.BifrostChatResponse.Choices[0].ChatStreamResponseChoice.Delta.Content != nil { - *chunk.BifrostChatResponse.Choices[0].ChatStreamResponseChoice.Delta.Content += " - modified by hello-world-plugin" + result := gjson.GetBytes(req.Body, "encoding_format") + if result.Exists() && result.Type != gjson.Null { + return nil, nil } - // Return the modified chunk - return chunk, nil -} -func PreLLMHook(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) { - value1 := ctx.Value(schemas.BifrostContextKey("hello-world-plugin-transport-pre-hook")) - fmt.Println("value1:", value1) - ctx.SetValue(schemas.BifrostContextKey("hello-world-plugin-pre-hook"), "pre-hook-value") - fmt.Println("PreLLMHook called") - return req, nil, nil + newBody, err := sjson.SetBytes(req.Body, "encoding_format", activeConfig.EncodingFormat) + if err != nil { + return nil, fmt.Errorf("[encoding-format] failed to set encoding_format in request body: %w", err) + } + + req.Body = newBody + return nil, nil } -func PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { - fmt.Println("PostLLMHook called") - value1 := ctx.Value(schemas.BifrostContextKey("hello-world-plugin-transport-pre-hook")) - fmt.Println("value1:", value1) - value2 := ctx.Value(schemas.BifrostContextKey("hello-world-plugin-pre-hook")) - fmt.Println("value2:", value2) - return resp, bifrostErr, nil +// isEmbeddingRequest checks whether the HTTP request targets an embedding endpoint. +func isEmbeddingRequest(req *schemas.HTTPRequest) bool { + return strings.Contains(strings.ToLower(req.Path), "embedding") } +// Cleanup performs any necessary teardown when the plugin is unloaded. func Cleanup() error { - fmt.Println("Cleanup called") return nil } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..99aca84 --- /dev/null +++ b/main_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" +) + +func newTestContext() *schemas.BifrostContext { + return schemas.NewBifrostContext(context.Background(), time.Now().Add(time.Minute)) +} + +func TestGetName(t *testing.T) { + name := GetName() + if name != "encoding-format" { + t.Errorf("expected plugin name 'encoding-format', got '%s'", name) + } +} + +func TestInitDefault(t *testing.T) { + activeConfig = pluginConfig{} + if err := Init(nil); err != nil { + t.Fatalf("Init(nil) returned error: %v", err) + } + if activeConfig.EncodingFormat != "float" { + t.Errorf("expected default encoding_format 'float', got '%s'", activeConfig.EncodingFormat) + } +} + +func TestInitWithFloatConfig(t *testing.T) { + activeConfig = pluginConfig{} + cfg := map[string]interface{}{"encoding_format": "float"} + if err := Init(cfg); err != nil { + t.Fatalf("Init with float config returned error: %v", err) + } + if activeConfig.EncodingFormat != "float" { + t.Errorf("expected encoding_format 'float', got '%s'", activeConfig.EncodingFormat) + } +} + +func TestInitWithBase64Config(t *testing.T) { + activeConfig = pluginConfig{} + cfg := map[string]interface{}{"encoding_format": "base64"} + if err := Init(cfg); err != nil { + t.Fatalf("Init with base64 config returned error: %v", err) + } + if activeConfig.EncodingFormat != "base64" { + t.Errorf("expected encoding_format 'base64', got '%s'", activeConfig.EncodingFormat) + } +} + +func TestInitWithUnsupportedFormat(t *testing.T) { + activeConfig = pluginConfig{} + cfg := map[string]interface{}{"encoding_format": "unsupported"} + err := Init(cfg) + if err == nil { + t.Fatal("expected error for unsupported encoding_format, got nil") + } +} + +func TestInitWithEmptyConfig(t *testing.T) { + activeConfig = pluginConfig{} + cfg := map[string]interface{}{} + if err := Init(cfg); err != nil { + t.Fatalf("Init with empty config returned error: %v", err) + } + if activeConfig.EncodingFormat != "float" { + t.Errorf("expected default encoding_format 'float', got '%s'", activeConfig.EncodingFormat) + } +} + +func TestIsEmbeddingRequest(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + {"standard embeddings path", "/v1/embeddings", true}, + {"embedding singular", "/v1/embedding", true}, + {"uppercase path", "/V1/EMBEDDINGS", true}, + {"mixed case", "/v1/Embeddings", true}, + {"nested path", "/api/v1/embeddings/create", true}, + {"chat path", "/v1/chat/completions", false}, + {"completions path", "/v1/completions", false}, + {"empty path", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &schemas.HTTPRequest{Path: tt.path} + result := isEmbeddingRequest(req) + if result != tt.expected { + t.Errorf("isEmbeddingRequest(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestHTTPTransportPreHookInjectsEncodingFormat(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "float"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/embeddings", + Body: []byte(`{"model":"text-embedding-ada-002","input":"hello"}`), + } + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response (continue processing)") + } + + result := gjson.GetBytes(req.Body, "encoding_format") + if !result.Exists() { + t.Fatal("encoding_format was not injected into request body") + } + if result.String() != "float" { + t.Errorf("expected encoding_format 'float', got '%s'", result.String()) + } +} + +func TestHTTPTransportPreHookInjectsBase64(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "base64"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/embeddings", + Body: []byte(`{"model":"text-embedding-ada-002","input":"hello"}`), + } + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response") + } + + result := gjson.GetBytes(req.Body, "encoding_format") + if result.String() != "base64" { + t.Errorf("expected encoding_format 'base64', got '%s'", result.String()) + } +} + +func TestHTTPTransportPreHookSkipsExistingFormat(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "float"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/embeddings", + Body: []byte(`{"model":"text-embedding-ada-002","input":"hello","encoding_format":"base64"}`), + } + + originalBody := string(req.Body) + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response") + } + + if string(req.Body) != originalBody { + t.Error("request body was modified when encoding_format was already set") + } + + result := gjson.GetBytes(req.Body, "encoding_format") + if result.String() != "base64" { + t.Errorf("expected existing encoding_format 'base64' to be preserved, got '%s'", result.String()) + } +} + +func TestHTTPTransportPreHookInjectsWhenNull(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "float"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/embeddings", + Body: []byte(`{"model":"text-embedding-ada-002","input":"hello","encoding_format":null}`), + } + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response") + } + + result := gjson.GetBytes(req.Body, "encoding_format") + if result.String() != "float" { + t.Errorf("expected encoding_format 'float' after null replacement, got '%s'", result.String()) + } +} + +func TestHTTPTransportPreHookSkipsNonEmbeddingRequests(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "float"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/chat/completions", + Body: []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}`), + } + + originalBody := string(req.Body) + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response") + } + + if string(req.Body) != originalBody { + t.Error("non-embedding request body was modified") + } +} + +func TestHTTPTransportPreHookSkipsEmptyBody(t *testing.T) { + activeConfig = pluginConfig{EncodingFormat: "float"} + ctx := newTestContext() + + req := &schemas.HTTPRequest{ + Path: "/v1/embeddings", + Body: []byte{}, + } + + resp, err := HTTPTransportPreHook(ctx, req) + if err != nil { + t.Fatalf("HTTPTransportPreHook returned error: %v", err) + } + if resp != nil { + t.Fatal("expected nil response") + } +} + +func TestCleanup(t *testing.T) { + if err := Cleanup(); err != nil { + t.Fatalf("Cleanup returned error: %v", err) + } +} From 18c18393b1a746eaffcb531e7c68a70a6c2fd8e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:33:52 +0000 Subject: [PATCH 3/3] Add explicit workflow permissions to fix CodeQL alert Agent-Logs-Url: https://github.com/lukoerfer/bifrost-encoding-format-plugin/sessions/cccdd85f-d219-4c93-a069-b6f9bccf619f Co-authored-by: lukoerfer <10530659+lukoerfer@users.noreply.github.com> --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d71ae5a..b62a573 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest