Skip to content

Commit f6fa684

Browse files
authored
feat: record user agent and best effort client (#158)
Adds raw user agent value and heuristically guessed client name to interception recording. Fixes: #31
1 parent 9d5df2e commit f6fa684

6 files changed

Lines changed: 198 additions & 13 deletions

File tree

bridge.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ import (
2424
"go.opentelemetry.io/otel/trace"
2525
)
2626

27+
const (
28+
// The duration after which an async recording will be aborted.
29+
recordingTimeout = time.Second * 5
30+
31+
ClientClaude = "Claude Code"
32+
ClientCodex = "Codex"
33+
ClientZed = "Zed"
34+
ClientCopilotVSC = "GitHub Copilot (VS Code)"
35+
ClientCopilotCLI = "GitHub Copilot (CLI)"
36+
ClientKilo = "Kilo Code"
37+
ClientRoo = "Roo Code"
38+
ClientCursor = "Cursor"
39+
ClientUnknown = "Unknown"
40+
)
41+
2742
// RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs;
2843
// specifically, OpenAI's & Anthropic's at present.
2944
// RequestBridge intercepts requests to - and responses from - these upstream services to provide
@@ -52,9 +67,6 @@ type RequestBridge struct {
5267

5368
var _ http.Handler = &RequestBridge{}
5469

55-
// The duration after which an async recording will be aborted.
56-
const recordingTimeout = time.Second * 5
57-
5870
// NewRequestBridge creates a new *[RequestBridge] and registers the HTTP routes defined by the given providers.
5971
// Any routes which are requested but not registered will be reverse-proxied to the upstream service.
6072
//
@@ -186,11 +198,13 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC
186198
interceptor.Setup(logger, asyncRecorder, mcpProxy)
187199

188200
if err := rec.RecordInterception(ctx, &recorder.InterceptionRecord{
201+
Client: guessClient(r),
189202
ID: interceptor.ID().String(),
190-
Metadata: actor.Metadata,
191203
InitiatorID: actor.ID,
192-
Provider: p.Name(),
204+
Metadata: actor.Metadata,
193205
Model: interceptor.Model(),
206+
Provider: p.Name(),
207+
UserAgent: r.UserAgent(),
194208
}); err != nil {
195209
span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err))
196210
logger.Warn(ctx, "failed to record interception", slog.Error(err))
@@ -318,3 +332,33 @@ func mergeContexts(base, other context.Context) context.Context {
318332
}()
319333
return ctx
320334
}
335+
336+
// guessClient attempts to guess the client application from the request headers.
337+
// Not all clients set proper user agent headers, so this is a best-effort approach.
338+
// Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101.
339+
func guessClient(r *http.Request) string {
340+
userAgent := strings.ToLower(r.UserAgent())
341+
originator := r.Header.Get("originator")
342+
343+
switch {
344+
case strings.HasPrefix(userAgent, "claude"):
345+
return ClientClaude
346+
case strings.HasPrefix(userAgent, "codex"):
347+
return ClientCodex
348+
case strings.HasPrefix(userAgent, "zed/"):
349+
return ClientZed
350+
case strings.HasPrefix(userAgent, "githubcopilotchat/"):
351+
return ClientCopilotVSC
352+
case strings.HasPrefix(userAgent, "copilot/"):
353+
return ClientCopilotCLI
354+
case strings.HasPrefix(userAgent, "kilo-code/") || originator == "kilo-code":
355+
return ClientKilo
356+
case strings.HasPrefix(userAgent, "roo-code/") || originator == "roo-code":
357+
return ClientRoo
358+
case strings.HasPrefix(userAgent, "copilot"):
359+
return ClientCursor
360+
case r.Header.Get("x-cursor-client-version") != "":
361+
return ClientCursor
362+
}
363+
return ClientUnknown
364+
}

