From 514075e700462bff577d3c6eb1497fb01db54907 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 27 Feb 2026 14:40:28 +0200 Subject: [PATCH 1/9] chore: refactoring client guessing functionality into separate file Signed-off-by: Danny Kopping --- bridge.go | 31 --------------- client.go | 51 ++++++++++++++++++++++++ client_test.go | 103 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 31 deletions(-) create mode 100644 client.go create mode 100644 client_test.go diff --git a/bridge.go b/bridge.go index 463eeb7..6b2229a 100644 --- a/bridge.go +++ b/bridge.go @@ -338,34 +338,3 @@ func mergeContexts(base, other context.Context) context.Context { }() return ctx } - -// guessClient attempts to guess the client application from the request headers. -// Not all clients set proper user agent headers, so this is a best-effort approach. -// Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101. -func guessClient(r *http.Request) string { - userAgent := strings.ToLower(r.UserAgent()) - originator := r.Header.Get("originator") - - // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 - switch { - case strings.HasPrefix(userAgent, "mux/"): - return ClientMux - case strings.HasPrefix(userAgent, "claude"): - return ClientClaude - case strings.HasPrefix(userAgent, "codex"): - return ClientCodex - case strings.HasPrefix(userAgent, "zed/"): - return ClientZed - case strings.HasPrefix(userAgent, "githubcopilotchat/"): - return ClientCopilotVSC - case strings.HasPrefix(userAgent, "copilot/"): - return ClientCopilotCLI - case strings.HasPrefix(userAgent, "kilo-code/") || originator == "kilo-code": - return ClientKilo - case strings.HasPrefix(userAgent, "roo-code/") || originator == "roo-code": - return ClientRoo - case r.Header.Get("x-cursor-client-version") != "": - return ClientCursor - } - return ClientUnknown -} diff --git a/client.go b/client.go new file mode 100644 index 0000000..7918eae --- /dev/null +++ b/client.go @@ -0,0 +1,51 @@ +package aibridge + +import ( + "net/http" + "strings" +) + +const ( + // Possible values for the "client" field in interception records. + // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 + ClientClaude = "Claude Code" + ClientCodex = "Codex" + ClientZed = "Zed" + ClientCopilotVSC = "GitHub Copilot (VS Code)" + ClientCopilotCLI = "GitHub Copilot (CLI)" + ClientKilo = "Kilo Code" + ClientRoo = "Roo Code" + ClientCursor = "Cursor" + ClientUnknown = "Unknown" +) + +// guessClient attempts to guess the client application from the request headers. +// Not all clients set proper user agent headers, so this is a best-effort approach. +// Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101. +func guessClient(r *http.Request) string { + userAgent := strings.ToLower(r.UserAgent()) + originator := r.Header.Get("originator") + + // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 + switch { + case strings.HasPrefix(userAgent, "claude"): + return ClientClaude + case strings.HasPrefix(userAgent, "codex"): + return ClientCodex + case strings.HasPrefix(userAgent, "zed/"): + return ClientZed + case strings.HasPrefix(userAgent, "githubcopilotchat/"): + return ClientCopilotVSC + case strings.HasPrefix(userAgent, "copilot/"): + return ClientCopilotCLI + case strings.HasPrefix(userAgent, "kilo-code/") || originator == "kilo-code": + return ClientKilo + case strings.HasPrefix(userAgent, "roo-code/") || originator == "roo-code": + return ClientRoo + case strings.HasPrefix(userAgent, "copilot"): + return ClientCursor + case r.Header.Get("x-cursor-client-version") != "": + return ClientCursor + } + return ClientUnknown +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..eb57b5c --- /dev/null +++ b/client_test.go @@ -0,0 +1,103 @@ +package aibridge + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGuessClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + userAgent string + headers map[string]string + wantClient string + }{ + { + name: "claude_code", + userAgent: "claude-cli/2.0.67 (external, cli)", + wantClient: ClientClaude, + }, + { + name: "codex_cli", + userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64) ghostty/1.3.0-main_250877ef", + wantClient: ClientCodex, + }, + { + name: "zed", + userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)", + wantClient: ClientZed, + }, + { + name: "github_copilot_vsc", + userAgent: "GitHubCopilotChat/0.37.2026011603", + wantClient: ClientCopilotVSC, + }, + { + name: "github_copilot_cli", + userAgent: "copilot/0.0.403 (client/cli linux v24.11.1)", + wantClient: ClientCopilotCLI, + }, + { + name: "kilo_code_user_agent", + userAgent: "kilo-code/5.1.0 (darwin 25.2.0; arm64) node/22.21.1", + wantClient: ClientKilo, + }, + { + name: "kilo_code_originator", + headers: map[string]string{"Originator": "kilo-code"}, + wantClient: ClientKilo, + }, + { + name: "roo_code_user_agent", + userAgent: "roo-code/3.45.0 (darwin 25.2.0; arm64) node/22.21.1", + wantClient: ClientRoo, + }, + { + name: "roo_code_originator", + headers: map[string]string{"Originator": "roo-code"}, + wantClient: ClientRoo, + }, + { + name: "cursor_x_cursor_client_version", + userAgent: "connect-es/1.6.1", + headers: map[string]string{"X-Cursor-client-version": "0.50.0"}, + wantClient: ClientCursor, + }, + { + name: "cursor_x_cursor_some_other_header", + headers: map[string]string{"x-cursor-client-version": "abc123"}, + wantClient: ClientCursor, + }, + { + name: "unknown_client", + userAgent: "ccclaude-cli/calude-with-wrong-prefix", + wantClient: ClientUnknown, + }, + { + name: "empty_user_agent", + userAgent: "", + wantClient: ClientUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "", nil) + require.NoError(t, err) + + req.Header.Set("User-Agent", tt.userAgent) + for key, value := range tt.headers { + req.Header.Set(key, value) + } + + got := guessClient(req) + require.Equal(t, tt.wantClient, got) + }) + } +} From 17511347240a07d65e762b545ff44b55ab707853 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 27 Feb 2026 16:14:01 +0200 Subject: [PATCH 2/9] chore: add Client type alias Signed-off-by: Danny Kopping --- bridge_integration_test.go | 6 +- bridge_test.go | 100 ---------------------------------- client.go | 30 +++++----- client_test.go | 4 +- responses_integration_test.go | 6 +- 5 files changed, 25 insertions(+), 121 deletions(-) diff --git a/bridge_integration_test.go b/bridge_integration_test.go index bf83264..60af5b1 100644 --- a/bridge_integration_test.go +++ b/bridge_integration_test.go @@ -549,7 +549,7 @@ func TestSimple(t *testing.T) { createRequest func(*testing.T, string, []byte) *http.Request expectedMsgID string userAgent string - expectedClient string + expectedClient aibridge.Client }{ { name: config.ProviderAnthropic, @@ -561,7 +561,7 @@ func TestSimple(t *testing.T) { createRequest: createAnthropicMessagesReq, expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn", userAgent: "claude-cli/2.0.67 (external, cli)", - expectedClient: aibridge.ClientClaude, + expectedClient: aibridge.ClientClaudeCode, }, { name: config.ProviderOpenAI, @@ -671,7 +671,7 @@ func TestSimple(t *testing.T) { interceptions := recorderClient.RecordedInterceptions() require.Len(t, interceptions, 1, "expected exactly one interception, got: %v", interceptions) assert.Equal(t, tc.userAgent, interceptions[0].UserAgent) - assert.Equal(t, tc.expectedClient, interceptions[0].Client) + assert.Equal(t, string(tc.expectedClient), interceptions[0].Client) recorderClient.VerifyAllInterceptionsEnded(t) }) diff --git a/bridge_test.go b/bridge_test.go index 2e58c0c..1709be1 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -104,103 +104,3 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }) } } - -func TestGuessClient(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - userAgent string - headers map[string]string - wantClient string - }{ - { - name: "mux", - userAgent: "mux/0.19.0-next.2.gcceff159 ai-sdk/openai/3.0.36 ai-sdk/provider-utils/4.0.15 runtime/node.js/22", - wantClient: ClientMux, - }, - { - name: "claude_code", - userAgent: "claude-cli/2.0.67 (external, cli)", - wantClient: ClientClaude, - }, - { - name: "codex_cli", - userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64) ghostty/1.3.0-main_250877ef", - wantClient: ClientCodex, - }, - { - name: "zed", - userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)", - wantClient: ClientZed, - }, - { - name: "github_copilot_vsc", - userAgent: "GitHubCopilotChat/0.37.2026011603", - wantClient: ClientCopilotVSC, - }, - { - name: "github_copilot_cli", - userAgent: "copilot/0.0.403 (client/cli linux v24.11.1)", - wantClient: ClientCopilotCLI, - }, - { - name: "kilo_code_user_agent", - userAgent: "kilo-code/5.1.0 (darwin 25.2.0; arm64) node/22.21.1", - wantClient: ClientKilo, - }, - { - name: "kilo_code_originator", - headers: map[string]string{"Originator": "kilo-code"}, - wantClient: ClientKilo, - }, - { - name: "roo_code_user_agent", - userAgent: "roo-code/3.45.0 (darwin 25.2.0; arm64) node/22.21.1", - wantClient: ClientRoo, - }, - { - name: "roo_code_originator", - headers: map[string]string{"Originator": "roo-code"}, - wantClient: ClientRoo, - }, - { - name: "cursor_x_cursor_client_version", - userAgent: "connect-es/1.6.1", - headers: map[string]string{"X-Cursor-client-version": "0.50.0"}, - wantClient: ClientCursor, - }, - { - name: "cursor_x_cursor_some_other_header", - headers: map[string]string{"x-cursor-client-version": "abc123"}, - wantClient: ClientCursor, - }, - { - name: "unknown_client", - userAgent: "ccclaude-cli/calude-with-wrong-prefix", - wantClient: ClientUnknown, - }, - { - name: "empty_user_agent", - userAgent: "", - wantClient: ClientUnknown, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - req, err := http.NewRequest(http.MethodGet, "", nil) - require.NoError(t, err) - - req.Header.Set("User-Agent", tt.userAgent) - for key, value := range tt.headers { - req.Header.Set(key, value) - } - - got := guessClient(req) - require.Equal(t, tt.wantClient, got) - }) - } -} diff --git a/client.go b/client.go index 7918eae..4403672 100644 --- a/client.go +++ b/client.go @@ -5,31 +5,35 @@ import ( "strings" ) +type Client string + const ( // Possible values for the "client" field in interception records. // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 - ClientClaude = "Claude Code" - ClientCodex = "Codex" - ClientZed = "Zed" - ClientCopilotVSC = "GitHub Copilot (VS Code)" - ClientCopilotCLI = "GitHub Copilot (CLI)" - ClientKilo = "Kilo Code" - ClientRoo = "Roo Code" - ClientCursor = "Cursor" - ClientUnknown = "Unknown" + ClientClaudeCode Client = "Claude Code" + ClientCodex Client = "Codex" + ClientZed Client = "Zed" + ClientCopilotVSC Client = "GitHub Copilot (VS Code)" + ClientCopilotCLI Client = "GitHub Copilot (CLI)" + ClientKilo Client = "Kilo Code" + ClientRoo Client = "Roo Code" + ClientCursor Client = "Cursor" + ClientUnknown Client = "Unknown" ) // guessClient attempts to guess the client application from the request headers. // Not all clients set proper user agent headers, so this is a best-effort approach. // Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101. -func guessClient(r *http.Request) string { +func guessClient(r *http.Request) Client { + headers := r.Header.Clone() + userAgent := strings.ToLower(r.UserAgent()) - originator := r.Header.Get("originator") + originator := headers.Get("originator") // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 switch { case strings.HasPrefix(userAgent, "claude"): - return ClientClaude + return ClientClaudeCode case strings.HasPrefix(userAgent, "codex"): return ClientCodex case strings.HasPrefix(userAgent, "zed/"): @@ -44,7 +48,7 @@ func guessClient(r *http.Request) string { return ClientRoo case strings.HasPrefix(userAgent, "copilot"): return ClientCursor - case r.Header.Get("x-cursor-client-version") != "": + case headers.Get("x-cursor-client-version") != "": return ClientCursor } return ClientUnknown diff --git a/client_test.go b/client_test.go index eb57b5c..d656c61 100644 --- a/client_test.go +++ b/client_test.go @@ -14,12 +14,12 @@ func TestGuessClient(t *testing.T) { name string userAgent string headers map[string]string - wantClient string + wantClient Client }{ { name: "claude_code", userAgent: "claude-cli/2.0.67 (external, cli)", - wantClient: ClientClaude, + wantClient: ClientClaudeCode, }, { name: "codex_cli", diff --git a/responses_integration_test.go b/responses_integration_test.go index 4b82bfb..5cde9c9 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -45,7 +45,7 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { expectToolRecorded *recorder.ToolUsageRecord expectTokenUsage *recorder.TokenUsageRecord userAgent string - expectedClient string + expectedClient aibridge.Client }{ { name: "blocking_simple", @@ -63,7 +63,7 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { }, }, userAgent: "claude-cli/2.0.67 (external, cli)", - expectedClient: aibridge.ClientClaude, + expectedClient: aibridge.ClientClaudeCode, }, { name: "blocking_builtin_tool", @@ -369,7 +369,7 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { require.Equal(t, intc.Provider, config.ProviderOpenAI) require.Equal(t, intc.Model, tc.expectModel) require.Equal(t, tc.userAgent, intc.UserAgent) - require.Equal(t, tc.expectedClient, intc.Client) + require.Equal(t, string(tc.expectedClient), intc.Client) recordedPrompts := mockRecorder.RecordedPromptUsages() if tc.expectPromptRecorded != "" { From 465415c5247f199b65a0376d5f073c54d3b6ea2c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 27 Feb 2026 16:23:24 +0200 Subject: [PATCH 3/9] feat: add session member and logic Signed-off-by: Danny Kopping --- recorder/types.go | 3 +- session.go | 54 +++++++++++++++++++ session_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 session.go create mode 100644 session_test.go diff --git a/recorder/types.go b/recorder/types.go index 82c34d0..5a7b365 100644 --- a/recorder/types.go +++ b/recorder/types.go @@ -26,13 +26,14 @@ type ToolArgs any type Metadata map[string]any type InterceptionRecord struct { - Client string ID string InitiatorID string Metadata Metadata Model string Provider string StartedAt time.Time + ClientSessionID string + Client string UserAgent string CorrelatingToolCallID *string } diff --git a/session.go b/session.go new file mode 100644 index 0000000..57160e1 --- /dev/null +++ b/session.go @@ -0,0 +1,54 @@ +package aibridge + +import ( + "bytes" + "io" + "net/http" + "regexp" + "strings" + + "github.com/tidwall/gjson" +) + +// guessSessionID attempts to retrieve a session ID which may have been sent by +// the client. We only attempt to retrieve sessions using methods recognized for +// the given client. +func guessSessionID(client Client, r *http.Request) string { + headers := r.Header.Clone() + payload, err := io.ReadAll(r.Body) + if err != nil { + // Failing silently is suitable here; if the body cannot be read, we won't be able to do much more. + return "" + } + _ = r.Body.Close() + + // Restore the request body. + r.Body = io.NopCloser(bytes.NewReader(payload)) + + switch client { + case ClientClaudeCode: + /* Claude Code adds the session ID into the `metadata.user_id` field in the JSON body. + { + ... + "metadata": { + "user_id": "user_{sha256}_account_{account_id}_session_{uuid_v4}" + }, + ... + } */ + userID := gjson.GetBytes(payload, "metadata.user_id") + if !userID.Exists() { + return "" + } + + matches := regexp.MustCompile(`_session_(.+)$`).FindStringSubmatch(userID.String()) + if len(matches) < 2 { + return "" + } + return matches[1] + case ClientCodex: + // Codex sends a `session_id` header. + return strings.TrimSpace(headers.Get("session_id")) + default: + return "" + } +} diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..ec56703 --- /dev/null +++ b/session_test.go @@ -0,0 +1,131 @@ +package aibridge + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGuessSessionID(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + client Client + body string + headers map[string]string + sessionID string + }{ + // Claude Code. + { + name: "claude_code_with_valid_session", + client: ClientClaudeCode, + body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`, + sessionID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + }, + { + name: "claude_code_missing_metadata", + client: ClientClaudeCode, + body: `{"model":"claude-3"}`, + sessionID: "", + }, + { + name: "claude_code_missing_user_id", + client: ClientClaudeCode, + body: `{"metadata":{}}`, + sessionID: "", + }, + { + name: "claude_code_user_id_without_session", + client: ClientClaudeCode, + body: `{"metadata":{"user_id":"user_abc123_account_456"}}`, + sessionID: "", + }, + { + name: "claude_code_empty_body", + client: ClientClaudeCode, + body: ``, + sessionID: "", + }, + { + name: "claude_code_invalid_json", + client: ClientClaudeCode, + body: `not json at all`, + sessionID: "", + }, + // Codex. + { + name: "codex_with_session_header", + client: ClientCodex, + headers: map[string]string{"session_id": "codex-session-123"}, + sessionID: "codex-session-123", + }, + { + name: "codex_with_whitespace_in_header", + client: ClientCodex, + headers: map[string]string{"session_id": " codex-session-123 "}, + sessionID: "codex-session-123", + }, + { + name: "codex_without_session_header", + client: ClientCodex, + sessionID: "", + }, + // Other clients shouldn't use others' logic. + { + name: "unknown_client_returns_empty", + client: ClientUnknown, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, + sessionID: "", + }, + { + name: "zed_returns_empty", + client: ClientZed, + headers: map[string]string{"session_id": "zed-session"}, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, + sessionID: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + body := tc.body + req, err := http.NewRequest(http.MethodPost, "http://localhost", strings.NewReader(body)) + require.NoError(t, err) + + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + got := guessSessionID(tc.client, req) + require.Equal(t, tc.sessionID, got) + + // Verify the body was restored and can be read again. + restored, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, body, string(restored)) + }) + } +} + +func TestUnreadableBody(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "http://localhost", &errReader{}) + require.NoError(t, err) + + got := guessSessionID(ClientClaudeCode, req) + require.Equal(t, "", got) +} + +// errReader is an io.Reader that always returns an error. +type errReader struct{} + +func (e *errReader) Read([]byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} From b529aaf247d1b0d7162dca8f50efc7a4bd128fd4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 27 Feb 2026 16:23:39 +0200 Subject: [PATCH 4/9] feat: guess session Signed-off-by: Danny Kopping --- bridge.go | 23 +++++++---------------- provider/openai.go | 2 +- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/bridge.go b/bridge.go index 6b2229a..355a61e 100644 --- a/bridge.go +++ b/bridge.go @@ -29,21 +29,6 @@ const ( recordingTimeout = time.Second * 5 ) -const ( - // Possible values for the "client" field in interception records. - // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 - ClientClaude = "Claude Code" - ClientCodex = "Codex" - ClientCursor = "Cursor" - ClientCopilotVSC = "GitHub Copilot (VS Code)" - ClientCopilotCLI = "GitHub Copilot (CLI)" - ClientKilo = "Kilo Code" - ClientMux = "Mux" - ClientRoo = "Roo Code" - ClientZed = "Zed" - ClientUnknown = "Unknown" -) - // RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs; // specifically, OpenAI's & Anthropic's at present. // RequestBridge intercepts requests to - and responses from - these upstream services to provide @@ -167,6 +152,11 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC ctx, span := tracer.Start(r.Context(), "Intercept") defer span.End() + // We execute this before CreateInterceptor since the interceptors + // read the request body and don't reset them. + client := guessClient(r) + sessionID := guessSessionID(client, r) + interceptor, err := p.CreateInterceptor(w, r.WithContext(ctx), tracer) if err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to create interceptor: %v", err)) @@ -203,13 +193,14 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC interceptor.Setup(logger, asyncRecorder, mcpProxy) if err := rec.RecordInterception(ctx, &recorder.InterceptionRecord{ - Client: guessClient(r), ID: interceptor.ID().String(), InitiatorID: actor.ID, Metadata: actor.Metadata, Model: interceptor.Model(), Provider: p.Name(), UserAgent: r.UserAgent(), + Client: string(client), + ClientSessionID: sessionID, CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), }); err != nil { span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) diff --git a/provider/openai.go b/provider/openai.go index 730fc68..43d6811 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -116,7 +116,7 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace return nil, fmt.Errorf("read body: %w", err) } var req responses.ResponsesNewParamsWrapper - if err := json.Unmarshal(payload, &req); err != nil { + if err := json.Unmarshal(payload, &req); err != nil { // TODO: should probably change to json.NewDecoder. return nil, fmt.Errorf("unmarshal request body: %w", err) } if req.Stream { From fbc285eae74d140f7a1715d05d91a0a929ef39f1 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 2 Mar 2026 11:42:20 +0200 Subject: [PATCH 5/9] feat: track mux sessions Signed-off-by: Danny Kopping --- client.go | 3 +++ session.go | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index 4403672..76c5749 100644 --- a/client.go +++ b/client.go @@ -16,6 +16,7 @@ const ( ClientCopilotVSC Client = "GitHub Copilot (VS Code)" ClientCopilotCLI Client = "GitHub Copilot (CLI)" ClientKilo Client = "Kilo Code" + ClientMux Client = "Mux" ClientRoo Client = "Roo Code" ClientCursor Client = "Cursor" ClientUnknown Client = "Unknown" @@ -32,6 +33,8 @@ func guessClient(r *http.Request) Client { // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 switch { + case strings.HasPrefix(userAgent, "mux/"): + return ClientMux case strings.HasPrefix(userAgent, "claude"): return ClientClaudeCode case strings.HasPrefix(userAgent, "codex"): diff --git a/session.go b/session.go index 57160e1..a8c9b2e 100644 --- a/session.go +++ b/session.go @@ -46,8 +46,9 @@ func guessSessionID(client Client, r *http.Request) string { } return matches[1] case ClientCodex: - // Codex sends a `session_id` header. return strings.TrimSpace(headers.Get("session_id")) + case ClientMux: + return strings.TrimSpace(headers.Get("X-Mux-Workspace-Id")) default: return "" } From dda8bf54b61e16b858a0aa4b9cc3451e90ec70ec Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 4 Mar 2026 10:57:31 +0200 Subject: [PATCH 6/9] feat: session detection for all supported clients Signed-off-by: Danny Kopping --- session.go | 19 ++++++++++++++++ session_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/session.go b/session.go index a8c9b2e..ae3b7b1 100644 --- a/session.go +++ b/session.go @@ -49,6 +49,25 @@ func guessSessionID(client Client, r *http.Request) string { return strings.TrimSpace(headers.Get("session_id")) case ClientMux: return strings.TrimSpace(headers.Get("X-Mux-Workspace-Id")) + case ClientZed: + return "" // Zed does not send a session ID from Zed Agent or Text Thread. + case ClientCopilotVSC: + // This does not map precisely to what we consider a session, but it's close enough. + // Most other providers' equivalent of this would persist for the duration of a + // conversation; it does seem to persist across an agentic loop though, which is + // all we really need. + // + // There's also `vscode-sessionid` but that's persistent for the duration of the + // VS Code window. + return strings.TrimSpace(headers.Get("x-interaction-id")) + case ClientCopilotCLI: + return strings.TrimSpace(headers.Get("X-Client-Session-Id")) + case ClientKilo: + return strings.TrimSpace(headers.Get("X-KILOCODE-TASKID")) + case ClientRoo: + return "" // RooCode doesn't send a session ID. + case ClientCursor: + return "" // Cursor is not currently supported. default: return "" } diff --git a/session_test.go b/session_test.go index ec56703..f0a2015 100644 --- a/session_test.go +++ b/session_test.go @@ -88,6 +88,66 @@ func TestGuessSessionID(t *testing.T) { body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, sessionID: "", }, + // Mux. + { + name: "mux_with_workspace_header", + client: ClientMux, + headers: map[string]string{"X-Mux-Workspace-Id": "ws-abc-123"}, + sessionID: "ws-abc-123", + }, + { + name: "mux_without_workspace_header", + client: ClientMux, + sessionID: "", + }, + // Copilot VS Code. + { + name: "copilot_vsc_with_interaction_id", + client: ClientCopilotVSC, + headers: map[string]string{"x-interaction-id": "interaction-xyz"}, + sessionID: "interaction-xyz", + }, + { + name: "copilot_vsc_without_interaction_id", + client: ClientCopilotVSC, + sessionID: "", + }, + // Copilot CLI. + { + name: "copilot_cli_with_session_header", + client: ClientCopilotCLI, + headers: map[string]string{"X-Client-Session-Id": "cli-sess-456"}, + sessionID: "cli-sess-456", + }, + { + name: "copilot_cli_without_session_header", + client: ClientCopilotCLI, + sessionID: "", + }, + // Kilo. + { + name: "kilo_with_task_id", + client: ClientKilo, + headers: map[string]string{"X-KILOCODE-TASKID": "task-789"}, + sessionID: "task-789", + }, + { + name: "kilo_without_task_id", + client: ClientKilo, + sessionID: "", + }, + // Roo. + { + name: "roo_returns_empty", + client: ClientRoo, + sessionID: "", + }, + // Cursor. + { + name: "cursor_returns_empty", + client: ClientCursor, + sessionID: "", + }, } for _, tc := range cases { From 087c870eabb0e964314fe6c3107777ba0c3dcdcd Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 4 Mar 2026 14:14:16 +0200 Subject: [PATCH 7/9] chore: refactoring Signed-off-by: Danny Kopping --- client.go | 6 +-- recorder/types.go | 2 +- session.go | 43 ++++++++++++------- session_test.go | 105 ++++++++++++++++++++-------------------------- 4 files changed, 76 insertions(+), 80 deletions(-) diff --git a/client.go b/client.go index 76c5749..ee85eca 100644 --- a/client.go +++ b/client.go @@ -26,10 +26,8 @@ const ( // Not all clients set proper user agent headers, so this is a best-effort approach. // Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101. func guessClient(r *http.Request) Client { - headers := r.Header.Clone() - userAgent := strings.ToLower(r.UserAgent()) - originator := headers.Get("originator") + originator := r.Header.Get("originator") // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 switch { @@ -51,7 +49,7 @@ func guessClient(r *http.Request) Client { return ClientRoo case strings.HasPrefix(userAgent, "copilot"): return ClientCursor - case headers.Get("x-cursor-client-version") != "": + case r.Header.Get("x-cursor-client-version") != "": return ClientCursor } return ClientUnknown diff --git a/recorder/types.go b/recorder/types.go index 5a7b365..b33494d 100644 --- a/recorder/types.go +++ b/recorder/types.go @@ -32,7 +32,7 @@ type InterceptionRecord struct { Model string Provider string StartedAt time.Time - ClientSessionID string + ClientSessionID *string Client string UserAgent string CorrelatingToolCallID *string diff --git a/session.go b/session.go index ae3b7b1..d0a5b3d 100644 --- a/session.go +++ b/session.go @@ -7,18 +7,20 @@ import ( "regexp" "strings" + "github.com/coder/aibridge/utils" "github.com/tidwall/gjson" ) +var claudeCodePattern = regexp.MustCompile(`_session_(.+)$`) // Save compilation on each call. + // guessSessionID attempts to retrieve a session ID which may have been sent by // the client. We only attempt to retrieve sessions using methods recognized for // the given client. -func guessSessionID(client Client, r *http.Request) string { - headers := r.Header.Clone() +func guessSessionID(client Client, r *http.Request) *string { payload, err := io.ReadAll(r.Body) if err != nil { // Failing silently is suitable here; if the body cannot be read, we won't be able to do much more. - return "" + return nil } _ = r.Body.Close() @@ -37,20 +39,20 @@ func guessSessionID(client Client, r *http.Request) string { } */ userID := gjson.GetBytes(payload, "metadata.user_id") if !userID.Exists() { - return "" + return nil } - matches := regexp.MustCompile(`_session_(.+)$`).FindStringSubmatch(userID.String()) + matches := claudeCodePattern.FindStringSubmatch(userID.String()) if len(matches) < 2 { - return "" + return nil } - return matches[1] + return cleanRef(matches[1]) case ClientCodex: - return strings.TrimSpace(headers.Get("session_id")) + return cleanRef(r.Header.Get("session_id")) case ClientMux: - return strings.TrimSpace(headers.Get("X-Mux-Workspace-Id")) + return cleanRef(r.Header.Get("X-Mux-Workspace-Id")) case ClientZed: - return "" // Zed does not send a session ID from Zed Agent or Text Thread. + return nil // Zed does not send a session ID from Zed Agent or Text Thread. case ClientCopilotVSC: // This does not map precisely to what we consider a session, but it's close enough. // Most other providers' equivalent of this would persist for the duration of a @@ -59,16 +61,25 @@ func guessSessionID(client Client, r *http.Request) string { // // There's also `vscode-sessionid` but that's persistent for the duration of the // VS Code window. - return strings.TrimSpace(headers.Get("x-interaction-id")) + return cleanRef(r.Header.Get("x-interaction-id")) case ClientCopilotCLI: - return strings.TrimSpace(headers.Get("X-Client-Session-Id")) + return cleanRef(r.Header.Get("X-Client-Session-Id")) case ClientKilo: - return strings.TrimSpace(headers.Get("X-KILOCODE-TASKID")) + return cleanRef(r.Header.Get("X-KILOCODE-TASKID")) case ClientRoo: - return "" // RooCode doesn't send a session ID. + return nil // RooCode doesn't send a session ID. case ClientCursor: - return "" // Cursor is not currently supported. + return nil // Cursor is not currently supported. default: - return "" + return nil } } + +func cleanRef(str string) *string { + str = strings.TrimSpace(str) + if str == "" { + return nil + } + + return utils.PtrTo(str) +} diff --git a/session_test.go b/session_test.go index f0a2015..44305f9 100644 --- a/session_test.go +++ b/session_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/coder/aibridge/utils" "github.com/stretchr/testify/require" ) @@ -17,136 +18,122 @@ func TestGuessSessionID(t *testing.T) { client Client body string headers map[string]string - sessionID string + sessionID *string }{ // Claude Code. { name: "claude_code_with_valid_session", client: ClientClaudeCode, body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`, - sessionID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"), }, { - name: "claude_code_missing_metadata", - client: ClientClaudeCode, - body: `{"model":"claude-3"}`, - sessionID: "", + name: "claude_code_missing_metadata", + client: ClientClaudeCode, + body: `{"model":"claude-3"}`, }, { - name: "claude_code_missing_user_id", - client: ClientClaudeCode, - body: `{"metadata":{}}`, - sessionID: "", + name: "claude_code_missing_user_id", + client: ClientClaudeCode, + body: `{"metadata":{}}`, }, { - name: "claude_code_user_id_without_session", - client: ClientClaudeCode, - body: `{"metadata":{"user_id":"user_abc123_account_456"}}`, - sessionID: "", + name: "claude_code_user_id_without_session", + client: ClientClaudeCode, + body: `{"metadata":{"user_id":"user_abc123_account_456"}}`, }, { - name: "claude_code_empty_body", - client: ClientClaudeCode, - body: ``, - sessionID: "", + name: "claude_code_empty_body", + client: ClientClaudeCode, + body: ``, }, { - name: "claude_code_invalid_json", - client: ClientClaudeCode, - body: `not json at all`, - sessionID: "", + name: "claude_code_invalid_json", + client: ClientClaudeCode, + body: `not json at all`, }, // Codex. { name: "codex_with_session_header", client: ClientCodex, headers: map[string]string{"session_id": "codex-session-123"}, - sessionID: "codex-session-123", + sessionID: utils.PtrTo("codex-session-123"), }, { name: "codex_with_whitespace_in_header", client: ClientCodex, headers: map[string]string{"session_id": " codex-session-123 "}, - sessionID: "codex-session-123", + sessionID: utils.PtrTo("codex-session-123"), }, { - name: "codex_without_session_header", - client: ClientCodex, - sessionID: "", + name: "codex_without_session_header", + client: ClientCodex, }, // Other clients shouldn't use others' logic. { - name: "unknown_client_returns_empty", - client: ClientUnknown, - body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, - sessionID: "", + name: "unknown_client_returns_empty", + client: ClientUnknown, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, }, { - name: "zed_returns_empty", - client: ClientZed, - headers: map[string]string{"session_id": "zed-session"}, - body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, - sessionID: "", + name: "zed_returns_empty", + client: ClientZed, + headers: map[string]string{"session_id": "zed-session"}, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, }, // Mux. { name: "mux_with_workspace_header", client: ClientMux, headers: map[string]string{"X-Mux-Workspace-Id": "ws-abc-123"}, - sessionID: "ws-abc-123", + sessionID: utils.PtrTo("ws-abc-123"), }, { - name: "mux_without_workspace_header", - client: ClientMux, - sessionID: "", + name: "mux_without_workspace_header", + client: ClientMux, }, // Copilot VS Code. { name: "copilot_vsc_with_interaction_id", client: ClientCopilotVSC, headers: map[string]string{"x-interaction-id": "interaction-xyz"}, - sessionID: "interaction-xyz", + sessionID: utils.PtrTo("interaction-xyz"), }, { - name: "copilot_vsc_without_interaction_id", - client: ClientCopilotVSC, - sessionID: "", + name: "copilot_vsc_without_interaction_id", + client: ClientCopilotVSC, }, // Copilot CLI. { name: "copilot_cli_with_session_header", client: ClientCopilotCLI, headers: map[string]string{"X-Client-Session-Id": "cli-sess-456"}, - sessionID: "cli-sess-456", + sessionID: utils.PtrTo("cli-sess-456"), }, { - name: "copilot_cli_without_session_header", - client: ClientCopilotCLI, - sessionID: "", + name: "copilot_cli_without_session_header", + client: ClientCopilotCLI, }, // Kilo. { name: "kilo_with_task_id", client: ClientKilo, headers: map[string]string{"X-KILOCODE-TASKID": "task-789"}, - sessionID: "task-789", + sessionID: utils.PtrTo("task-789"), }, { - name: "kilo_without_task_id", - client: ClientKilo, - sessionID: "", + name: "kilo_without_task_id", + client: ClientKilo, }, // Roo. { - name: "roo_returns_empty", - client: ClientRoo, - sessionID: "", + name: "roo_returns_empty", + client: ClientRoo, }, // Cursor. { - name: "cursor_returns_empty", - client: ClientCursor, - sessionID: "", + name: "cursor_returns_empty", + client: ClientCursor, }, } @@ -180,7 +167,7 @@ func TestUnreadableBody(t *testing.T) { require.NoError(t, err) got := guessSessionID(ClientClaudeCode, req) - require.Equal(t, "", got) + require.Nil(t, got) } // errReader is an io.Reader that always returns an error. From 2202a74651538fb54e683f2ab8db38b639fbb723 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 4 Mar 2026 15:54:09 +0200 Subject: [PATCH 8/9] chore: restoring lost changes Signed-off-by: Danny Kopping --- client.go | 2 -- client_test.go | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index ee85eca..f7da258 100644 --- a/client.go +++ b/client.go @@ -47,8 +47,6 @@ func guessClient(r *http.Request) Client { return ClientKilo case strings.HasPrefix(userAgent, "roo-code/") || originator == "roo-code": return ClientRoo - case strings.HasPrefix(userAgent, "copilot"): - return ClientCursor case r.Header.Get("x-cursor-client-version") != "": return ClientCursor } diff --git a/client_test.go b/client_test.go index d656c61..42188e1 100644 --- a/client_test.go +++ b/client_test.go @@ -16,6 +16,11 @@ func TestGuessClient(t *testing.T) { headers map[string]string wantClient Client }{ + { + name: "mux", + userAgent: "mux/0.19.0-next.2.gcceff159 ai-sdk/openai/3.0.36 ai-sdk/provider-utils/4.0.15 runtime/node.js/22", + wantClient: ClientMux, + }, { name: "claude_code", userAgent: "claude-cli/2.0.67 (external, cli)", From f91aa3ada9e4bb19a2c54ddc81181d7ed33a3281 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 4 Mar 2026 15:54:19 +0200 Subject: [PATCH 9/9] chore: perf improvement; only read body when necessary Signed-off-by: Danny Kopping --- session.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/session.go b/session.go index d0a5b3d..b00a929 100644 --- a/session.go +++ b/session.go @@ -17,16 +17,6 @@ var claudeCodePattern = regexp.MustCompile(`_session_(.+)$`) // Save compilation // the client. We only attempt to retrieve sessions using methods recognized for // the given client. func guessSessionID(client Client, r *http.Request) *string { - payload, err := io.ReadAll(r.Body) - if err != nil { - // Failing silently is suitable here; if the body cannot be read, we won't be able to do much more. - return nil - } - _ = r.Body.Close() - - // Restore the request body. - r.Body = io.NopCloser(bytes.NewReader(payload)) - switch client { case ClientClaudeCode: /* Claude Code adds the session ID into the `metadata.user_id` field in the JSON body. @@ -37,6 +27,15 @@ func guessSessionID(client Client, r *http.Request) *string { }, ... } */ + payload, err := io.ReadAll(r.Body) + if err != nil { + // Failing silently is suitable here; if the body cannot be read, we won't be able to do much more. + return nil + } + _ = r.Body.Close() + + // Restore the request body. + r.Body = io.NopCloser(bytes.NewReader(payload)) userID := gjson.GetBytes(payload, "metadata.user_id") if !userID.Exists() { return nil