Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d791c4c
Add OpenAI to Anthropic (direct API) translator
ajac-zero May 5, 2026
099ab90
translator: share Anthropic response redaction
ajac-zero May 11, 2026
0421851
Merge branch 'envoyproxy:main' into add-openai-to-anthropic-translator
ajac-zero May 11, 2026
fe39173
Merge remote-tracking branch 'origin/add-openai-to-anthropic-translat…
ajac-zero May 11, 2026
131215a
translator: clean up Anthropic comments
ajac-zero May 11, 2026
b476d64
translator: merge main into add-openai-to-anthropic-translator, resol…
ajac-zero Jun 11, 2026
7fa33e2
translator: do not forward Anthropic thinking config to OpenAI backends
ajac-zero Jun 11, 2026
8ecf5da
translator: merge main (8671f195) into add-openai-to-anthropic-transl…
ajac-zero Jun 11, 2026
2bae265
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jun 17, 2026
cac0361
fix: refine Anthropic chat translation plumbing
ajac-zero Jun 17, 2026
3743023
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jun 17, 2026
d66d4c5
fix: validate Anthropic OpenAI max tokens
ajac-zero Jun 17, 2026
a603cba
test: cover Anthropic translator branches
ajac-zero Jun 18, 2026
443f4c6
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jun 18, 2026
fa8768b
Merge remote-tracking branch 'origin/main' into add-openai-to-anthrop…
Jul 13, 2026
9f48212
fix: update newAnthropicStreamParser test call sites after merge from…
Jul 13, 2026
12fc07e
translator: reuse GCP path for direct Anthropic
Jul 13, 2026
f769b6f
translator: cover direct Anthropic integration
Jul 13, 2026
29dd17d
translator: clarify omitted Anthropic max tokens
Jul 13, 2026
babd41f
Merge upstream main into add-openai-to-anthropic-translator
Jul 13, 2026
752afc9
docs: format Anthropic provider support
Jul 13, 2026
adf8a38
test: preserve Anthropic missing-token coverage
Jul 13, 2026
6e6492a
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jul 13, 2026
d35e134
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jul 14, 2026
453c856
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jul 17, 2026
5bc63ab
Merge branch 'main' into add-openai-to-anthropic-translator
ajac-zero Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/endpointspec/endpointspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ func (ChatCompletionsEndpointSpec) GetTranslator(schema filterapi.VersionedAPISc
return translator.NewChatCompletionOpenAIToGCPVertexAITranslator(modelNameOverride), nil
case filterapi.APISchemaGCPAnthropic:
return translator.NewChatCompletionOpenAIToGCPAnthropicTranslator(schema.Version, modelNameOverride), nil
case filterapi.APISchemaAnthropic:
return translator.NewChatCompletionOpenAIToAnthropicTranslator(schema.AnthropicPrefix(), modelNameOverride), nil
default:
return nil, fmt.Errorf("unsupported API schema: backend=%s", schema)
}
Expand Down
1 change: 1 addition & 0 deletions internal/endpointspec/endpointspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func TestChatCompletionsEndpointSpec_GetTranslator(t *testing.T) {
{Name: filterapi.APISchemaAzureOpenAI, Version: "2024-02-01"},
{Name: filterapi.APISchemaGCPVertexAI},
{Name: filterapi.APISchemaGCPAnthropic, Version: "2024-05-01"},
{Name: filterapi.APISchemaAnthropic, Prefix: "v1"},
}

for _, schema := range supported {
Expand Down
41 changes: 26 additions & 15 deletions internal/translator/anthropic_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,13 +681,13 @@ func mapReasoningEffortToOutputConfigEffort(reasonEffort openai.ReasoningEffort)
}
}