bridge_integration_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,8 @@ func TestSimple(t *testing.T) {
638638
getResponseIDFunc func(streaming bool, resp *http.Response) (string, error)
639639
createRequest func(*testing.T, string, []byte) *http.Request
640640
expectedMsgID string
641+
userAgent string
642+
expectedClient string
641643
}{
642644
{
643645
name: config.ProviderAnthropic,
@@ -648,6 +650,8 @@ func TestSimple(t *testing.T) {
648650
getResponseIDFunc: getAnthropicResponseID,
649651
createRequest: createAnthropicMessagesReq,
650652
expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn",
653+
userAgent: "claude-cli/2.0.67 (external, cli)",
654+
expectedClient: aibridge.ClientClaude,
651655
},
652656
{
653657
name: config.ProviderOpenAI,
@@ -658,6 +662,8 @@ func TestSimple(t *testing.T) {
658662
getResponseIDFunc: getOpenAIResponseID,
659663
createRequest: createOpenAIChatCompletionsReq,
660664
expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N",
665+
userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64)",
666+
expectedClient: aibridge.ClientCodex,
661667
},
662668
{
663669
name: config.ProviderAnthropic + "_baseURL_path",
@@ -668,6 +674,8 @@ func TestSimple(t *testing.T) {
668674
getResponseIDFunc: getAnthropicResponseID,
669675
createRequest: createAnthropicMessagesReq,
670676
expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn",
677+
userAgent: "GitHubCopilotChat/0.37.2026011603",
678+
expectedClient: aibridge.ClientCopilotVSC,
671679
},
672680
{
673681
name: config.ProviderOpenAI + "_baseURL_path",
@@ -678,6 +686,8 @@ func TestSimple(t *testing.T) {
678686
getResponseIDFunc: getOpenAIResponseID,
679687
createRequest: createOpenAIChatCompletionsReq,
680688
expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N",
689+
userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)",
690+
expectedClient: aibridge.ClientZed,
681691
},
682692
}
683693

@@ -726,6 +736,7 @@ func TestSimple(t *testing.T) {
726736
mockSrv.Start()
727737
// When: calling the "API server" with the fixture's request body.
728738
req := tc.createRequest(t, mockSrv.URL, reqBody)
739+
req.Header.Set("User-Agent", tc.userAgent)
729740
client := &http.Client{}
730741
resp, err := client.Do(req)
731742
require.NoError(t, err)
@@ -756,6 +767,12 @@ func TestSimple(t *testing.T) {
756767
require.GreaterOrEqual(t, len(tokenUsages), 1)
757768
require.Equal(t, tokenUsages[0].MsgID, tc.expectedMsgID)
758769

770+
// Validate user agent and client have been recorded.
771+
interceptions := recorderClient.RecordedInterceptions()
772+
require.Len(t, interceptions, 1, "expected exactly one interception, got: %v", interceptions)
773+
assert.Equal(t, tc.userAgent, interceptions[0].UserAgent)
774+
assert.Equal(t, tc.expectedClient, interceptions[0].Client)
775+
759776
recorderClient.VerifyAllInterceptionsEnded(t)
760777
})
761778
}

bridge_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,98 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
104104
})
105105
}
106106
}
107+
108+
func TestGuessClient(t *testing.T) {
109+
t.Parallel()
110+
111+
tests := []struct {
112+
name string
113+
userAgent string
114+
headers map[string]string
115+
wantClient string
116+
}{
117+
{
118+
name: "claude_code",
119+
userAgent: "claude-cli/2.0.67 (external, cli)",
120+
wantClient: ClientClaude,
121+
},
122+
{
123+
name: "codex_cli",
124+
userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64) ghostty/1.3.0-main_250877ef",
125+
wantClient: ClientCodex,
126+
},
127+
{
128+
name: "zed",
129+
userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)",
130+
wantClient: ClientZed,
131+
},
132+
{
133+
name: "github_copilot_vsc",
134+
userAgent: "GitHubCopilotChat/0.37.2026011603",
135+
wantClient: ClientCopilotVSC,
136+
},
137+
{
138+
name: "github_copilot_cli",
139+
userAgent: "copilot/0.0.403 (client/cli linux v24.11.1)",
140+
wantClient: ClientCopilotCLI,
141+
},
142+
{
143+
name: "kilo_code_user_agent",
144+
userAgent: "kilo-code/5.1.0 (darwin 25.2.0; arm64) node/22.21.1",
145+
wantClient: ClientKilo,
146+
},
147+
{
148+
name: "kilo_code_originator",
149+
headers: map[string]string{"Originator": "kilo-code"},
150+
wantClient: ClientKilo,
151+
},
152+
{
153+
name: "roo_code_user_agent",
154+
userAgent: "roo-code/3.45.0 (darwin 25.2.0; arm64) node/22.21.1",
155+
wantClient: ClientRoo,
156+
},
157+
{
158+
name: "roo_code_originator",
159+
headers: map[string]string{"Originator": "roo-code"},
160+
wantClient: ClientRoo,
161+
},
162+
{
163+
name: "cursor_x_cursor_client_version",
164+
userAgent: "connect-es/1.6.1",
165+
headers: map[string]string{"X-Cursor-client-version": "0.50.0"},
166+
wantClient: ClientCursor,
167+
},
168+
{
169+
name: "cursor_x_cursor_some_other_header",
170+
headers: map[string]string{"x-cursor-client-version": "abc123"},
171+
wantClient: ClientCursor,
172+
},
173+
{
174+
name: "unknown_client",
175+
userAgent: "ccclaude-cli/calude-with-wrong-prefix",
176+
wantClient: ClientUnknown,
177+
},
178+
{
179+
name: "empty_user_agent",
180+
userAgent: "",
181+
wantClient: ClientUnknown,
182+
},
183+
}
184+
185+
for _, tt := range tests {
186+
t.Run(tt.name, func(t *testing.T) {
187+
t.Parallel()
188+
189+
req, err := http.NewRequest(http.MethodGet, "", nil)
190+
require.NoError(t, err)
191+
192+
req.Header.Set("User-Agent", tt.userAgent)
193+
for key, value := range tt.headers {
194+
req.Header.Set(key, value)
195+
}
196+
197+
got := guessClient(req)
198+
require.Equal(t, tt.wantClient, got)
199+
})
200+
}
201+
}

provider/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ var UnknownRoute = errors.New("unknown route")
4141
//
4242
// !Note!
4343
// OpenAI and Anthropic use different route patterns.
44-
// OpenAI includes the version '/v1' in the base url while Antropic does not.
44+
// OpenAI includes the version '/v1' in the base url while Anthropic does not.
4545
// More details/examples: https://github.com/coder/aibridge/pull/174#discussion_r2782320152
4646
type Provider interface {
4747
// Name returns the provider's name.

recorder/types.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,14 @@ type ToolArgs any
2626
type Metadata map[string]any
2727

2828
type InterceptionRecord struct {
29-
ID string
30-
InitiatorID, Provider, Model string
31-
Metadata Metadata
32-
StartedAt time.Time
29+
Client string
30+
ID string
31+
InitiatorID string
32+
Metadata Metadata
33+
Model string
34+
Provider string
35+
StartedAt time.Time
36+
UserAgent string
3337
}
3438

3539
type InterceptionRecordEnded struct {

0 commit comments

Comments
 (0)