| name | aigw-contrib-add-translator |
| description | Add a new LLM provider translator to envoyproxy/ai-gateway — the most common contribution type |
| arguments | [{"name":"SourceSchema","description":"Input API schema (e.g., OpenAI, Anthropic)","required":true},{"name":"TargetSchema","description":"Output API schema (e.g., AWSBedrock, AzureOpenAI, GCPVertexAI)","required":true},{"name":"ProviderName","description":"Human-readable provider name (e.g., AWS Bedrock, Azure OpenAI)","required":true}] |
Add a New Translator
Add a new LLM provider translator to Envoy AI Gateway. This is the most common contribution type. A translator converts requests from one API schema (e.g., OpenAI) to another (e.g., AWS Bedrock) and translates responses back.
Before starting: Read aigw-contrib-go-style — performance patterns and JSON handling are the most common reasons translator PRs get rejected.
Phase 1: Understand the Interfaces
Study an existing translator to understand the pattern. Start with a simple one like openai_openai.go (passthrough), then look at a complex one like openai_awsbedrock.go.
Key interfaces your translator must implement:
- Request translation:
(sourceBody []byte) → (targetBody []byte, error)
- Response translation:
(targetBody []byte) → (sourceBody []byte, error)
- Streaming chunk translation:
(targetChunk []byte) → (sourceChunk []byte, error)
The translator is generic over [ReqT, RespT, RespChunkT] type parameters.
Phase 2: Create the Translator File
Create internal/extproc/translators/${SourceSchema}_${TargetSchema}.go:
package translators
import (
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/envoyproxy/ai-gateway/internal/json"
)
type ${sourceSchema}To${targetSchema}Translator struct {
}
func new${SourceSchema}To${TargetSchema}Translator() *${sourceSchema}To${targetSchema}Translator {
return &${sourceSchema}To${targetSchema}Translator{}
}
Key Implementation Rules
- Use
gjson to read JSON fields — never unmarshal to map[string]interface{}
- Use
sjson to mutate JSON bytes — never marshal from maps
- Use
internal/json for marshal/unmarshal — never encoding/json
- Pre-allocate byte buffers and reusable objects in the constructor
- No allocations inside loops — the translator runs in the hot path
Request Translation Example
func (t *${sourceSchema}To${targetSchema}Translator) translateRequest(
body []byte,
) (translated []byte, err error) {
model := gjson.GetBytes(body, "model").String()
if model == "" {
return nil, fmt.Errorf("missing model field in request")
}
translated = body
translated, err = sjson.SetBytes(translated, "modelId", model)
if err != nil {
return nil, fmt.Errorf("setting modelId: %w", err)
}
translated, err = sjson.DeleteBytes(translated, "model")
if err != nil {
return nil, fmt.Errorf("deleting model: %w", err)
}
return translated, nil
}
Streaming Response Translation
var (
sseDataPrefix = []byte("data: ")
sseLineEnd = []byte("\n\n")
sseDoneMessage = []byte("data: [DONE]\n\n")
)
func (t *${sourceSchema}To${targetSchema}Translator) translateStreamChunk(
chunk []byte,
) (out []byte, err error) {
if bytes.Equal(bytes.TrimSpace(chunk), []byte("[DONE]")) {
return sseDoneMessage, nil
}
out = append(out, sseDataPrefix...)
out = append(out, translatedChunk...)
out = append(out, sseLineEnd...)
return out, nil
}
Phase 3: Register the Translator
Register your translator in the EndpointSpec path-based lookup. The exact registration location depends on the current codebase structure — look for where existing translators are registered (typically in the router or upstream processor initialization).
The registration maps:
- API path (e.g.,
/v1/chat/completions) + source schema + target schema → your translator
Phase 4: Add Backend Auth (if new auth type)
If the target provider needs a new authentication method not already supported:
- Add auth type to
internal/extproc/backendauth/
- Update
api/v1alpha1/backend_security_policy_types.go (separate API PR)
- Update controller to handle the new auth type
Existing auth types: APIKey, AnthropicAPIKey, AzureAPIKey, AWSCredentials, AzureCredentials, GCPCredentials.
Phase 5: Update filterapi.Config
If the new translator introduces a new VersionedAPISchema value:
- Add the schema constant to
api/v1alpha1/filterapi/filterapi.go
- Update the controller to recognize the new schema
- This may be part of the API PR if it is type-only
Phase 6: Write Unit Tests
Create internal/extproc/translators/${SourceSchema}_${TargetSchema}_test.go:
package translators
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test${SourceSchema}To${TargetSchema}_TranslateRequest(t *testing.T) {
translator := new${SourceSchema}To${TargetSchema}Translator()
tests := []struct {
name string
input []byte
expOutput []byte
expErr string
}{
{
name: "valid chat completion",
input: []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}`),
expOutput: []byte(`{...target format...}`),
},
{
name: "missing model",
input: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
expErr: "missing model",
},
{
name: "with streaming option",
input: []byte(`{"model":"gpt-4o","stream":true,"messages":[...]}`),
expOutput: []byte(`{...target with streaming...}`),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output, err := translator.translateRequest(tc.input)
if tc.expErr != "" {
require.ErrorContains(t, err, tc.expErr)
return
}
require.NoError(t, err)
require.JSONEq(t, string(tc.expOutput), string(output))
})
}
}
func Test${SourceSchema}To${TargetSchema}_TranslateStreamChunk(t *testing.T) {
translator := new${SourceSchema}To${TargetSchema}Translator()
tests := []struct {
name string
chunk []byte
expOutput []byte
expErr string
}{
{
name: "normal data chunk",
chunk: []byte(`{...target streaming chunk...}`),
expOutput: []byte("data: {...source streaming chunk...}\n\n"),
},
{
name: "done sentinel",
chunk: []byte("[DONE]"),
expOutput: []byte("data: [DONE]\n\n"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output, err := translator.translateStreamChunk(tc.chunk)
if tc.expErr != "" {
require.ErrorContains(t, err, tc.expErr)
return
}
require.NoError(t, err)
require.Equal(t, string(tc.expOutput), string(output))
})
}
}
go-vcr for Real Provider Responses
For more realistic tests, use go-vcr to record real API interactions:
func TestWithRealProvider(t *testing.T) {
r, err := recorder.New("testdata/fixtures/${SourceSchema}-to-${TargetSchema}")
require.NoError(t, err)
defer r.Stop()
}
Phase 7: Write Data Plane Tests
Add tests in tests/data-plane/ that run your translator through a real Envoy:
func Test${SourceSchema}To${TargetSchema}_DataPlane(t *testing.T) {
}
Run: make test-data-plane
Checklist