// buildAnthropicParams is a helper function that translates an OpenAI request
// into the parameter struct required by the Anthropic SDK.
// buildAnthropicParams translates an OpenAI request into Anthropic SDK parameters.
// It leaves Model unset because cloud providers identify the model in the request path.
// The apiSchema parameter indicates the backend API schema (e.g., "AWSAnthropic", "GCPAnthropic").
func buildAnthropicParams(openAIReq *openai.ChatCompletionRequest, apiSchema string, modelNameOverride internalapi.ModelNameOverride) (params *anthropic.MessageNewParams, err error) {
// 1. Handle simple parameters.
// max_tokens is required by the Anthropic API but optional in the OpenAI API.
// If not set, pass 0 and let the Anthropic API reject the request.
// If not set, preserve the zero value for the provider to interpret.
var maxTokensVal int64
if maxTokens := cmp.Or(openAIReq.MaxCompletionTokens, openAIReq.MaxTokens); maxTokens != nil {
maxTokensVal = *maxTokens
Expand Down Expand Up @@ -811,15 +811,17 @@ type streamingToolCall struct {
// anthropicStreamParser manages the stateful translation of an Anthropic SSE stream
// to an OpenAI-compatible SSE stream.
type anthropicStreamParser struct {
buffer bytes.Buffer
activeMessageID string
activeToolCalls map[int64]*streamingToolCall
toolIndex int64
tokenUsage metrics.TokenUsage
stopReason anthropic.StopReason
requestModel internalapi.RequestModel
sentFirstChunk bool
created openai.JSONUNIXTime
buffer bytes.Buffer
activeMessageID string
activeToolCalls map[int64]*streamingToolCall
toolIndex int64
tokenUsage metrics.TokenUsage
stopReason anthropic.StopReason
requestModel internalapi.RequestModel
responseModel internalapi.ResponseModel
useResponseModel bool
sentFirstChunk bool
created openai.JSONUNIXTime
}

// newAnthropicStreamParser creates a new parser for a streaming request.
Expand All @@ -832,6 +834,13 @@ func newAnthropicStreamParser(requestModel string) *anthropicStreamParser {
}
}

func (p *anthropicStreamParser) model() string {
if p.useResponseModel && p.responseModel != "" {
return p.responseModel
}
return p.requestModel
}

func (p *anthropicStreamParser) writeChunk(eventBlock []byte, buf *[]byte) error {
chunk, err := p.parseAndHandleEvent(eventBlock)
if err != nil {
Expand Down Expand Up @@ -914,7 +923,7 @@ func (p *anthropicStreamParser) Process(body io.Reader, endOfStream bool, span t
) {
newBody = make([]byte, 0)
_ = span // TODO: add support for streaming chunks in tracing.
responseModel = p.requestModel
responseModel = p.model()
if _, err = p.buffer.ReadFrom(body); err != nil {
err = fmt.Errorf("failed to read from stream body: %w", err)
return
Expand Down Expand Up @@ -968,7 +977,7 @@ func (p *anthropicStreamParser) Process(body io.Reader, endOfStream bool, span t
ReasoningTokens: int(reasoningTokens),
},
},
Model: p.requestModel,
Model: p.model(),
}

// Add active tool calls to the final chunk.
Expand Down Expand Up @@ -1006,6 +1015,7 @@ func (p *anthropicStreamParser) Process(body io.Reader, endOfStream bool, span t
newBody = append(newBody, '\n', '\n')
}
tokenUsage = p.tokenUsage
responseModel = p.model()
return
}

Expand Down Expand Up @@ -1040,6 +1050,7 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat
return nil, fmt.Errorf("unmarshal message_start: %w", err)
}
p.activeMessageID = event.Message.ID
p.responseModel = event.Message.Model
p.created = openai.JSONUNIXTime(time.Now())
u := event.Message.Usage
usage := metrics.ExtractTokenUsageFromExplicitCaching(
Expand Down Expand Up @@ -1251,7 +1262,7 @@ func (p *anthropicStreamParser) constructOpenAIChatCompletionChunk(delta openai.
FinishReason: finishReason,
},
},
Model: p.requestModel,
Model: p.model(),
}
}

Expand Down
95 changes: 95 additions & 0 deletions internal/translator/openai_anthropic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package translator

import (
"io"
"path"
"strconv"
"strings"

"github.com/tidwall/sjson"

"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
"github.com/envoyproxy/ai-gateway/internal/json"
)

const (
anthropicBackendError = "AnthropicBackendError"
anthropicDefaultVersion = "2023-06-01"
anthropicVersionHeaderName = "anthropic-version"
)

