Skip to content

Commit 27756a4

Browse files
authored
extension: add new llm-proxy extension (#278)
This pull request added a new llm-proxy extension which want to provide out-of-box feature set for AI traffic routing and observing. The users needn't to care which AI providers are used and the llm-proxy will detect it by default and then extract the necessary information and generate the stats and update the metadata. We will also support the custom extractor/API format in following PR. By the custom format, the extension could also easily be used for self-host inference service which have different API format with the public AI providers. This PR have supported: - model based routing. - stats for usage and latency. - metadata which could be used for logging. OpenAI and anthropic are auto detected and without any additional configuration. --------- Signed-off-by: wbpcode <wbphub@gmail.com>
1 parent 3ca404d commit 27756a4

18 files changed

Lines changed: 2781 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright Built On Envoy
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package llmproxy
7+
8+
import (
9+
"bytes"
10+
"encoding/json"
11+
"fmt"
12+
)
13+
14+
// anthropicRequest is the minimal subset of an Anthropic Messages API request body
15+
// needed to extract the model name and streaming flag.
16+
type anthropicRequest struct {
17+
Model string `json:"model"`
18+
Stream bool `json:"stream"`
19+
}
20+
21+
// anthropicUsage holds token-usage fields from an Anthropic response.
22+
type anthropicUsage struct {
23+
InputTokens uint32 `json:"input_tokens"`
24+
OutputTokens uint32 `json:"output_tokens"`
25+
}
26+
27+
// anthropicResponse is the minimal subset of an Anthropic Messages API response body.
28+
type anthropicResponse struct {
29+
Usage anthropicUsage `json:"usage"`
30+
}
31+
32+
// anthropicMessageStartData is the payload of an Anthropic "message_start" SSE event.
33+
type anthropicMessageStartData struct {
34+
Message struct {
35+
Usage anthropicUsage `json:"usage"`
36+
} `json:"message"`
37+
}
38+
39+
// anthropicMessageDeltaData is the payload of an Anthropic "message_delta" SSE event.
40+
type anthropicMessageDeltaData struct {
41+
Usage struct {
42+
OutputTokens uint32 `json:"output_tokens"`
43+
} `json:"usage"`
44+
}
45+
46+
// --- LLMRequest implementation ---
47+
48+
// anthropicLLMRequest implements LLMRequest for the Anthropic Messages API.
49+
type anthropicLLMRequest struct {
50+
model string
51+
stream bool
52+
}
53+
54+
func (r *anthropicLLMRequest) GetModel() string { return r.model }
55+
func (r *anthropicLLMRequest) IsStream() bool { return r.stream }
56+
57+
// parseAnthropicRequest parses an Anthropic Messages API request body and returns
58+
// an LLMRequest with the extracted model and stream fields.
59+
func parseAnthropicRequest(body []byte) (LLMRequest, error) {
60+
var req anthropicRequest
61+
if err := json.Unmarshal(body, &req); err != nil {
62+
return nil, err
63+
}
64+
return &anthropicLLMRequest{model: req.Model, stream: req.Stream}, nil
65+
}
66+
67+
// --- LLMResponse implementation ---
68+
69+
// anthropicLLMResponse implements LLMResponse for the Anthropic Messages API.
70+
type anthropicLLMResponse struct {
71+
usage LLMUsage
72+
}
73+
74+
func (r *anthropicLLMResponse) GetUsage() LLMUsage { return r.usage }
75+
76+
// parseAnthropicResponse parses an Anthropic Messages API response body and returns
77+
// an LLMResponse with the extracted token-usage information.
78+
func parseAnthropicResponse(body []byte) (LLMResponse, error) {
79+
var resp anthropicResponse
80+
if err := json.Unmarshal(body, &resp); err != nil {
81+
return nil, err
82+
}
83+
return &anthropicLLMResponse{usage: anthropicUsageToLLM(resp.Usage)}, nil
84+
}
85+
86+
// --- LLMResponseChunk implementation ---
87+
88+
// anthropicLLMResponseChunk implements LLMResponseChunk for the Anthropic streaming API.
89+
type anthropicLLMResponseChunk struct {
90+
usage LLMUsage
91+
}
92+
93+
func (c *anthropicLLMResponseChunk) GetUsage() LLMUsage { return c.usage }
94+
95+
// parseAnthropicChunk parses a single Anthropic SSE event and returns an
96+
// LLMResponseChunk containing any usage data carried by that event.
97+
// eventType is the value from the preceding "event:" SSE line.
98+
func parseAnthropicChunk(eventType string, data []byte) (anthropicLLMResponseChunk, error) {
99+
switch eventType {
100+
case "message_start":
101+
var msg anthropicMessageStartData
102+
if err := json.Unmarshal(data, &msg); err != nil {
103+
return anthropicLLMResponseChunk{}, err
104+
}
105+
return anthropicLLMResponseChunk{usage: anthropicUsageToLLM(msg.Message.Usage)}, nil
106+
107+
case "message_delta":
108+
var delta anthropicMessageDeltaData
109+
if err := json.Unmarshal(data, &delta); err != nil {
110+
return anthropicLLMResponseChunk{}, err
111+
}
112+
return anthropicLLMResponseChunk{usage: LLMUsage{OutputTokens: delta.Usage.OutputTokens}}, nil
113+
}
114+
return anthropicLLMResponseChunk{}, nil
115+
}
116+
117+
// anthropicUsageToLLM converts an anthropicUsage to an LLMUsage.
118+
// Returns the zero value when u is nil.
119+
func anthropicUsageToLLM(u anthropicUsage) LLMUsage {
120+
return LLMUsage{
121+
InputTokens: u.InputTokens,
122+
OutputTokens: u.OutputTokens,
123+
TotalTokens: u.InputTokens + u.OutputTokens,
124+
}
125+
}
126+
127+
// --- SSE accumulator ---
128+
129+
var (
130+
anthropicSSEEventPrefix = []byte("event: ")
131+
anthropicSSEDataPrefix = []byte("data: ")
132+
)
133+
134+
// anthropicSSEParser accumulates usage information from an Anthropic streaming SSE response.
135+
// It consumes body chunks as they arrive and produces an LLMResponse when finished.
136+
type anthropicSSEParser struct {
137+
buf []byte
138+
done bool
139+
inputTokens uint32
140+
outputTokens uint32
141+
// currentEvent tracks the most recently seen "event:" value so that the
142+
// following "data:" line can be routed to the correct handler.
143+
currentEvent string
144+
}
145+
146+
func newAnthropicSSEParser() *anthropicSSEParser {
147+
return &anthropicSSEParser{}
148+
}
149+
150+
func (a *anthropicSSEParser) Feed(data []byte) error {
151+
if a.done {
152+
return nil
153+
}
154+
a.buf = append(a.buf, data...)
155+
return a.parseEvents()
156+
}
157+
158+
func (a *anthropicSSEParser) parseEvents() error {
159+
for {
160+
idx := bytes.IndexByte(a.buf, '\n')
161+
if idx < 0 {
162+
return nil
163+
}
164+
line := bytes.TrimSpace(a.buf[:idx])
165+
a.buf = a.buf[idx+1:]
166+
167+
if bytes.HasPrefix(line, anthropicSSEEventPrefix) {
168+
a.currentEvent = string(bytes.TrimPrefix(line, anthropicSSEEventPrefix))
169+
continue
170+
}
171+
if bytes.HasPrefix(line, anthropicSSEDataPrefix) {
172+
payload := bytes.TrimPrefix(line, anthropicSSEDataPrefix)
173+
if err := a.processEvent(a.currentEvent, payload); err != nil {
174+
return err
175+
}
176+
a.currentEvent = ""
177+
}
178+
}
179+
}
180+
181+
func (a *anthropicSSEParser) processEvent(eventType string, data []byte) error {
182+
if eventType == "message_stop" {
183+
a.done = true
184+
return nil
185+
}
186+
chunk, err := parseAnthropicChunk(eventType, data)
187+
if err != nil {
188+
return fmt.Errorf("llm-proxy: failed to parse Anthropic SSE event %q: %w", eventType, err)
189+
}
190+
if u := chunk.GetUsage(); u != (LLMUsage{}) {
191+
if u.InputTokens > 0 {
192+
a.inputTokens = u.InputTokens
193+
}
194+
if u.OutputTokens > 0 {
195+
a.outputTokens = u.OutputTokens
196+
}
197+
}
198+
return nil
199+
}
200+
201+
func (a *anthropicSSEParser) Finish() (LLMResponse, error) {
202+
return &anthropicLLMResponse{usage: LLMUsage{
203+
InputTokens: a.inputTokens,
204+
OutputTokens: a.outputTokens,
205+
TotalTokens: a.inputTokens + a.outputTokens,
206+
}}, nil
207+
}
208+
209+
// anthropicFactory implements LLMFactory for the Anthropic Messages API.
210+
type anthropicFactory struct{}
211+
212+
func (f *anthropicFactory) ParseRequest(body []byte) (LLMRequest, error) {
213+
return parseAnthropicRequest(body)
214+
}
215+
216+
func (f *anthropicFactory) ParseResponse(body []byte) (LLMResponse, error) {
217+
return parseAnthropicResponse(body)
218+
}
219+
220+
func (f *anthropicFactory) NewSSEParser() SSEParser { return newAnthropicSSEParser() }
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright Built On Envoy
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package llmproxy
7+
8+
import (
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// --- parseAnthropicRequest ---
15+
16+
func TestParseAnthropicRequest_Basic(t *testing.T) {
17+
body := []byte(`{"model":"claude-3-5-sonnet-20241022","stream":false}`)
18+
req, err := parseAnthropicRequest(body)
19+
require.NoError(t, err)
20+
require.Equal(t, "claude-3-5-sonnet-20241022", req.GetModel())
21+
require.False(t, req.IsStream())
22+
}
23+
24+
func TestParseAnthropicRequest_Stream(t *testing.T) {
25+
body := []byte(`{"model":"claude-3-haiku-20240307","stream":true}`)
26+
req, err := parseAnthropicRequest(body)
27+
require.NoError(t, err)
28+
require.Equal(t, "claude-3-haiku-20240307", req.GetModel())
29+
require.True(t, req.IsStream())
30+
}
31+
32+
func TestParseAnthropicRequest_MissingFields(t *testing.T) {
33+
body := []byte(`{}`)
34+
req, err := parseAnthropicRequest(body)
35+
require.NoError(t, err)
36+
require.Empty(t, req.GetModel())
37+
require.False(t, req.IsStream())
38+
}
39+
40+
func TestParseAnthropicRequest_InvalidJSON(t *testing.T) {
41+
_, err := parseAnthropicRequest([]byte(`{invalid`))
42+
require.Error(t, err)
43+
}
44+
45+
// --- parseAnthropicResponse ---
46+
47+
func TestParseAnthropicResponse_WithUsage(t *testing.T) {
48+
body := []byte(`{
49+
"id":"msg_01","type":"message","role":"assistant","content":[],
50+
"usage":{"input_tokens":12,"output_tokens":34}
51+
}`)
52+
resp, err := parseAnthropicResponse(body)
53+
require.NoError(t, err)
54+
usage := resp.GetUsage()
55+
require.Equal(t, uint32(12), usage.InputTokens)
56+
require.Equal(t, uint32(34), usage.OutputTokens)
57+
require.Equal(t, uint32(46), usage.TotalTokens)
58+
}
59+
60+
func TestParseAnthropicResponse_NoUsage(t *testing.T) {
61+
body := []byte(`{"id":"msg_01","type":"message"}`)
62+
resp, err := parseAnthropicResponse(body)
63+
require.NoError(t, err)
64+
require.Equal(t, LLMUsage{}, resp.GetUsage())
65+
}
66+
67+
func TestParseAnthropicResponse_InvalidJSON(t *testing.T) {
68+
_, err := parseAnthropicResponse([]byte(`bad`))
69+
require.Error(t, err)
70+
}
71+
72+
// --- parseAnthropicChunk ---
73+
74+
func TestParseAnthropicChunk_MessageStart(t *testing.T) {
75+
data := []byte(`{"type":"message_start","message":{"usage":{"input_tokens":20,"output_tokens":0}}}`)
76+
chunk, err := parseAnthropicChunk("message_start", data)
77+
require.NoError(t, err)
78+
usage := chunk.GetUsage()
79+
require.Equal(t, uint32(20), usage.InputTokens)
80+
require.Equal(t, uint32(0), usage.OutputTokens)
81+
require.Equal(t, uint32(20), usage.TotalTokens)
82+
}
83+
84+
func TestParseAnthropicChunk_MessageDelta(t *testing.T) {
85+
data := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}`)
86+
chunk, err := parseAnthropicChunk("message_delta", data)
87+
require.NoError(t, err)
88+
usage := chunk.GetUsage()
89+
require.Equal(t, uint32(0), usage.InputTokens)
90+
require.Equal(t, uint32(15), usage.OutputTokens)
91+
}
92+
93+
func TestParseAnthropicChunk_ContentBlockDelta_NoUsage(t *testing.T) {
94+
data := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`)
95+
chunk, err := parseAnthropicChunk("content_block_delta", data)
96+
require.NoError(t, err)
97+
require.Equal(t, LLMUsage{}, chunk.GetUsage())
98+
}
99+
100+
func TestParseAnthropicChunk_UnknownEvent_NoUsage(t *testing.T) {
101+
chunk, err := parseAnthropicChunk("ping", []byte(`{}`))
102+
require.NoError(t, err)
103+
require.Equal(t, LLMUsage{}, chunk.GetUsage())
104+
}
105+
106+
func TestParseAnthropicChunk_MessageStart_InvalidJSON(t *testing.T) {
107+
_, err := parseAnthropicChunk("message_start", []byte(`bad`))
108+
require.Error(t, err)
109+
}
110+
111+
// --- anthropicSSEParser ---
112+
113+
func TestAnthropicSSEParser_FullStream(t *testing.T) {
114+
acc := newAnthropicSSEParser()
115+
116+
events := "" +
117+
"event: message_start\n" +
118+
"data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":25,\"output_tokens\":0}}}\n\n" +
119+
"event: content_block_start\n" +
120+
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
121+
"event: content_block_delta\n" +
122+
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n" +
123+
"event: message_delta\n" +
124+
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":10}}\n\n" +
125+
"event: message_stop\n" +
126+
"data: {\"type\":\"message_stop\"}\n\n"
127+
128+
require.NoError(t, acc.Feed([]byte(events)))
129+
resp, err := acc.Finish()
130+
require.NoError(t, err)
131+
usage := resp.GetUsage()
132+
require.Equal(t, uint32(25), usage.InputTokens)
133+
require.Equal(t, uint32(10), usage.OutputTokens)
134+
require.Equal(t, uint32(35), usage.TotalTokens)
135+
}
136+
137+
func TestAnthropicSSEParser_NoUsageEvents(t *testing.T) {
138+
acc := newAnthropicSSEParser()
139+
require.NoError(t, acc.Feed([]byte("event: ping\ndata: {}\n\n")))
140+
resp, err := acc.Finish()
141+
require.NoError(t, err)
142+
require.Equal(t, LLMUsage{}, resp.GetUsage())
143+
}
144+
145+
func TestAnthropicSSEParser_ChunkedFeed(t *testing.T) {
146+
acc := newAnthropicSSEParser()
147+
raw := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n" +
148+
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{},\"usage\":{\"output_tokens\":3}}\n\n"
149+
for _, b := range []byte(raw) {
150+
require.NoError(t, acc.Feed([]byte{b}))
151+
}
152+
resp, err := acc.Finish()
153+
require.NoError(t, err)
154+
usage := resp.GetUsage()
155+
require.Equal(t, uint32(7), usage.InputTokens)
156+
require.Equal(t, uint32(3), usage.OutputTokens)
157+
require.Equal(t, uint32(10), usage.TotalTokens)
158+
}
159+
160+
func TestAnthropicSSEParser_IgnoresAfterDone(t *testing.T) {
161+
acc := newAnthropicSSEParser()
162+
require.NoError(t, acc.Feed([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")))
163+
// Feeding more data after message_stop should be ignored.
164+
require.NoError(t, acc.Feed([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":99,\"output_tokens\":0}}}\n\n")))
165+
resp, err := acc.Finish()
166+
require.NoError(t, err)
167+
require.Equal(t, LLMUsage{}, resp.GetUsage())
168+
}

0 commit comments

Comments
 (0)