| --- |
| title: "Plugin Migration Guide" |
| description: "How to migrate your Bifrost plugins from v1.3.x to v1.4.x" |
| icon: "arrow-up-right-dots" |
| --- |
|
|
| ## Overview |
|
|
| Bifrost v1.4.x introduces a new plugin interface for HTTP transport layer interception. This guide helps you migrate existing plugins from the v1.3.x `TransportInterceptor` pattern to the v1.4.x `HTTPTransportPreHook` and `HTTPTransportPostHook` pattern. |
|
|
| <Note> |
| If your plugin doesn't use `TransportInterceptor`, no migration is needed. The `PreLLMHook`, `PostLLMHook`, `Init`, `GetName`, and `Cleanup` functions remain unchanged. |
| </Note> |
|
|
| ## What Changed? |
|
|
| The HTTP transport interception mechanism changed from a simple function that receives and returns headers/body to a dual-hook pattern that works with both native `.so` plugins and WASM plugins. |
|
|
| ### Key Differences |
|
|
| | Aspect | v1.3.x (TransportInterceptor) | v1.4.x+ (Pre/Post Hooks) | |
| |--------|-------------------------------|--------------------------| |
| | Signature | `TransportInterceptor(ctx, url, headers, body)` | `HTTPTransportPreHook(ctx, req)` + `HTTPTransportPostHook(ctx, req, resp)` | |
| | Return type | `(headers, body, error)` | Pre: `(*HTTPResponse, error)`, Post: `error` | |
| | Request type | Separate `headers map`, `body map` | Unified `*HTTPRequest` struct | |
| | Response access | Not available | Post-hook receives `*HTTPResponse` | |
| | Modification | Return modified maps | Modify `req`/`resp` in-place | |
| | Short-circuit | Return error | Return `*HTTPResponse` | |
| | WASM support | No | Yes | |
| | Context | Limited `BifrostContext` | Full `*BifrostContext` with `SetValue`/`Value` | |
|
|
| ### Why the Change? |
|
|
| The new dual-hook pattern provides: |
|
|
| 1. **WASM plugin support** - Serializable types work across WASM boundary |
| 2. **Response interception** - Post-hook can modify responses before returning to client |
| 3. **Simpler API** - No middleware wrapper, direct function call |
| 4. **Better testability** - No fasthttp dependency in plugin tests |
| 5. **Full context access** - BifrostContext available for sharing data between hooks |
| 6. **Custom response short-circuits** - Return a full response to short-circuit |
|
|
| ## Migration Steps |
|
|
| ### Step 1: Update Imports |
|
|
| Remove the `fasthttp` import if present: |
|
|
| ```go |
| import ( |
| "fmt" |
|
|
| "github.com/maximhq/bifrost/core/schemas" |
| // Remove: "github.com/valyala/fasthttp" |
| ) |
| ``` |
|
|
| ### Step 2: Replace the Function |
|
|
| **Before (v1.3.x):** |
|
|
| ```go |
| // TransportInterceptor modifies raw HTTP headers and body |
| func TransportInterceptor(ctx *schemas.BifrostContext, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { |
| // Add custom header |
| headers["X-Custom-Header"] = "value" |
| |
| // Modify body |
| body["custom_field"] = "custom_value" |
| |
| return headers, body, nil |
| } |
| ``` |
|
|
| **After (v1.4.x+):** |
|
|
| ```go |
| // HTTPTransportPreHook intercepts requests BEFORE they enter Bifrost core |
| // Modify req in-place. Return (*HTTPResponse, nil) to short-circuit. |
| func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { |
| // Add custom header (in-place modification) |
| req.Headers["x-custom-header"] = "value" |
| |
| // Modify body (in-place modification) |
| var body map[string]any |
| sonic.Unmarshal(req.Body, &body) |
| body["custom_field"] = "custom_value" |
| req.Body, _ = sonic.Marshal(body) |
| |
| // Store values in context for use in post-hook |
| ctx.SetValue(schemas.BifrostContextKey("my-plugin-key"), "my-value") |
| |
| // Return nil to continue, or return &HTTPResponse{} to short-circuit |
| return nil, nil |
| } |
|
|
| // HTTPTransportPostHook intercepts responses AFTER they exit Bifrost core |
| // Modify resp in-place. Called in reverse order of pre-hooks. |
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Add response header |
| resp.Headers["x-processed-by"] = "my-plugin" |
| |
| // Read values set in pre-hook |
| if val := ctx.Value(schemas.BifrostContextKey("my-plugin-key")) |
| fmt.Println("Context value:", val) |
| } |
| |
| // Return nil to continue, or return error to short-circuit |
| return nil |
| } |
| ``` |
|
|
| ### Step 3: Update Body Modification Logic |
|
|
| In v1.3.x, you received the body as a `map[string]any`. In v1.4.x, you work with `req.Body` bytes: |
|
|
| **Before (v1.3.x):** |
|
|
| ```go |
| func TransportInterceptor(ctx *schemas.BifrostContext, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { |
| // Direct map access |
| body["model"] = "gpt-4" |
| return headers, body, nil |
| } |
| ``` |
|
|
| **After (v1.4.x+):** |
|
|
| ```go |
| import "github.com/bytedance/sonic" |
|
|
| func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { |
| // Parse body |
| var body map[string]any |
| if err := sonic.Unmarshal(req.Body, &body) |
| // Modify body |
| body["model"] = "gpt-4" |
| // Update req.Body in-place |
| req.Body, _ = sonic.Marshal(body) |
| } |
| return nil, nil |
| } |
|
|
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Modify response body if needed |
| var respBody map[string]any |
| if err := sonic.Unmarshal(resp.Body, &respBody) |
| respBody["plugin_processed"] = true |
| resp.Body, _ = sonic.Marshal(respBody) |
| } |
| return nil |
| } |
| ``` |
|
|
| ## Common Migration Patterns |
|
|
| ### Adding Headers |
|
|
| **v1.3.x:** |
| ```go |
| headers["authorization"] = "Bearer " + token |
| return headers, body, nil |
| ``` |
|
|
| **v1.4.x+:** |
| ```go |
| // In HTTPTransportPreHook - modify request headers |
| req.Headers["authorization"] = "Bearer " + token |
| return nil, nil |
|
|
| // In HTTPTransportPostHook - modify response headers |
| resp.Headers["x-request-id"] = requestID |
| return nil |
| ``` |
|
|
| ### Reading Headers |
|
|
| **v1.3.x:** |
| ```go |
| apiKey := headers["X-API-Key"] |
| ``` |
|
|
| **v1.4.x+:** |
| ```go |
| // Use case-insensitive helper for reading (recommended) |
| apiKey := req.CaseInsensitiveHeaderLookup("X-API-Key") |
|
|
| // Or direct map access (case-sensitive) |
| apiKey := req.Headers["x-api-key"] |
| ``` |
|
|
| ### Conditional Processing |
|
|
| **v1.3.x:** |
| ```go |
| func TransportInterceptor(ctx *schemas.BifrostContext, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { |
| if headers["x-skip-processing"] == "true" { |
| return headers, body, nil |
| } |
| // Process... |
| return headers, body, nil |
| } |
| ``` |
|
|
| **v1.4.x+:** |
| ```go |
| func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { |
| if req.CaseInsensitiveHeaderLookup("x-skip-processing") == "true" { |
| return nil, nil // Continue without modification |
| } |
| // Process... |
| return nil, nil |
| } |
|
|
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Post-hook always runs unless pre-hook short-circuited |
| return nil |
| } |
| ``` |
|
|
| ### Error Handling / Short-Circuit |
|
|
| **v1.3.x:** |
| ```go |
| func TransportInterceptor(ctx *schemas.BifrostContext, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { |
| if headers["x-api-key"] == "" { |
| return nil, nil, fmt.Errorf("missing API key") |
| } |
| return headers, body, nil |
| } |
| ``` |
|
|
| **v1.4.x+:** |
| ```go |
| func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { |
| if req.CaseInsensitiveHeaderLookup("x-api-key") == "" { |
| // Return a custom response to short-circuit |
| return &schemas.HTTPResponse{ |
| StatusCode: 401, |
| Headers: map[string]string{"Content-Type": "application/json"}, |
| Body: []byte(`{"error": "missing API key"}`), |
| }, nil |
| } |
| return nil, nil |
| } |
|
|
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Not called if pre-hook short-circuited |
| return nil |
| } |
| ``` |
|
|
| ### Accessing Request Method and Path |
|
|
| **v1.3.x:** |
| ```go |
| // url parameter contained the full URL |
| func TransportInterceptor(ctx *schemas.BifrostContext, url string, headers map[string]string, body map[string]any) (map[string]string, map[string]any, error) { |
| // Limited access to URL |
| return headers, body, nil |
| } |
| ``` |
|
|
| **v1.4.x+:** |
| ```go |
| func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error) { |
| // Full access to request properties |
| method := req.Method // "GET", "POST", etc. |
| path := req.Path // "/v1/chat/completions" |
| query := req.Query // map[string]string of query params |
| pathParams := req.PathParams // map[string]string of path variables (e.g., {model}) |
| return nil, nil |
| } |
|
|
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Access both request and response |
| statusCode := resp.StatusCode |
| responseHeaders := resp.Headers |
| responseBody := resp.Body |
| _ = statusCode // Use variables... |
| _ = responseHeaders |
| _ = responseBody |
| return nil |
| } |
| ``` |
|
|
| ## Testing Your Migration |
|
|
| 1. **Build your updated plugin:** |
| ```bash |
| go build -buildmode=plugin -o my-plugin.so main.go |
| ``` |
|
|
| 2. **Update Bifrost to v1.4.x:** |
| ```bash |
| go get github.com/maximhq/bifrost/core@v1.4.0 |
| ``` |
|
|
| 3. **Test with a simple request:** |
| ```bash |
| curl -X POST http://localhost:8080/v1/chat/completions \ |
| -H "Content-Type: application/json" \ |
| -d '{"model": "openai/gpt-4o-mini", "messages": [{"role": "user", "content": "Hello"}]}' |
| ``` |
|
|
| 4. **Verify logs show both hooks being called:** |
| ``` |
| HTTPTransportPreHook called |
| PreLLMHook called |
| PostLLMHook called |
| HTTPTransportPostHook called |
| ``` |
|
|
| ## Troubleshooting |
|
|
| ### Plugin fails to load after migration |
|
|
| **Error:** `plugin: symbol TransportInterceptor not found` |
|
|
| This error occurs if Bifrost v1.4.x is looking for the old function. Make sure: |
| 1. You've updated to `HTTPTransportPreHook` and `HTTPTransportPostHook` |
| 2. The function signatures match exactly: |
| - `func HTTPTransportPreHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest) (*schemas.HTTPResponse, error)` |
| - `func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error` |
| 3. You've rebuilt the plugin with the correct core version |
|
|
| ### Body modification not working |
|
|
| Make sure you're assigning back to `req.Body` in the pre-hook: |
|
|
| ```go |
| // Wrong - body changes lost |
| var body map[string]any |
| sonic.Unmarshal(req.Body, &body) |
| body["model"] = "gpt-4" |
| // Missing: req.Body = ... |
|
|
| // Correct - body changes applied |
| var body map[string]any |
| sonic.Unmarshal(req.Body, &body) |
| body["model"] = "gpt-4" |
| req.Body, _ = sonic.Marshal(body) // Assign back! |
| ``` |
|
|
| ### Response modification not working |
|
|
| Make sure you're modifying `resp` in the post-hook: |
|
|
| ```go |
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Modify response headers |
| resp.Headers["x-custom-header"] = "value" |
| |
| // Modify response body |
| var body map[string]any |
| sonic.Unmarshal(resp.Body, &body) |
| body["extra_field"] = "value" |
| resp.Body, _ = sonic.Marshal(body) |
| |
| return nil |
| } |
| ``` |
|
|
| ### Headers not being set |
|
|
| Make sure you're modifying `req.Headers` or `resp.Headers` directly: |
|
|
| ```go |
| // Set request header in pre-hook |
| req.Headers["x-custom-header"] = "value" |
|
|
| // Set response header in post-hook |
| resp.Headers["x-custom-header"] = "value" |
|
|
| // Read headers using case-insensitive helper |
| value := req.CaseInsensitiveHeaderLookup("X-Custom-Header") |
| ``` |
|
|
| ### Context values not available in post-hook |
|
|
| Make sure you're using the correct context key type: |
|
|
| ```go |
| // In pre-hook - set value |
| ctx.SetValue(schemas.BifrostContextKey("my-key"), "my-value") |
|
|
| // In post-hook - read value |
| if val := ctx.Value(schemas.BifrostContextKey("my-key")) |
| // Use val |
| } |
| ``` |
|
|
| ## Streaming Chunk Hook (v1.4.x) |
|
|
| Bifrost v1.4.x introduces a new hook for intercepting streaming response chunks: |
|
|
| ### HTTPTransportStreamChunkHook |
|
|
| This hook is called for each chunk during streaming responses, allowing plugins to modify or filter chunks before they're sent to the client. |
|
|
| ```go |
| // HTTPTransportStreamChunkHook intercepts streaming chunks BEFORE they're written to the client. |
| // Modify chunk data or return nil to skip the chunk entirely. |
| // Only called for streaming responses when using HTTP transport (bifrost-http). |
| func HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, chunk *schemas.BifrostStreamChunk) (*schemas.BifrostStreamChunk, error) { |
| // chunk is a typed struct containing one of: |
| // - BifrostTextCompletionResponse (text completion streaming) |
| // - BifrostChatResponse (chat completion streaming) |
| // - BifrostResponsesStreamResponse (responses API streaming) |
| // - BifrostSpeechStreamResponse (speech synthesis streaming) |
| // - BifrostTranscriptionStreamResponse (transcription streaming) |
| // - BifrostImageGenerationStreamResponse (image generation streaming) |
| // - BifrostError (error during streaming) |
|
|
| // Return chunk unchanged to pass through |
| return chunk, nil |
|
|
| // Return nil to skip/filter this chunk |
| // return nil, nil |
|
|
| // Return modified chunk |
| // modifiedChunk := &schemas.BifrostStreamChunk{BifrostChatResponse: ...} |
| // return modifiedChunk, nil |
| } |
| ``` |
|
|
| **Key differences from `HTTPTransportPostHook`:** |
|
|
| | Aspect | HTTPTransportPostHook | HTTPTransportStreamChunkHook | |
| |--------|----------------------|------------------------------| |
| | When called | After complete response | Per-chunk during streaming | |
| | Input | Full HTTPResponse | `*BifrostStreamChunk` (typed struct) | |
| | Can modify | Full response | Individual chunk struct | |
| | Can skip | N/A | Return nil to skip chunk | |
|
|
| <Note> |
| `HTTPTransportPostHook` is **not called** for streaming responses. Use `HTTPTransportStreamChunkHook` instead to intercept streaming data. |
| </Note> |
|
|
| ### Migration for Existing Plugins |
|
|
| If your plugin implements `HTTPTransportPostHook` and you want to also handle streaming responses, add the new hook: |
|
|
| ```go |
| // Existing hook for non-streaming responses |
| func HTTPTransportPostHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, resp *schemas.HTTPResponse) error { |
| // Handle complete responses |
| return nil |
| } |
|
|
| // NEW: Add this for streaming responses |
| func HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, req *schemas.HTTPRequest, chunk *schemas.BifrostStreamChunk) (*schemas.BifrostStreamChunk, error) { |
| // Handle streaming chunks (typed struct, not raw bytes) |
| // Return chunk unchanged if no modification needed |
| return chunk, nil |
| } |
| ``` |
|
|
| ## Need Help? |
|
|
| - **Discord Community**: [Join our Discord](https://discord.gg/exN5KAydbU) |
| - **GitHub Issues**: [Report bugs or request features](https://github.com/maximhq/bifrost/issues) |
| - **Writing Plugins Guide**: [Full plugin documentation](./writing-plugin) |
|
|
|
|
|
|