// NewChatCompletionOpenAIToAnthropicTranslator implements [Factory] for direct Anthropic translation.
func NewChatCompletionOpenAIToAnthropicTranslator(prefix string, modelNameOverride internalapi.ModelNameOverride) OpenAIChatCompletionTranslator {
return &openAIToAnthropicTranslatorV1ChatCompletion{
openAIToGCPAnthropicTranslatorV1ChatCompletion: openAIToGCPAnthropicTranslatorV1ChatCompletion{
modelNameOverride: modelNameOverride,
},
path: path.Join("/", prefix, "messages"),
}
}

// openAIToAnthropicTranslatorV1ChatCompletion reuses the GCP Anthropic
// response translation; direct Anthropic only differs in request construction.
type openAIToAnthropicTranslatorV1ChatCompletion struct {
openAIToGCPAnthropicTranslatorV1ChatCompletion
path string
}

// RequestBody implements [OpenAIChatCompletionTranslator.RequestBody].
func (o *openAIToAnthropicTranslatorV1ChatCompletion) RequestBody(_ []byte, openAIReq *openai.ChatCompletionRequest, _ bool) (
newHeaders []internalapi.Header, newBody []byte, err error,
) {
params, err := buildAnthropicParams(openAIReq, "Anthropic", o.modelNameOverride)
if err != nil {
return
}

o.requestModel = openAIReq.Model
if o.modelNameOverride != "" {
o.requestModel = o.modelNameOverride
}
params.Model = o.requestModel

newBody, err = json.Marshal(params)
if err != nil {
return
}
if openAIReq.Stream {
newBody, err = sjson.SetBytes(newBody, "stream", true)
if err != nil {
return
}
o.streamParser = newAnthropicStreamParser(o.requestModel)
o.streamParser.useResponseModel = true
}

newHeaders = []internalapi.Header{
{pathHeaderName, o.path},
{anthropicVersionHeaderName, anthropicDefaultVersion},
{contentLengthHeaderName, strconv.Itoa(len(newBody))},
}
return
}

// ResponseError uses the GCP Anthropic error envelope with a direct-provider fallback type.
func (o *openAIToAnthropicTranslatorV1ChatCompletion) ResponseError(respHeaders map[string]string, body io.Reader) (
newHeaders []internalapi.Header, newBody []byte, err error,
) {
newHeaders, newBody, err = o.openAIToGCPAnthropicTranslatorV1ChatCompletion.ResponseError(respHeaders, body)
if err == nil && !strings.Contains(respHeaders[contentTypeHeaderName], jsonContentType) {
newBody, err = sjson.SetBytes(newBody, "error.type", anthropicBackendError)
if err == nil {
newHeaders = []internalapi.Header{
{contentTypeHeaderName, jsonContentType},
{contentLengthHeaderName, strconv.Itoa(len(newBody))},
}
}
}
return
}
90 changes: 90 additions & 0 deletions internal/translator/openai_anthropic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package translator

import (
"bytes"
"testing"

"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"k8s.io/utils/ptr"

"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
)

func TestOpenAIToAnthropicTranslator_RequestBody(t *testing.T) {
req := &openai.ChatCompletionRequest{
Model: "claude-requested",
MaxTokens: ptr.To(int64(100)),
Messages: []openai.ChatCompletionMessageParamUnion{{
OfUser: &openai.ChatCompletionUserMessageParam{
Role: openai.ChatMessageRoleUser,
Content: openai.StringOrUserRoleContentUnion{Value: "Hello"},
},
}},
}

tr := NewChatCompletionOpenAIToAnthropicTranslator("gateway/v1", "claude-override")
headers, body, err := tr.RequestBody(nil, req, false)
require.NoError(t, err)
require.Contains(t, headers, internalapi.Header{pathHeaderName, "/gateway/v1/messages"})
require.Contains(t, headers, internalapi.Header{anthropicVersionHeaderName, anthropicDefaultVersion})
require.Equal(t, "claude-override", gjson.GetBytes(body, "model").String())
require.False(t, gjson.GetBytes(body, anthropicVersionKey).Exists())

req.Stream = true
_, body, err = tr.RequestBody(nil, req, false)
require.NoError(t, err)
require.True(t, gjson.GetBytes(body, "stream").Bool())
}

