`. The same security denylist and filter config that gates provider forwarding gates the span attribute — they are always the same set.
### Example: forwarding a session ID
```bash theme={null}
curl -X POST http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'x-bf-eh-session-id: sess-abc-123' \
-H 'x-bf-eh-tenant-id: acme-corp' \
-d '{
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
Result on the `llm.call` span:
| Attribute | Value |
| ---------------------------------------- | -------------- |
| `gen_ai.request.extra_header.session-id` | `sess-abc-123` |
| `gen_ai.request.extra_header.tenant-id` | `acme-corp` |
You can then filter or group traces by session in Grafana, Datadog, Honeycomb, Langfuse, etc. — no extra wiring required.
Want runtime labels on Prometheus metrics instead of OTel spans? Use `x-bf-dim-*` headers — see [Telemetry → Dynamic Label Injection](./telemetry#dynamic-label-injection). The same `x-bf-dim-*` values also flow through to OTel as span attributes.
***
## Setup
OpenTelemetry export is configured through the Bifrost UI, `config.json`, or the Go SDK. Full configuration options, popular platform recipes (Grafana Cloud, Datadog, New Relic, Honeycomb, Langfuse, self-hosted), cluster-mode metrics push, and the local Docker Compose stack are documented on the integrations page:
Full configuration reference, platform-specific examples, Docker Compose stack, and metrics push setup.
***
## Next Steps
* **[OpenTelemetry Integration](./observability/otel)** — Full setup with platform-specific examples
* **[Telemetry](./telemetry)** — Prometheus metrics that complement OTel traces
* **[Extra Headers Reference](/providers/request-options#extra-headers-x-bf-eh)** — Full `x-bf-eh-*` request format
# JSON Parser
Source: https://docs.getbifrost.ai/features/plugins/jsonparser
A simple Bifrost plugin that handles partial JSON chunks in streaming responses by making them valid JSON objects.
## Overview
When using AI providers that stream JSON responses, the individual chunks often contain incomplete JSON that cannot be parsed directly. This plugin automatically detects and fixes partial JSON chunks by adding the necessary closing braces, brackets, and quotes to make them valid JSON.
## Features
* **Automatic JSON Completion**: Detects partial JSON and adds missing closing characters
* **Streaming Only**: Processes only streaming responses (non-streaming responses are ignored)
* **Flexible Usage Modes**: Supports two usage types for different deployment scenarios
* **Safe Fallback**: Returns original content if JSON cannot be fixed
* **Memory Leak Prevention**: Automatic cleanup of stale accumulated content with configurable intervals
* **Zero Dependencies**: Only depends on Go's standard library
## Usage
### Usage Types
The plugin supports two usage types:
1. **AllRequests**: Processes all streaming responses automatically
2. **PerRequest**: Processes only when explicitly enabled via request context
```go theme={null}
package main
import (
"time"
"github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/plugins/jsonparser"
)
func main() {
// Create the JSON parser plugin for all requests
jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{
Usage: jsonparser.AllRequests,
CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes
MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes
})
// Initialize Bifrost with the plugin
client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &MyAccount{},
LLMPlugins: []schemas.LLMPlugin{
jsonPlugin,
},
})
if err != nil {
panic(err)
}
// Use the client normally - JSON parsing happens automatically
// in the PostLLMHook for all streaming responses
}
```
### PerRequest Mode
```go theme={null}
package main
import (
"context"
"time"
"github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/plugins/jsonparser"
)
func main() {
// Create the JSON parser plugin for per-request control
jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{
Usage: jsonparser.PerRequest,
CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes
MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes
})
// Initialize Bifrost with the plugin
client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &MyAccount{},
LLMPlugins: []schemas.LLMPlugin{
jsonPlugin,
},
})
if err != nil {
panic(err)
}
ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, true)
// Enable JSON parsing for specific requests
stream, bifrostErr := client.ChatCompletionStreamRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), request)
if bifrostErr != nil {
// handle error
}
for chunk := range stream {
_ = chunk // handle each streaming chunk
}
}
```
### Configuration
```go theme={null}
// Custom cleanup configuration
plugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{
Usage: jsonparser.AllRequests,
CleanupInterval: 2 * time.Minute, // Cleanup every 2 minutes
MaxAge: 10 * time.Minute, // Remove entries older than 10 minutes
})
```
#### Default Values
* **CleanupInterval**: 5 minutes (how often to run cleanup)
* **MaxAge**: 30 minutes (how old entries can be before cleanup)
* **Usage**: Must be specified (AllRequests or PerRequest)
### Context Key for PerRequest Mode
When using `PerRequest` mode, the plugin checks for the context key `jsonparser.EnableStreamingJSONParser` with a boolean value:
* `true`: Enable JSON parsing for this request
* `false`: Disable JSON parsing for this request
* Key not present: Disable JSON parsing for this request
**Example:**
```go theme={null}
import (
"context"
"github.com/maximhq/bifrost/plugins/jsonparser"
)
// Enable JSON parsing for this request
ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, true)
// Disable JSON parsing for this request
ctx := context.WithValue(context.Background(), jsonparser.EnableStreamingJSONParser, false)
// No context key - JSON parsing disabled (default behavior)
ctx := context.Background()
```
## How It Works
The plugin implements an optimized `parsePartialJSON` function with the following steps:
1. **Usage Check**: Determines if processing should occur based on usage type and context
2. **Validates Input**: First tries to parse the string as valid JSON
3. **Character Analysis**: If invalid, processes the string character-by-character to track:
* String boundaries (inside/outside quotes)
* Escape sequences
* Opening/closing braces and brackets
4. **Auto-Completion**: Adds missing closing characters in the correct order
5. **Validation**: Verifies the completed JSON is valid
6. **Fallback**: Returns original content if completion fails
### Memory Management
The plugin automatically manages memory by:
1. **Accumulating Content**: Stores partial JSON chunks with timestamps for each request
2. **Periodic Cleanup**: Runs a background goroutine that removes stale entries based on `MaxAge`
3. **Request Completion**: Automatically clears accumulated content when requests complete successfully
4. **Configurable Intervals**: Allows customization of cleanup frequency and retention periods
### Real-Life Streaming Example
Here's a practical example showing how the JSON parser plugin fixes broken JSON chunks in streaming responses:
```go theme={null}
package main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/plugins/jsonparser"
)
func main() {
// Create JSON parser plugin
jsonPlugin := jsonparser.NewJsonParserPlugin(jsonparser.PluginConfig{
Usage: jsonparser.AllRequests,
CleanupInterval: 2 * time.Minute,
MaxAge: 10 * time.Minute,
})
// Initialize Bifrost with the plugin
client, err := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &MyAccount{},
LLMPlugins: []schemas.LLMPlugin{jsonPlugin},
})
if err != nil {
panic(err)
}
defer client.Shutdown()
// Request structured JSON response
request := &schemas.BifrostChatRequest{
Provider: schemas.OpenAI,
Model: "gpt-4o-mini",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: schemas.ChatMessageContent{
ContentStr: bifrost.Ptr("Return user profile as JSON: {\"name\": \"John Doe\", \"email\": \"john@example.com\"}"),
},
},
},
}
// Stream the response
stream, bifrostErr := client.ChatCompletionStreamRequest(schemas.NewBifrostContext(context.Background(), schemas.NoDeadline), request)
if bifrostErr != nil {
panic(bifrostErr)
}
fmt.Println("Streaming JSON response:")
for chunk := range stream {
if chunk.BifrostChatResponse != nil && len(chunk.BifrostChatResponse.Choices) > 0 {
choice := chunk.BifrostChatResponse.Choices[0]
if choice.ChatStreamResponseChoice != nil && choice.ChatStreamResponseChoice.Delta != nil {
content := *choice.ChatStreamResponseChoice.Delta.Content
fmt.Printf("Chunk: %s\n", content)
// With JSON parser, you can parse each chunk immediately
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(content), &jsonData); err == nil {
fmt.Printf("✅ Valid JSON parsed successfully\n")
} else {
fmt.Printf("❌ Invalid JSON: %v\n", err)
}
}
}
}
}
```
**Without JSON Parser** (raw streaming chunks):
```
Chunk 1: `{` ❌ Invalid JSON
Chunk 2: `{"name"` ❌ Invalid JSON
Chunk 3: `{"name": "John"` ❌ Invalid JSON
Chunk 4: `{"name": "John Doe"` ❌ Invalid JSON
```
**With JSON Parser** (processed chunks):
```
Chunk 1: `{}` ✅ Valid JSON
Chunk 2: `{"name": ""}` ✅ Valid JSON
Chunk 3: `{"name": "John"}` ✅ Valid JSON
Chunk 4: `{"name": "John Doe"}` ✅ Valid JSON
```
### Use Cases
* **Function Calling**: Stream tool call arguments as valid JSON throughout the response
* **Structured Data**: Stream complex JSON objects (user profiles, product catalogs) progressively
* **Real-time Parsing**: Enable client-side JSON parsing at each streaming step without waiting for completion
* **API Integration**: Forward streaming JSON to downstream services that expect valid JSON
* **Live Updates**: Update UI components with valid JSON data as it streams in
### Example Transformations
| Input | Output |
| ---------------------------- | ----------------------------- |
| `{"name": "John"` | `{"name": "John"}` |
| `["apple", "banana"` | `["apple", "banana"]` |
| `{"user": {"name": "John"` | `{"user": {"name": "John"}}` |
| `{"message": "Hello\nWorld"` | `{"message": "Hello\nWorld"}` |
| `""` (empty string) | `{}` |
| `" "` (whitespace only) | `{}` |
## Testing
Run the test suite:
```bash theme={null}
cd plugins/jsonparser
go test -v
```
The tests cover:
* Plugin interface compliance
* Both usage types (AllRequests and PerRequest)
* Context-based enabling/disabling
* Streaming responses only (non-streaming responses are ignored)
* Various JSON completion scenarios
* Edge cases and error conditions
* Memory cleanup functionality with real and simulated requests
* Configuration options and default values
# Mocker
Source: https://docs.getbifrost.ai/features/plugins/mocker
Mock AI provider responses for testing, development, and simulation purposes.
## Quick Start
### Minimal Configuration
The simplest way to use the Mocker plugin is with no configuration - it will create a default catch-all rule:
```go theme={null}
package main
import (
"context"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
mocker "github.com/maximhq/bifrost/plugins/mocker"
)
func main() {
// Create plugin with minimal config
plugin, err := mocker.NewMockerPlugin(mocker.MockerConfig{
Enabled: true, // Default rule will be created automatically
})
if err != nil {
panic(err)
}
// Initialize Bifrost with the plugin
client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &yourAccount,
LLMPlugins: []schemas.LLMPlugin{plugin},
})
if err != nil {
panic(err)
}
defer client.Shutdown()
// All chat and responses requests will now return: "This is a mock response from the Mocker plugin"
// Chat completion request
chatResponse, _ := client.ChatCompletionRequest(schemas.NewBifrostContext(context.Background(), schemas.NoDeadline), &schemas.BifrostChatRequest{
Provider: schemas.OpenAI,
Model: "gpt-4",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: schemas.ChatMessageContent{
ContentStr: bifrost.Ptr("Hello!"),
},
},
},
})
// Responses request
responsesResponse, _ := client.ResponsesRequest(schemas.NewBifrostContext(context.Background(), schemas.NoDeadline), &schemas.BifrostResponsesRequest{
Provider: schemas.OpenAI,
Model: "gpt-4o",
Input: []schemas.ResponsesMessage{
{
Role: bifrost.Ptr(schemas.ResponsesInputMessageRoleUser),
Content: &schemas.ResponsesMessageContent{
ContentStr: bifrost.Ptr("Hello!"),
},
},
},
})
}
```
### Custom Response
```go theme={null}
plugin, err := mocker.NewMockerPlugin(mocker.MockerConfig{
Enabled: true,
Rules: []mocker.MockRule{
{
Name: "openai-mock",
Enabled: true,
Probability: 1.0, // Always trigger
Conditions: mocker.Conditions{
Providers: []string{"openai"},
},
Responses: []mocker.Response{
{
Type: mocker.ResponseTypeSuccess,
Content: &mocker.SuccessResponse{
Message: "Hello! This is a custom mock response for OpenAI.",
Usage: &mocker.Usage{
PromptTokens: 15,
CompletionTokens: 25,
TotalTokens: 40,
},
},
},
},
},
},
})
```
### Responses Request Example
The mocker plugin automatically handles both chat completion and responses requests with the same configuration:
```go theme={null}
// This rule will work for both ChatCompletionRequest and ResponsesRequest
{
Name: "universal-mock",
Enabled: true,
Probability: 1.0,
Conditions: mocker.Conditions{
MessageRegex: stringPtr("(?i).*hello.*"),
},
Responses: []mocker.Response{
{
Type: mocker.ResponseTypeSuccess,
Content: &mocker.SuccessResponse{
Message: "Hello! I'm a mock response that works for both request types.",
},
},
},
}
```
## Installation
Add the plugin to your project:
```bash theme={null}
go get github.com/maximhq/bifrost/plugins/mocker
```
Import in your code:
```go theme={null}
import mocker "github.com/maximhq/bifrost/plugins/mocker"
```
## Basic Usage
### Creating the Plugin
```go theme={null}
config := mocker.MockerConfig{
Enabled: true,
DefaultBehavior: mocker.DefaultBehaviorPassthrough, // "passthrough", "success", "error"
Rules: []mocker.MockRule{
// Your rules here
},
}
plugin, err := mocker.NewMockerPlugin(config)
if err != nil {
log.Fatal(err)
}
```
### Adding to Bifrost
```go theme={null}
client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &yourAccount,
LLMPlugins: []schemas.LLMPlugin{plugin},
Logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo),
})
```
### Disabling the Plugin
```go theme={null}
config := mocker.MockerConfig{
Enabled: false, // All requests pass through to real providers
}
```
## Supported Request Types
The Mocker plugin supports the following Bifrost request types:
* **Chat Completion Requests** (`ChatCompletionRequest`) - Standard chat-based interactions
* **Responses Requests** (`ResponsesRequest`) - OpenAI-compatible responses API format
* **Skip Context Key** - Use `"skip-mocker"` context key to bypass mocking per request
### Skip Mocker for Specific Requests
You can skip the mocker plugin for specific requests by adding a context key:
```go theme={null}
import "github.com/maximhq/bifrost/core/schemas"
// Create context that skips mocker
ctx := context.WithValue(context.Background(),
schemas.BifrostContextKey("skip-mocker"), true)
// This request will bypass the mocker and go to the real provider
response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), request)
```
## Key Features
### Template Variables
Create dynamic responses using templates:
```go theme={null}
Response{
Type: mocker.ResponseTypeSuccess,
Content: &mocker.SuccessResponse{
MessageTemplate: stringPtr("Hello from {{provider}} using model {{model}}!"),
},
}
```
**Available Variables:**
* `{{provider}}` - Provider name (e.g., "openai", "anthropic")
* `{{model}}` - Model name (e.g., "gpt-4", "claude-3")
* `{{faker.*}}` - Fake data generation (see Configuration Reference)
### Weighted Response Selection
Configure multiple responses with different probabilities:
```go theme={null}
Responses: []mocker.Response{
{
Type: mocker.ResponseTypeSuccess,
Weight: 0.8, // 80% chance
Content: &mocker.SuccessResponse{
Message: "Success response",
},
},
{
Type: mocker.ResponseTypeError,
Weight: 0.2, // 20% chance
Error: &mocker.ErrorResponse{
Message: "Rate limit exceeded",
Type: stringPtr("rate_limit"),
Code: stringPtr("429"),
},
},
}
```
### Latency Simulation
Add realistic delays to responses:
```go theme={null}
// Fixed latency
Latency: &mocker.Latency{
Type: mocker.LatencyTypeFixed,
Min: 250 * time.Millisecond,
}
// Variable latency
Latency: &mocker.Latency{
Type: mocker.LatencyTypeUniform,
Min: 100 * time.Millisecond,
Max: 500 * time.Millisecond,
}
```
### Advanced Matching
#### Regex Message Matching
```go theme={null}
Conditions: mocker.Conditions{
MessageRegex: stringPtr(`(?i).*support.*|.*help.*`),
}
```
#### Request Size Filtering
```go theme={null}
Conditions: mocker.Conditions{
RequestSize: &mocker.SizeRange{
Min: 100, // bytes
Max: 1000, // bytes
},
}
```
### Faker Data Generation
Create realistic test data using faker variables:
```go theme={null}
{
Name: "user-profile-example",
Responses: []mocker.Response{
{
Type: mocker.ResponseTypeSuccess,
Content: &mocker.SuccessResponse{
MessageTemplate: stringPtr(`User Profile:
- Name: {{faker.name}}
- Email: {{faker.email}}
- Company: {{faker.company}}
- Address: {{faker.address}}, {{faker.city}}
- Phone: {{faker.phone}}
- User ID: {{faker.uuid}}
- Join Date: {{faker.date}}
- Premium Account: {{faker.boolean}}`),
},
},
},
}
```
### Statistics and Monitoring
Get runtime statistics for monitoring:
```go theme={null}
stats := plugin.GetStatistics()
fmt.Printf("Plugin enabled: %v\n", stats.Enabled)
fmt.Printf("Total requests: %d\n", stats.TotalRequests)
fmt.Printf("Mocked requests: %d\n", stats.MockedRequests)
// Rule-specific stats
for ruleName, ruleStats := range stats.Rules {
fmt.Printf("Rule %s: %d triggers\n", ruleName, ruleStats.Triggers)
}
```
## Configuration Reference
### MockerConfig
| Field | Type | Default | Description |
| ----------------- | ------------ | --------------- | ------------------------------------------------------------------- |
| `Enabled` | `bool` | `false` | Enable/disable the entire plugin |
| `DefaultBehavior` | `string` | `"passthrough"` | Action when no rules match: `"passthrough"`, `"success"`, `"error"` |
| `GlobalLatency` | `*Latency` | `nil` | Global latency applied to all rules |
| `Rules` | `[]MockRule` | `[]` | List of mock rules evaluated in priority order |
### MockRule
| Field | Type | Default | Description |
| ------------- | ------------ | ------- | ---------------------------------------------- |
| `Name` | `string` | - | Unique rule name for identification |
| `Enabled` | `bool` | `true` | Enable/disable this specific rule |
| `Priority` | `int` | `0` | Higher numbers = higher priority |
| `Probability` | `float64` | `1.0` | Activation probability (0.0=never, 1.0=always) |
| `Conditions` | `Conditions` | `{}` | Matching conditions (empty = match all) |
| `Responses` | `[]Response` | - | Possible responses (weighted random selection) |
| `Latency` | `*Latency` | `nil` | Rule-specific latency override |
### Conditions
| Field | Type | Description |
| -------------- | ------------ | --------------------------------------------------- |
| `Providers` | `[]string` | Match specific providers: `["openai", "anthropic"]` |
| `Models` | `[]string` | Match specific models: `["gpt-4", "claude-3"]` |
| `MessageRegex` | `*string` | Regex pattern to match message content |
| `RequestSize` | `*SizeRange` | Request size constraints in bytes |
### Response
| Field | Type | Description |
| ---------------- | ------------------ | ------------------------------------------------------ |
| `Type` | `string` | Response type: `"success"` or `"error"` |
| `Weight` | `float64` | Weight for random selection (default: 1.0) |
| `Content` | `*SuccessResponse` | Required if `Type="success"` |
| `Error` | `*ErrorResponse` | Required if `Type="error"` |
| `AllowFallbacks` | `*bool` | Control fallback behavior (`nil`=allow, `false`=block) |
### SuccessResponse
| Field | Type | Description |
| ----------------- | ------------------------ | ------------------------------------------------------------------- |
| `Message` | `string` | Static response message |
| `MessageTemplate` | `*string` | Template with variables: `{{provider}}`, `{{model}}`, `{{faker.*}}` |
| `Model` | `*string` | Override model name in response |
| `Usage` | `*Usage` | Token usage information |
| `FinishReason` | `*string` | Completion reason (default: `"stop"`) |
| `CustomFields` | `map[string]interface{}` | Additional metadata fields |
### ErrorResponse
| Field | Type | Description |
| ------------ | --------- | ------------------------------------------------- |
| `Message` | `string` | Error message to return |
| `Type` | `*string` | Error type (e.g., `"rate_limit"`, `"auth_error"`) |
| `Code` | `*string` | Error code (e.g., `"429"`, `"401"`) |
| `StatusCode` | `*int` | HTTP status code |
### Latency
| Field | Type | Description |
| ------ | --------------- | ---------------------------------------------- |
| `Type` | `string` | Latency type: `"fixed"` or `"uniform"` |
| `Min` | `time.Duration` | Minimum/exact latency (use `time.Millisecond`) |
| `Max` | `time.Duration` | Maximum latency (required for `"uniform"`) |
**Important**: Use Go's `time.Duration` constants:
* ✅ Correct: `100 * time.Millisecond`
* ❌ Wrong: `100` (nanoseconds, barely noticeable)
### Faker Variables
#### Personal Information
* `{{faker.name}}` - Full name
* `{{faker.first_name}}` - First name only
* `{{faker.last_name}}` - Last name only
* `{{faker.email}}` - Email address
* `{{faker.phone}}` - Phone number
#### Location
* `{{faker.address}}` - Street address
* `{{faker.city}}` - City name
* `{{faker.state}}` - State/province
* `{{faker.zip_code}}` - Postal code
#### Business
* `{{faker.company}}` - Company name
* `{{faker.job_title}}` - Job title
#### Text and Data
* `{{faker.lorem_ipsum}}` - Lorem ipsum text
* `{{faker.lorem_ipsum:10}}` - Lorem ipsum with 10 words
* `{{faker.uuid}}` - UUID v4
* `{{faker.hex_color}}` - Hex color code
#### Numbers and Dates
* `{{faker.integer}}` - Random integer (1-100)
* `{{faker.integer:10,50}}` - Random integer between 10-50
* `{{faker.float}}` - Random float (0-100, 2 decimals)
* `{{faker.float:1,10}}` - Random float between 1-10
* `{{faker.boolean}}` - Random boolean
* `{{faker.date}}` - Date (YYYY-MM-DD format)
* `{{faker.datetime}}` - Datetime (YYYY-MM-DD HH:MM:SS format)
## Best Practices
### Rule Organization
```go theme={null}
// Use priority to control rule evaluation order
rules := []mocker.MockRule{
{Name: "specific-error", Priority: 100, Conditions: /* specific */},
{Name: "general-success", Priority: 50, Conditions: /* general */},
{Name: "catch-all", Priority: 0, Conditions: /* empty */},
}
```
### Development vs Production
```go theme={null}
// Development: High mock rate
config := mocker.MockerConfig{
Enabled: true,
Rules: []mocker.MockRule{
{Probability: 1.0}, // Always mock
},
}
// Production: Occasional testing
config := mocker.MockerConfig{
Enabled: true,
Rules: []mocker.MockRule{
{Probability: 0.1}, // 10% mock rate
},
}
```
### Performance Considerations
* Place specific conditions before general ones (higher priority)
* Use simple string matching over complex regex when possible
* Keep response templates reasonably sized
* Consider disabling debug logging in production
### Testing Your Configuration
```go theme={null}
func validateMockerConfig(config mocker.MockerConfig) error {
_, err := mocker.NewMockerPlugin(config)
return err
}
// Test before deployment
if err := validateMockerConfig(yourConfig); err != nil {
log.Fatalf("Invalid mocker configuration: %v", err)
}
```
## Common Issues
### Plugin Not Triggering
1. Check if plugin is enabled: `Enabled: true`
2. Verify rule is enabled: `rule.Enabled: true`
3. Check probability: `Probability: 1.0` for testing
4. Verify conditions match your request
### Latency Not Working
Use `time.Duration` constants, not raw integers:
```go theme={null}
// ❌ Wrong: 100 nanoseconds (barely noticeable)
Min: 100
// ✅ Correct: 100 milliseconds
Min: 100 * time.Millisecond
```
### Regex Not Matching
Test your regex pattern and ensure proper escaping:
```go theme={null}
// Case-insensitive matching
MessageRegex: stringPtr(`(?i).*help.*`)
// Escape special characters
MessageRegex: stringPtr(`\$\d+\.\d+`) // Match $12.34
```
### Controlling Fallbacks
```go theme={null}
Response{
Type: mocker.ResponseTypeError,
AllowFallbacks: boolPtr(false), // Block fallbacks
Error: &mocker.ErrorResponse{
Message: "Authentication failed",
},
}
```
### Skip Mocker Not Working
Ensure you're using the correct context key format:
```go theme={null}
// ✅ Correct
ctx := context.WithValue(context.Background(),
schemas.BifrostContextKey("skip-mocker"), true)
// ❌ Wrong
ctx := context.WithValue(context.Background(), "skip-mocker", true)
```
### Responses Request Issues
If responses requests aren't being mocked:
1. Verify the plugin supports `ResponsesRequest` (version 1.2.13+)
2. Check that your regex patterns match the message content
3. Ensure the request type is `schemas.ResponsesRequest`
### Debug Mode
Enable debug logging to troubleshoot:
```go theme={null}
client, initErr := bifrost.Init(context.Background(), schemas.BifrostConfig{
Account: &account,
LLMPlugins: []schemas.LLMPlugin{plugin},
Logger: bifrost.NewDefaultLogger(schemas.LogLevelDebug),
})
```
# Auto Prompt Caching
Source: https://docs.getbifrost.ai/features/prompt-caching
Automatically inject prompt-cache breakpoints for clients that send none, so agentic tools stop paying the cache-write rate on every turn.
Not to be confused with [semantic caching](/features/semantic-caching). Semantic caching is
Bifrost replaying a **response** it has already seen, so the provider is never called.
Prompt caching is the **provider** reusing the prefix of your request: the call still
happens and is still billed, but cached input is much cheaper than fresh input. The two
are independent and can both be on.
## Overview
Providers such as Anthropic cache a prompt prefix only when the request marks where the
cacheable region ends, using a `cache_control` block on a message. Most SDKs let you add
that marker yourself, but agentic clients such as Codex send none at all. On Anthropic
models that means nothing is cached and every turn pays full price for a prompt that
barely changed. On providers that cache implicitly, the cached prefix slides onto the
newest message, so each turn writes a new cache entry and reads almost nothing back.
Bifrost can add the marker for the client. Turn on `prompt_cache.auto_inject` for a
provider and Bifrost marks the first cacheable content block of every request that
arrives without markers of its own. That block is the prefix an agent loop replays
verbatim each turn, so turn 1 writes the cache and turn 2 onward reads it.
**Key properties:**
* **Off by default** - a cache marker is a cost decision, and Bifrost never spends one the operator did not ask for.
* **Caller markers always win** - a request that already carries `cache_control` or `prompt_cache_breakpoint` is forwarded unchanged.
* **Capability gated** - a marker is only injected for models that can act on one. Implicit-caching providers are never sent a marker they would reject or ignore.
* **Per-provider** - configure it on each provider independently, from the provider config sheet, the management API, or `config.json`.
* **Overridable per request** - flip `auto_inject` for a single request with the `x-bf-prompt-cache-auto-inject` header.
***
## How it works
```mermaid theme={null}
graph LR
A[Request arrives] --> B{Caller sent
cache markers?}
B -- Yes --> F[Forward unchanged]
B -- No --> C{prompt_cache
enabled on provider?}
C -- No --> F
C -- Yes --> D{Model supports
explicit caching?}
D -- No --> F
D -- Yes --> E[Mark first cacheable block
or configured injection points]
E --> G[Provider translates marker
cache_control / cachePoint / prompt_cache_breakpoint]
```
Injection runs on Chat Completions and Responses requests, including their streaming
variants, and on every SDK integration route that Bifrost converts into one of those
two shapes. A few rules govern what gets marked:
1. **First cacheable block.** With `auto_inject` alone, Bifrost walks the messages in order and marks the first text, image, or file block it finds. A message whose content is a plain string is promoted to a single text block so the marker has somewhere to sit. The promotion is deterministic, so the cached prefix stays byte-identical across turns.
2. **Caller markers win.** If any message already carries a marker, the request is left completely alone. Injection is a default for clients that say nothing, never an override of a client that spoke.
3. **At most four markers.** Anthropic rejects a request carrying more than four blocks with `cache_control`, and every other dialect derives from that ceiling. Injection stops at four rather than relying on a downstream clamp that would silently discard the earliest marker.
4. **Copy on write.** The request Bifrost holds is never mutated. The marker is added to a copy handed to the provider, so plugins, retries, and fallbacks never see a marker the caller did not send.
5. **Per attempt.** A fallback to a different provider re-evaluates injection against that provider's own `prompt_cache` config and model capabilities. It does not inherit the previous provider's decision.
***
## Provider support
Bifrost injects one internal marker shape and each provider translates it to its own
wire format. The capability gate is evaluated per model, not per provider, so a
provider that serves both explicit-caching and implicit-caching models only injects on
the former.
| Provider | Models that take a marker | Wire format | TTL |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Anthropic | All Claude models | `cache_control: {"type": "ephemeral"}` on the content block | `5m` default, `1h` with `ttl` |
| Vertex AI | Claude models only. Gemini caches through a server-side `cachedContent` resource, so injection is a no-op there | `cache_control` | `5m` default, `1h` with `ttl` |
| Bedrock | Claude and Amazon Nova on the Converse API. gpt-5.6 ids that resolve to the Mantle surface follow the OpenAI row | `cachePoint` block | Claude only. Nova accepts only the default and returns 400 for `1h` |
| Bedrock Mantle | Claude, Amazon Nova, and the gpt-5.6 family | `cachePoint` for Claude and Nova, `prompt_cache_breakpoint` for gpt-5.6 | Same as Bedrock |
| OpenAI | gpt-5.6 family on the Responses API. Earlier models cache implicitly, and Chat Completions strips the marker for every model | `prompt_cache_breakpoint` on the block plus `prompt_cache_options.mode: "explicit"` on the request | Ignored |
| Azure OpenAI | gpt-5.6 family on the Responses API | Same as OpenAI | Ignored |
| OpenRouter | Claude models | `cache_control` (Chat) or `prompt_cache_breakpoint` (Responses), converted upstream | `5m` default, `1h` with `ttl` |
| Custom providers | Follow the base provider they wrap | Same as the base provider | Same as the base provider |
| Gemini, DeepSeek, Groq, xAI, Mistral, and other implicit-caching providers | None | Not applicable, injection is a no-op | Not applicable |
The `Prompt Caching` tab appears in the provider sheet for every provider, including
those where injection is a no-op. Saving `auto_inject: true` on an implicit-caching
provider is harmless: the capability gate answers false for every model, so no marker is
ever sent. The setting starts working the moment that provider gains a model that
accepts explicit markers.
See the provider guides for the full cache-control semantics of each dialect:
[Anthropic](/providers/supported-providers/anthropic#auto-inject-cache-breakpoints),
[Bedrock](/providers/supported-providers/bedrock#cache-control),
[OpenAI](/providers/supported-providers/openai),
[Vertex](/providers/supported-providers/vertex),
[OpenRouter](/providers/supported-providers/openrouter), and
[Gemini](/providers/supported-providers/gemini).
***
## Configuration
Prompt caching is configured per provider. Three settings are available:
| Field | Type | Required | Description |
| -------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto_inject` | boolean | Yes | Mark the first cacheable content block when the caller supplied no markers. |
| `ttl` | string | No | Lifetime requested for injected markers. The only accepted value is `"1h"`. Omit it for the provider default (5 minutes on Anthropic). Providers that cannot carry a TTL ignore it. |
| `cache_control_injection_points` | array | No | Target specific messages instead of the first cacheable block. When set, this **replaces** `auto_inject` rather than adding to it. See [Injection points](#injection-points). |
1. Open **Model Providers** and select the provider you want to configure.
2. Click **Edit Provider Config** to open the provider configuration sheet.
3. Select the **Prompt Caching** tab.
4. Turn on **Auto-inject cache breakpoints**.
5. Optionally change **Cache TTL** from **Provider default (5 minutes)** to **1 hour**.
6. Optionally click **Add injection point** and set a **Role**, an **Index**, or both for each point. Adding any point replaces the default first-block strategy.
7. Click **Save Prompt Caching**.
Update the provider with a `prompt_cache` block. The endpoint replaces the provider-level
configuration, so send your existing network and concurrency settings alongside it.
```bash theme={null}
curl --location --request PUT 'http://localhost:8080/api/providers/anthropic' \
--header 'Content-Type: application/json' \
--data '{
"network_config": {
"default_request_timeout_in_seconds": 30,
"max_retries": 0
},
"concurrency_and_buffer_size": {
"concurrency": 1000,
"buffer_size": 5000
},
"prompt_cache": {
"auto_inject": true,
"ttl": "1h"
}
}'
```
To turn injection off again, send `"prompt_cache": {"auto_inject": false}`. To remove
the block entirely, send `"prompt_cache": null`. Omitting the field leaves the current
value untouched.
**Response:**
```json theme={null}
{
"name": "anthropic",
"network_config": { "...": "..." },
"concurrency_and_buffer_size": { "concurrency": 1000, "buffer_size": 5000 },
"prompt_cache": {
"auto_inject": true,
"ttl": "1h"
},
"provider_status": "active"
}
```
A `ttl` other than `"1h"`, an unknown `role`, or a `location` other than `"message"` is
rejected with `400 Bad Request` and a message starting with
`prompt cache validation failed`.
```json theme={null}
{
"providers": {
"anthropic": {
"keys": [
{
"name": "anthropic-key-1",
"value": "env.ANTHROPIC_API_KEY",
"models": ["*"],
"weight": 1.0
}
],
"prompt_cache": {
"auto_inject": true,
"ttl": "1h"
}
}
}
}
```
With explicit injection points instead of the default strategy:
```json theme={null}
{
"providers": {
"bedrock": {
"keys": [
{
"name": "bedrock-key-1",
"models": ["*"],
"weight": 1.0,
"bedrock_key_config": {
"access_key": "env.AWS_ACCESS_KEY_ID",
"secret_key": "env.AWS_SECRET_ACCESS_KEY",
"region": "us-east-1"
}
}
],
"prompt_cache": {
"auto_inject": true,
"cache_control_injection_points": [
{ "location": "message", "role": "system" },
{ "location": "message", "index": -1 }
]
}
}
}
}
```
`prompt_cache` is part of the provider's config hash. In the default split mode, a
change to the block in `config.json` is synced into the config store on the next
restart, while an unchanged block keeps whatever was last saved from the Web UI or API.
With `source_of_truth: "config.json"` the file always wins. See
[Source of Truth & Reconciliation](/deployment-guides/config-json/source-of-truth).
The file path stores the block as written. Only the management API rejects an
unsupported `ttl` or `role`, so keep the file within the values listed above.
Set `PromptCache` on the `ProviderConfig` your account returns from
`GetConfigForProvider`:
```go theme={null}
func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) {
switch provider {
case schemas.Anthropic:
return &schemas.ProviderConfig{
NetworkConfig: schemas.DefaultNetworkConfig,
ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize,
PromptCache: &schemas.PromptCacheConfig{
AutoInject: true,
TTL: new("1h"),
},
}, nil
}
return nil, fmt.Errorf("provider %s not configured", provider)
}
```
***
## Injection points
`cache_control_injection_points` gives you precise control over which messages are
marked. It mirrors LiteLLM's setting of the same name, so an existing LiteLLM
configuration carries over directly.
Each point has three fields:
| Field | Type | Required | Description |
| ---------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `location` | string | No | What to target. Only `"message"` is supported today. Reserved so tools and system targets can be added later without changing the config shape. |
| `role` | string | No | Match messages with this role: `system`, `developer`, `user`, or `assistant`. |
| `index` | integer | No | Match the message at this position. Negative values count from the end, so `-1` is the last message. |
The matching rules:
* **Role and index combine with AND.** A point with both matches only when the message at that index has that role.
* **Role alone matches every message with that role.** Four `user` messages produce four markers, which is the whole budget.
* **Index alone matches one message.** An index past either end of the conversation matches nothing. A conversation shorter than the configured index is normal in the early turns of a session, so it is not treated as an error and no other message is marked in its place.
* **A point with neither role nor index matches nothing.** It is almost certainly a mistake, and marking every message would burn the whole budget.
* **The last cacheable block of each match is marked**, not the first. A point names a message you want cached through to its end, unlike the default strategy which names a prefix boundary.
* **At most four markers are emitted**, in the order the points are listed. Points beyond the budget are ignored.
* **Any point replaces the default strategy.** `auto_inject` is still required to turn the feature on, but once at least one point is present the first-block rule no longer applies.
**Example: cache the system prompt and the latest user turn**
```json theme={null}
{
"prompt_cache": {
"auto_inject": true,
"cache_control_injection_points": [
{ "location": "message", "role": "system" },
{ "location": "message", "role": "user", "index": -1 }
]
}
}
```
This is the shape most chat applications want: the system prompt is a stable prefix, and
marking the newest user message caches everything up to it for the next turn.
**Example: pin the first two messages**
```json theme={null}
{
"prompt_cache": {
"auto_inject": true,
"cache_control_injection_points": [
{ "location": "message", "index": 0 },
{ "location": "message", "index": 1 }
]
}
}
```
Every point that matches spends one of the four markers. A `role: "assistant"` point on a
long conversation fills the budget with the first four assistant messages and leaves
nothing for the messages you actually care about. Prefer negative indexes for
"the latest" and role-plus-index for a specific slot.
***
## Per-request override
The `x-bf-prompt-cache-auto-inject` header flips `auto_inject` for a single request.
Send `true` to inject on a request to a provider that has it off, or `false` to leave a
request alone when the provider has it on.
```bash theme={null}
curl --location 'http://localhost:8080/v1/chat/completions' \
--header 'x-bf-prompt-cache-auto-inject: false' \
--header 'Content-Type: application/json' \
--data '{
"model": "anthropic/claude-sonnet-4-5",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
```go theme={null}
ctx := context.Background()
ctx = context.WithValue(ctx, schemas.BifrostContextKeyPromptCacheAutoInject, false)
response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-5",
Input: messages,
})
```
Two limits apply to the override:
* **It cannot manufacture opt-in.** The header only takes effect on a provider that has a `prompt_cache` block configured. A provider with no block is one whose operator has expressed no opinion, and a request header must not spend a cache marker or change the billing profile on their behalf. Configure `{"auto_inject": false}` on the provider if you want callers to opt in per request.
* **Only `auto_inject` is overridable.** The TTL and injection points stay a config-level decision. A caller that wants specific placement can send the markers itself, and caller markers always win.
The model capability gate applies either way, so the header cannot force a marker onto a
model that has no use for one.
***
## Verifying that caching works
A `200` response alone does not mean the cache was used. Read the usage block.
On Chat Completions, Bifrost surfaces the provider's cache counters under
`usage.prompt_tokens_details`:
```json theme={null}
{
"usage": {
"prompt_tokens": 4213,
"completion_tokens": 88,
"prompt_tokens_details": {
"cached_read_tokens": 4096,
"cached_write_tokens": 0
}
}
}
```
On the Responses API the same counters appear under `usage.input_tokens_details`.
Expect the first request in a session to report `cached_write_tokens` and the following
requests to report `cached_read_tokens`. If every turn reports writes and no reads, the
cached prefix is changing between turns: check for a timestamp in the system prompt,
tools listed in a different order, or a client that rewrites earlier messages.
To see exactly where the marker landed, send `x-bf-send-back-raw-request: true` and
inspect `extra_fields.raw_request`. See
[Request Options](/providers/request-options#send-back-raw-request).
***
## Cost considerations
Prompt caching is cheaper only when the cached prefix is read more often than it is
written. Cache reads are billed well below the fresh-input rate, but the turn that
writes the cache costs more than fresh input: on Anthropic, 1.25x for the default 5
minute TTL and 2x for the 1 hour TTL.
* **Agent loops** replay the same prefix every turn, often dozens of times within a minute. This is the case the feature is built for and the savings are large.
* **One-shot requests** never read what they wrote. Injecting a marker there costs 25% to 100% more on the marked prefix for nothing in return. Leave `auto_inject` off on providers that only serve one-shot traffic, or use the header to opt those requests out.
* **A 1 hour TTL** survives long pauses between turns but doubles the write cost. Use it when turns are minutes apart, such as a human-in-the-loop workflow, and stay on the default when turns are seconds apart.
***
## Next steps
* **[Semantic Caching](/features/semantic-caching)** - Replay whole responses from Bifrost's own cache instead of calling the provider.
* **[Request Options](/providers/request-options)** - Every per-request header and context key, including the prompt-cache override.
* **[Anthropic](/providers/supported-providers/anthropic#cache-control)** - Cache-control semantics for Claude, which injected markers follow.
* **[Bedrock](/providers/supported-providers/bedrock#cache-control)** - How markers become `cachePoint` blocks on Bedrock.
* **[OpenAI](/providers/supported-providers/openai)** - Explicit cache mode on the gpt-5.6 family.
# Playground
Source: https://docs.getbifrost.ai/features/prompt-repository/playground
Create, test, and version prompts in an interactive playground.
## Overview
The **Playground** in Bifrost is an interactive workspace for building, testing, and managing prompts. It allows you to experiment with messages, switch models, adjust parameters, and iterate until the output looks right. Once you're satisfied, you can **publish a version** and use it directly in your codebase. Over time, the prompt repository becomes a centralized **CMS for all your prompts**, making it easier to manage versions, collaborate with teammates, and maintain production-ready prompts.
## How it Works
The playground is built around four core concepts: **Prompts, Sessions, and Versions**.
### Folders
Folders help organize prompts into logical groups. Teams often structure them by product area, feature, or use case.
* Each folder has a **name** and optional **description**
* Prompts can live inside folders or at the root level
* Deleting a folder removes **all prompts, sessions, and versions inside it**
### Prompts
A **Prompt** is the main unit in the repository.
Think of it as a container that holds the full lifecycle of a prompt, from early experiments to production-ready versions.
Each prompt can have:
* Multiple **sessions** for experimentation
* Multiple **versions** for stable releases
### Sessions (Working Copies)
Sessions are **editable working copies** where you experiment with a prompt.
You can freely:
* Modify messages
* Switch providers or models
* Adjust parameters
* Run the prompt repeatedly
Sessions don't affect committed versions, so you can iterate safely.
If your session has unsaved changes, a **red asterisk appears next to the prompt name** in the top bar.\
You can save your progress using:
* **Save Session** button
* `Cmd + S` / `Ctrl + S`
Saved sessions can be **renamed and restored** from the dropdown next to the Save button.
### Versions (Immutable Snapshots)
When you're happy with a prompt, you can **commit it as a version**.
Versions are **immutable snapshots**; once created, they cannot be edited. When the config differs from the last saved version, the **Unpublished Changes** badge appears, and it can be committed to create a new version.
Each version stores:
* The selected **message history** (system, user, assistant)
* **Provider and model configuration**
* **Model parameters** (temperature, max tokens, etc.)
* A **commit message** describing the change
Versions are automatically numbered:
```
v1 → v2 → v3 → ...
```
You can also **restore a previous version** from the dropdown next to the **Commit Version** button.
***
## Workspace Layout
The playground uses a simple **three-panel layout**:
| Panel | Purpose |
| ----------------------- | -------------------------------------------------------------------------- |
| **Sidebar (left)** | Browse prompts, manage folders, and organize items |
| **Playground (center)** | Build and test your prompt messages |
| **Settings (right)** | Configure provider, model, API key, variables, parameters, and deployments |
The settings panel is organized into collapsible sections:
* **Configuration** - Provider, model, API key, variables, and model parameters
* **Deployments** - Prompt deployment strategies and traffic routing (enterprise)
***
## Getting Started
Click the **"+"** button in the sidebar and select **New Folder**.
Folders help organize prompts by team, feature, or use case.
Click **"+"** again and choose **New Prompt**.\
Give it a name and optionally assign it to a folder.
Add messages to your prompt in the Playground:
* **System messages** for instructions
* **User messages** for input
* **Assistant messages** for examples or few-shot responses
Configure the provider, model, and parameters from the settings panel on the right.
Click **Run** or press `Cmd + S` / `Ctrl + S`.
Optionally, if you do not want to execute the prompt and only want to add a message to history, use the **+ Add** button.
Once you're satisfied with the results:
1. **Save Session** to preserve your work
2. **Commit Version** to create an immutable snapshot
## Key Capabilities
### Version Control
Each committed version creates a permanent record of your prompt.
This allows teams to track changes and safely iterate without breaking production prompts.
Key characteristics:
* **Sequential versioning** - v1, v2, v3, ...
* **Commit messages** explaining what changed
* **Immutable history**
### Multi-Provider Testing
You can switch between providers and models directly in the Playground.
Supported providers may include:
* OpenAI
* Anthropic
* AWS Bedrock
* Others configured in your Bifrost instance
You can also choose which API key to use:
* **Auto**: Uses the first available key.
* **Specific key**: Select a particular key.
* **Virtual key**: Uses governance-managed keys.
This makes it easy to compare how different models respond to the same prompt.
### Message Types
The Playground supports several message roles:
* **System**: Defines behavior or instructions.
* **User**: Input to the model.
* **Assistant**: The model's response to the user's input.
* **Tool Calls**: Function calls made by the model.
* **Tool Results**: Mock or real responses from called tools.
These allow you to simulate complex conversations and agent workflows.
### Attachments
For models that support multimodal input, you can attach files directly to user messages.
Supported attachments may include:
* Images
* PDFs
* Other supported file types
Attachments are only enabled when the selected model supports them.
### Drag-and-Drop Organization
Prompts can be reorganized easily using drag and drop in the sidebar.
You can move prompts:
* Between folders
* Back to the root level
## Session Management
Sessions store the state of your prompt experiments.
Each prompt maintains its **own session history**, allowing you to explore different approaches without losing previous work.
With sessions you can:
* Save specific conversation states
* Rename sessions for clarity
* Switch between past experiments
***
## Using prompts in production
To attach committed versions to **Chat Completions** or **Responses** requests through the gateway (HTTP headers, merging, and caching behavior), see the [Prompts plugin](/features/prompt-repository/prompts-plugin).
# Prompts plugin
Source: https://docs.getbifrost.ai/features/prompt-repository/prompts-plugin
Use committed prompt templates from the Prompt Repository on inference requests via HTTP headers or custom resolvers.
## Overview
The **Prompts** plugin connects the [Prompt Repository](/features/prompt-repository/playground) to inference. It loads committed prompt versions from the config store and **prepends** their messages to **Chat Completions** and **Responses** requests. It also **merges model parameters** from the stored version with the incoming request (request values take precedence).
**What it does:**
* Resolves which prompt and version to apply per request (default: HTTP headers).
* Injects the version’s message history **before** the client’s messages.
* Applies the version’s `model` parameters as defaults, then overrides with whatever the client sent for the same parameters.
***
## Prerequisites
* **Config store** with Prompt Repository tables (typically **PostgreSQL**). File-backed config alone does not store prompts.
* Prompts authored and **committed as versions** in the UI or via the `/api/prompt-repo/...` HTTP API (see `docs/openapi/openapi.yaml` in the repository).
* A **prompt ID** (UUID) for each prompt you reference at runtime. You can read it from the repository API or the playground.
***
## How it works
```mermaid theme={null}
flowchart TB
Client([Client]) --> Gateway[Bifrost HTTP]
Gateway --> PreHook["HTTP transport pre-hook:
copy x-bf-prompt-id / x-bf-prompt-version to context"]
PreHook --> PreLLM["PreLLM hook:
resolve version, merge params,
prepend template messages"]
PreLLM --> Provider[Provider]
```
1. **Transport (HTTP):** Incoming headers `x-bf-prompt-id` and `x-bf-prompt-version` are copied onto the Bifrost context (header name matching is case-insensitive).
2. **Resolve:** The plugin looks up the prompt and the requested version. If **`x-bf-prompt-version` is omitted**, the prompt’s **latest committed version** is used.
3. **Parameters:** Version `model` parameters are merged into the request; any field already set on the request wins.
4. **Messages:** Messages from the committed version are **prepended** to `messages` (chat) or `input` (responses). Your request body adds the user turn(s) after the template.
If the prompt ID is missing, the plugin does nothing and the request passes through unchanged.
***
## HTTP headers (gateway)
| Header | Required | Description |
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `x-bf-prompt-id` | Yes, to enable injection | UUID of the prompt in the repository. |
| `x-bf-prompt-version` | No | **Integer version number** (e.g. `3` for v3). If omitted, the **latest** committed version for that prompt is used. |
Invalid or unknown IDs / versions are logged as warnings; the request is **not** failed by the plugin (it proceeds without template injection).
***
## Example: Chat Completions
Use the same JSON body as a normal chat request. Only the headers select the template.
```bash theme={null}
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-bf-prompt-id: YOUR-PROMPT-UUID" \
-H "x-bf-vk: sk-bf-your-virtual-key" \
-d '{
"model": "openai/gpt-5.4",
"messages": [
{
"role": "user",
"content": "Tell me about Bifrost Gateway?"
}
]
}'
```
When you commit a version from the playground, the model parameters (temperature, max tokens, etc.) are saved with it. These parameters are merged into the outgoing request, with client-supplied values taking precedence.
In **Logs**, that run shows the full conversation: the committed **system** template, your **user** message from the request body, and the assistant reply. The log also displays the **Selected Prompt** name and version number for easy traceability.
The provider receives the merged model parameters from both the prompt version and the client request, with the messages from the committed version prepended before the client’s messages.
***
## Example: Responses API
```bash theme={null}
curl -X POST http://localhost:8080/v1/responses \
-H "Content-Type: application/json" \
-H "x-bf-prompt-id: YOUR-PROMPT-UUID" \
-H "x-bf-prompt-version: 4" \
-H "x-bf-vk: sk-bf-your-virtual-key" \
-d '{
"model": "openai/gpt-5-nano-2025-08-07",
"input": "What is Pale Blue Dot?"
}'
```
***
## Streaming
Streaming is controlled entirely by the client request. If you want streaming, set `"stream": true` in the request body. The plugin merges model parameters from the committed version (request values take precedence), but does **not** override the transport-level streaming mode.
***
## Cache and updates
The plugin keeps an in-memory cache of prompts and versions (loaded with a small number of store queries at startup). When you create, update, or delete prompts or versions through the **gateway APIs**, the server **reloads** that cache so new commits are visible without a full process restart.
***
## Go SDK and custom resolution
For embedded Bifrost (Go SDK), register the plugin with `prompts.Init` and a **config store** that implements the prompt tables API. The default resolver reads the same logical keys from `BifrostContext`:
* `prompts.PromptIDKey` (`x-bf-prompt-id`)
* `prompts.PromptVersionKey` (`x-bf-prompt-version`)
Set them on the context you pass to `ChatCompletion` / `Responses` if you are not going through the HTTP transport hooks.
For advanced routing (for example, choosing a prompt from governance metadata), implement `prompts.PromptResolver` and use **`prompts.InitWithResolver`**. The interface is:
```go theme={null}
type PromptResolver interface {
Resolve(ctx *schemas.BifrostContext, req *schemas.BifrostRequest) (promptID string, versionNumber int, err error)
}
```
Return an empty `promptID` to skip injection for a request. Return `versionNumber == 0` to use the prompt's **latest** committed version; any positive integer selects that specific version.
After injection, the plugin sets the following context keys (read by the logging plugin to populate log fields):
* `schemas.BifrostContextKeySelectedPromptID` - UUID of the applied prompt
* `schemas.BifrostContextKeySelectedPromptName` - Display name of the prompt
* `schemas.BifrostContextKeySelectedPromptVersion` - Version number as a string (e.g. `"3"`)
***
## Related
* [Playground](/features/prompt-repository/playground) - create folders, prompts, sessions, and committed versions.
* [Writing Go plugins](/plugins/writing-go-plugin) - plugin interfaces and lifecycle.
* Built-in plugin name in code: `prompts` (`github.com/maximhq/bifrost/plugins/prompts`).
# Retries & Fallbacks
Source: https://docs.getbifrost.ai/features/retries-and-fallbacks
Automatic retry with exponential backoff and provider failover. Retries handle transient errors within a provider; fallbacks switch to a different provider when all retries are exhausted.
## Overview
Bifrost provides two complementary layers of resilience:
* **Retries** - When a provider returns a transient server error (network issue, 5xx) or a per-key failure (`429` rate-limit, `401`/`403` auth, `402` billing), Bifrost automatically retries the same request against the same provider. Transient-server retries reuse the same key with exponential backoff; per-key failures rotate to a different API key from your pool. Backoff is skipped only when rotating away from a *permanent* per-key failure (`401`/`402`/`403`) where waiting offers nothing — for `429` rotations a backoff is still applied to let account-level quota windows slide.
* **Fallbacks** - When the primary provider fails after exhausting all retries, Bifrost moves on to the next provider in your fallback chain. Each fallback provider gets its own full retry budget.
Together, they let you build LLM-powered applications that stay up through rate limits, transient outages, and even full provider failures - with no changes required in your application code.
***
## Retries
### How retries work
When a request fails with a retryable error, Bifrost:
1. Classifies the failure as either a **per-key failure** (the credential / account is the problem — status `401`/`402`/`403`/`429`) or a **transient server failure** (the upstream is the problem — `5xx` / network / DNS).
2. On **per-key failures**, rotates to a different API key from the pool (if multiple keys are configured). Two sub-cases:
* **Permanent per-key failure** (`401`/`402`/`403`): mark the key dead for the remainder of the request and rotate immediately — **no backoff**, since waiting can't revive a bad credential.
* **Transient per-key failure** (`429` rate-limit): mark the key as used-this-cycle and rotate, but **still apply backoff** — providers often enforce account-level quotas shared across keys, so the new key may not have fresh capacity until the window slides.
3. On **transient server failures** (`5xx`, DNS, connection refused): reuse the same key and wait using **exponential backoff with jitter** before the next attempt.
4. Continues until the request succeeds, `max_retries` is exhausted, or every key is permanently dead (in which case Bifrost returns `502 upstream_credentials_exhausted` rather than the raw `4xx`, to make it clear the caller's Bifrost API key is fine — the configured provider credentials are not).
### Azure streaming errors before output
Azure Chat Completions and Responses streams can emit startup metadata before
reporting an error inside an HTTP `200` response. Bifrost buffers recognized
startup events so these errors can reach the existing retry and fallback logic.
* **Chat Completions:** empty-choice annotations, empty deltas, and assistant-role chunks.
* **Responses:** empty `response.created`, `response.in_progress`, `response.queued`,
and ping events, plus empty assistant-message and output-text-part startup events.
For example, an annotation followed by an assistant-role chunk and a rate-limit
error can trigger recovery. The error does not need to occur at a particular
chunk number. Retry eligibility, retry budgets, and fallback permissions still apply.
When output arrives, Bifrost replays the successful attempt's buffered events in
order and continues streaming. Buffered events from failed attempts are discarded.
Text, reasoning, tool activity, other output, terminal results, and unrecognized
events end startup buffering. Errors after that boundary remain stream errors.
A `content_filter` finish reason remains a terminal response.
Buffering also ends when startup metadata reaches 64 chunks or 256 KiB of
serialized data. Existing request deadlines and stream idle timeouts still apply.
Raw streaming passthrough and Responses retrieval/resumption are excluded.
See Microsoft's [Azure streaming examples](https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/content-streaming)
and [Responses event reference](https://learn.microsoft.com/en-us/rest/api/microsoft-foundry/azureopenai/responses).
### Backoff formula
Backoff applies to **same-key retries** (transient server 5xx / network errors) and to **`429` rate-limit rotations** (since account-level quotas can be shared across keys). It is skipped only when rotating away from a **permanent per-key failure** (`401`/`402`/`403`) to a genuinely different credential — a dead key gains nothing from waiting.
```
backoff = min(retry_backoff_initial × 2^attempt, retry_backoff_max) × jitter(0.8–1.2)
```
With the defaults of `retry_backoff_initial = 500ms` and `retry_backoff_max = 5000ms`:
| Attempt | Base backoff | With jitter (approx.) |
| ---------- | ---------------- | --------------------- |
| 1st retry | 500 ms | 400–600 ms |
| 2nd retry | 1000 ms | 800 ms–1.2 s |
| 3rd retry | 2000 ms | 1.6–2.4 s |
| 4th retry | 4000 ms | 3.2–4.8 s |
| 5th+ retry | 5000 ms (capped) | 4–5 s |
### What triggers a retry
| Condition | Retried? | Key rotation? | Backoff before next attempt? |
| ------------------------------------------------ | -------- | -------------------------------------------------------------- | ---------------------------------------------------- |
| Network error (DNS, connection refused) | Yes | No - same key reused | Yes |
| `5xx` server errors (500, 502, 503, 504) | Yes | No - same key reused | Yes |
| Rate limit (`429` or rate-limit message pattern) | Yes | Yes - rate-limited key may be retried later in the cycle | Yes - account-level quotas may be shared across keys |
| Auth failure (`401`, `403`) | Yes | Yes - failing key marked **permanently dead** for this request | No - waiting can't revive a bad credential |
| Billing failure (`402`) | Yes | Yes - failing key marked **permanently dead** for this request | No - waiting can't revive a bad credential |
| Request validation error (`400`/`404`/`422`/...) | No | - | - |
| Plugin-enforced block | No | - | - |
| Cancelled request | No | - | - |
### Configuring retries
Retries are configured per-provider in `network_config`. The defaults are `max_retries: 0` (no retries), `retry_backoff_initial: 500` ms, and `retry_backoff_max: 5000` ms.
Navigate to **Providers**, select a provider, and open the **Network Config** section.
Set:
* **Max Retries** - number of additional attempts after the first failure (e.g. `3`)
* **Retry Backoff Initial** - starting backoff in milliseconds (e.g. `500`)
* **Retry Backoff Max** - maximum backoff cap in milliseconds (e.g. `5000`)
```bash theme={null}
curl --location 'http://localhost:8080/api/providers' \
--header 'Content-Type: application/json' \
--data '{
"provider": "openai",
"keys": [
{
"name": "openai-key-1",
"value": "env.OPENAI_API_KEY",
"models": ["*"],
"weight": 1.0
}
],
"network_config": {
"max_retries": 3,
"retry_backoff_initial": 500,
"retry_backoff_max": 5000
}
}'
```
```go theme={null}
func (a *MyAccount) GetConfigForProvider(provider schemas.ModelProvider) (*schemas.ProviderConfig, error) {
switch provider {
case schemas.OpenAI:
return &schemas.ProviderConfig{
NetworkConfig: schemas.NetworkConfig{
MaxRetries: 3,
RetryBackoffInitial: 500 * time.Millisecond,
RetryBackoffMax: 5 * time.Second,
},
ConcurrencyAndBufferSize: schemas.DefaultConcurrencyAndBufferSize,
}, nil
}
return nil, fmt.Errorf("provider %s not supported", provider)
}
```
```json theme={null}
{
"providers": {
"openai": {
"keys": [
{ "name": "openai-key-1", "value": "env.OPENAI_KEY_1", "models": ["*"], "weight": 1.0 },
{ "name": "openai-key-2", "value": "env.OPENAI_KEY_2", "models": ["*"], "weight": 1.0 },
{ "name": "openai-key-3", "value": "env.OPENAI_KEY_3", "models": ["*"], "weight": 1.0 }
],
"network_config": {
"max_retries": 3,
"retry_backoff_initial": 500,
"retry_backoff_max": 5000
}
}
}
}
```
| Field | Type | Default | Description |
| ----------------------- | ------------ | ------- | ----------------------------------------------------- |
| `max_retries` | integer | `0` | Number of additional attempts after the first failure |
| `retry_backoff_initial` | integer (ms) | `500` | Starting backoff duration in milliseconds |
| `retry_backoff_max` | integer (ms) | `5000` | Maximum backoff cap in milliseconds |
### Key rotation on per-key failures
Key rotation on retries requires **v1.5.0-prerelease4 or later**. Rotation on auth (401/403) and billing (402) errors (in addition to rate limits) requires the retry-logic-enhancements release.
When you configure multiple API keys for a provider, Bifrost automatically rotates to a fresh key when the failure is bound to the credential rather than the request:
* **`429 Too Many Requests`** — this key is rate-limited; another may have spare quota.
* **`401 Unauthorized` / `403 Forbidden`** — bad / revoked key, or key lacks permission.
* **`402 Payment Required`** — billing issue on this key's account.
```json theme={null}
{
"providers": {
"openai": {
"keys": [
{ "name": "openai-key-1", "value": "env.OPENAI_KEY_1", "models": ["*"], "weight": 1.0 },
{ "name": "openai-key-2", "value": "env.OPENAI_KEY_2", "models": ["*"], "weight": 1.0 },
{ "name": "openai-key-3", "value": "env.OPENAI_KEY_3", "models": ["*"], "weight": 1.0 }
],
"network_config": {
"max_retries": 5
}
}
}
}
```
**Rate-limited keys** are tracked in a per-request `used` set. Once all keys in the pool have been tried, Bifrost resets that set and starts a fresh weighted round — a previously rate-limited key may have free quota by then. With 3 keys and `max_retries: 5`, Bifrost can cycle through all three keys twice before giving up.
**Auth and billing failures** (401/402/403) are different: the failing key is marked **permanently dead** for the remainder of the request and is never reset. A bad credential won't become valid by waiting. If every configured key ends up permanently dead, Bifrost returns `502 upstream_credentials_exhausted` and skips any remaining retries.
Key rotation on retries only applies when `max_retries > 0` and more than one key is configured for the provider. With a single key, all retries reuse that key (and a permanent per-key failure terminates immediately with `502`).
***
## Fallbacks
Fallbacks provide automatic failover to a different provider when the primary fails after exhausting all its retries. Each fallback is tried in order until one succeeds.
### How fallbacks work
1. **Primary attempt**: Tries your configured provider with its full retry budget
2. **Fallback decision**: If the primary fails (and the error is retryable at the provider level), Bifrost moves to the first fallback
3. **Sequential fallbacks**: Each fallback provider also gets its own full retry budget
4. **First success wins**: Returns the response from the first provider that succeeds
5. **All fail**: Returns the original error from the primary provider. Exception: if a plugin on a fallback provider sets `AllowFallbacks = false` on the error (e.g. a security or compliance plugin that should halt the chain regardless of remaining fallbacks), Bifrost stops immediately and returns that fallback's error rather than continuing to the next provider or returning the primary error.
Each fallback is treated as a completely fresh request - all configured plugins (semantic caching, governance, logging) run again for the fallback provider.
### Implementation
Pass a `fallbacks` array in the request body. Each entry specifies a `provider/model` string:
```bash theme={null}
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "Explain quantum computing in simple terms"
}
],
"fallbacks": [
"anthropic/claude-3-5-sonnet-20241022",
"bedrock/anthropic.claude-3-sonnet-20240229-v1:0"
],
"max_tokens": 1000,
"temperature": 0.7
}'
```
The response `extra_fields.provider` tells you which provider actually served the request:
```json theme={null}
{
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Quantum computing is like having a super-powered calculator..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 150,
"total_tokens": 162
},
"extra_fields": {
"provider": "anthropic",
"latency": 1.2
}
}
```
```go theme={null}
package main
import (
"context"
"fmt"
"github.com/maximhq/bifrost"
"github.com/maximhq/bifrost/core/schemas"
)
func chatWithFallbacks(client *bifrost.Bifrost) {
ctx := context.Background()
response, err := client.ChatCompletionRequest(
schemas.NewBifrostContext(ctx, schemas.NoDeadline),
&schemas.BifrostChatRequest{
Provider: schemas.OpenAI,
Model: "gpt-4o-mini",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{
ContentStr: bifrost.Ptr("Explain quantum computing in simple terms"),
},
},
},
// Fallback chain: OpenAI → Anthropic → Bedrock
Fallbacks: []schemas.Fallback{
{Provider: schemas.Anthropic, Model: "claude-3-5-sonnet-20241022"},
{Provider: schemas.Bedrock, Model: "anthropic.claude-3-sonnet-20240229-v1:0"},
},
Params: &schemas.ChatParameters{
MaxCompletionTokens: bifrost.Ptr(1000),
Temperature: bifrost.Ptr(0.7),
},
},
)
if err != nil {
fmt.Printf("All providers failed: %v\n", err)
return
}
fmt.Printf("Response from %s: %s\n",
response.ExtraFields.Provider,
*response.Choices[0].BifrostNonStreamResponseChoice.Message.Content.ContentStr)
}
```
***
## How retries and fallbacks work together
The two mechanisms form a nested resilience loop. Retries run inside each provider attempt; fallbacks run across providers once retries are exhausted.
```mermaid theme={null}
sequenceDiagram
participant App
participant Bifrost
participant Primary as Primary Provider
participant FB1 as Fallback 1
participant FB2 as Fallback 2
App->>Bifrost: Request (primary + fallbacks)
rect rgb(220, 235, 250)
note over Bifrost,Primary: Primary provider attempt (with retries)
Bifrost->>Primary: Attempt 1
Primary-->>Bifrost: 401 Unauthorized
note over Bifrost: Key marked dead, rotate (no backoff)
Bifrost->>Primary: Attempt 2 (different key)
Primary-->>Bifrost: 429 Rate Limit
note over Bifrost: Backoff + rotate key
Bifrost->>Primary: Attempt 3 (different key)
Primary-->>Bifrost: 503 Unavailable
note over Bifrost: Backoff (same key)
Bifrost->>Primary: Attempt 4
Primary-->>Bifrost: 503 Unavailable
note over Bifrost: max_retries exhausted
end
rect rgb(235, 250, 220)
note over Bifrost,FB1: Fallback 1 attempt (with its own retries)
Bifrost->>FB1: Attempt 1
FB1-->>Bifrost: 500 Server Error
note over Bifrost: Backoff
Bifrost->>FB1: Attempt 2
FB1-->>Bifrost: ✓ Success
end
Bifrost-->>App: Response (from Fallback 1)
```
**Key point:** each provider in the chain - primary and every fallback - gets its own full `max_retries` budget. A primary configured with `max_retries: 3` and two fallbacks each also configured with `max_retries: 3` means up to 12 total attempts before giving up.
The retry budget is set per-provider in `network_config`. If your fallback providers have different retry configurations, each will use their own settings.
***
## Auditing retry and fallback decisions
Every retry transition and every fallback transition is recorded on the request's **routing engine log trail** under the engine name `core`. This is the same per-request trail that plugins like `governance`, `loadbalancing`, `routing-rule`, and `model-catalog` write to when they make routing decisions — so the chain reads end-to-end: which engine picked the primary, what the primary failed with, what core retried with, and which fallback ultimately served the response.
Entries core emits:
| Phase | Level | Shape |
| -------------------------------------- | ----- | ----------------------------------------------------------------------------------------- |
| Primary failed, entering fallback loop | Info | `Primary / failed ( HTTP ); evaluating N configured fallback(s)` |
| Each fallback iteration | Info | `Trying fallback i/N: / (previous attempt failed: HTTP )` |
| Fallback skipped (no provider config) | Warn | `Fallback / skipped: missing provider config` |
| Fallback succeeded | Info | `Request served by fallback / (attempt i/N)` |
| Fallback halted by short-circuit | Error | `Fallback / failed ( HTTP ); halting further fallbacks` |
| All fallbacks exhausted | Error | `All N fallback(s) exhausted; returning primary error ( HTTP )` |
| Retry transition (rotated key) | Info | `Retry n/N for / (previous attempt failed: HTTP ; rotated key=