func TestOpenAIToAnthropicTranslator_UsesResponseModel(t *testing.T) {
req := &openai.ChatCompletionRequest{
Model: "claude-requested",
MaxTokens: ptr.To(int64(100)),
Messages: []openai.ChatCompletionMessageParamUnion{{
OfUser: &openai.ChatCompletionUserMessageParam{
Role: openai.ChatMessageRoleUser,
Content: openai.StringOrUserRoleContentUnion{Value: "Hello"},
},
}},
}

tr := NewChatCompletionOpenAIToAnthropicTranslator("v1", "")
_, _, err := tr.RequestBody(nil, req, false)
require.NoError(t, err)

response := `{"id":"msg_1","type":"message","role":"assistant","model":"claude-executed","content":[{"type":"text","text":"Hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
_, body, _, model, err := tr.ResponseBody(nil, bytes.NewBufferString(response), true, nil)
require.NoError(t, err)
require.Equal(t, "claude-executed", model)
require.Equal(t, "claude-executed", gjson.GetBytes(body, "model").String())
}

func TestOpenAIToAnthropicTranslator_StreamUsesResponseModel(t *testing.T) {
req := &openai.ChatCompletionRequest{
Model: "claude-requested",
Stream: true,
MaxTokens: ptr.To(int64(100)),
}
tr := NewChatCompletionOpenAIToAnthropicTranslator("v1", "")
_, _, err := tr.RequestBody(nil, req, false)
require.NoError(t, err)

event := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-executed\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n"
_, _, _, model, err := tr.ResponseBody(nil, bytes.NewBufferString(event), false, nil)
require.NoError(t, err)
require.Equal(t, "claude-executed", model)
}

func TestOpenAIToAnthropicTranslator_RawError(t *testing.T) {
tr := NewChatCompletionOpenAIToAnthropicTranslator("v1", "")
_, body, err := tr.ResponseError(map[string]string{statusHeaderName: "503"}, bytes.NewBufferString("unavailable"))
require.NoError(t, err)
require.Equal(t, anthropicBackendError, gjson.GetBytes(body, "error.type").String())
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The Envoy AI Gateway acts as a proxy that accepts OpenAI-compatible and Anthropi
- Azure OpenAI (with automatic translation)
- GCP VertexAI (with automatic translation)
- GCP Anthropic (with automatic translation)
- Anthropic (with automatic translation)
- Any OpenAI-compatible provider (Groq, Together AI, Mistral, Tetrate Agent Router Service, etc.)

**Example:**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Below is a table of currently supported providers and their respective configura
| [Tetrate Agent Router Service (TARS)](https://router.tetrate.ai/) | `{"name":"OpenAI","prefix":"/v1"}` | [API Key] | ✅ | |
| [SambaNova](https://docs.sambanova.ai/sambastudio/latest/open-ai-api.html) | `{"name":"OpenAI","prefix":"/v1"}` | [API Key] | ✅ | |
| Self-hosted-models | `{"name":"OpenAI","prefix":"/v1"}` | N/A | ⚠️ | Depending on the API schema spoken by self-hosted servers. For example, [vLLM] speaks the OpenAI format. Also, API Key auth can be configured as well. |
| [Anthropic](https://docs.claude.com/en/home) | `{"name":"Anthropic"}` | [Anthropic API Key] | ✅ | Support only Native Anthropic messages endpoint |
| [Anthropic](https://docs.claude.com/en/home) | `{"name":"Anthropic"}` | [Anthropic API Key] | ✅ | Native Anthropic Messages and OpenAI-compatible Chat Completions endpoints |

[AIServiceBackend]: api/api.mdx#aiservicebackendspec
[BackendSecurityPolicy]: api/api.mdx#backendsecuritypolicyspec
Expand Down
1 change: 1 addition & 0 deletions site/docs/getting-started/connect-providers/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ See the [Basic Usage](../basic-usage.md) page for instructions.
curl -H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [
{
"role": "user",
Expand Down
Loading