From 10d2641cd6073ce3f3df24cdd881423f28328b2d Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 1 Jul 2026 11:09:55 +0200 Subject: [PATCH 1/2] feat(aibridge/provider): support Anthropic /v1/messages route on Copilot GitHub Copilot CLI targeting Claude models sends Anthropic-style /v1/messages requests. The Copilot provider only registered the OpenAI-compatible /chat/completions and /responses routes, so these requests failed with "route not supported: POST /copilot/v1/messages". Register /v1/messages as a bridged route and reuse the existing intercept/messages logic to handle it. Copilot is BYOK with no Bedrock backing, so the per-user bearer token flows through unchanged. --- aibridge/provider/copilot.go | 19 ++++ aibridge/provider/copilot_internal_test.go | 105 +++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index c3b4f07ea79c3..d4e1691a6209a 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/intercept/chatcompletions" + "github.com/coder/coder/v2/aibridge/intercept/messages" "github.com/coder/coder/v2/aibridge/intercept/responses" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/tracing" @@ -28,6 +29,7 @@ const ( // Copilot exposes an OpenAI-compatible API, including for Anthropic models. routeCopilotChatCompletions = "/chat/completions" routeCopilotResponses = "/responses" + routeCopilotMessages = "/v1/messages" ) var copilotOpenErrorResponse = func() []byte { @@ -82,6 +84,7 @@ func (*Copilot) BridgedRoutes() []string { return []string{ routeCopilotChatCompletions, routeCopilotResponses, + routeCopilotMessages, } } @@ -172,6 +175,22 @@ func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trac interceptor = responses.NewBlockingInterceptor(id, reqPayload, cfg, cred, r.Header, tracer) } + case routeCopilotMessages: + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil, xerrors.Errorf("read body: %w", err) + } + reqPayload, err := messages.NewRequestPayload(payload) + if err != nil { + return nil, xerrors.Errorf("unmarshal request body: %w", err) + } + + if reqPayload.Stream() { + interceptor = messages.NewStreamingInterceptor(id, reqPayload, cfg, cred, nil, r.Header, tracer) + } else { + interceptor = messages.NewBlockingInterceptor(id, reqPayload, cfg, cred, nil, r.Header, tracer) + } + default: span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) return nil, ErrUnknownRoute diff --git a/aibridge/provider/copilot_internal_test.go b/aibridge/provider/copilot_internal_test.go index c29afec72ca18..0735ce4957336 100644 --- a/aibridge/provider/copilot_internal_test.go +++ b/aibridge/provider/copilot_internal_test.go @@ -281,6 +281,111 @@ func TestCopilot_CreateInterceptor(t *testing.T) { assert.Empty(t, receivedHeaders.Get("X-Api-Key"), "X-Api-Key must not be set upstream") }) + t.Run("Messages_NonStreamingRequest_BlockingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-sonnet-4.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.False(t, interceptor.Streaming()) + }) + + t.Run("Messages_StreamingRequest_StreamingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-sonnet-4.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": true}` + req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.True(t, interceptor.Streaming()) + }) + + t.Run("Messages_MissingAuthorizationHeader", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-sonnet-4.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}]}` + req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "missing Copilot authorization: Authorization header not found or invalid") + }) + + t.Run("Messages_InvalidRequestBody", func(t *testing.T) { + t.Parallel() + + body := `invalid json` + req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "unmarshal request body") + }) + + t.Run("Messages_ClientHeaders", func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + // Mock upstream that captures headers. + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4.5","content":[{"type":"text","text":"Hello!"}],"stop_reason":"end_turn","usage":{"input_tokens":9,"output_tokens":12}}`)) + })) + t.Cleanup(mockUpstream.Close) + + // Create provider with mock upstream URL. + provider := NewCopilot(config.Copilot{ + BaseURL: mockUpstream.URL, + }) + + body := `{"model": "claude-sonnet-4.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Editor-Version", "vscode/1.85.0") + req.Header.Set("Copilot-Integration-Id", "test-integration") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + // Setup and process request. + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, routeCopilotMessages, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + // Verify Copilot-specific headers were forwarded. + assert.Equal(t, "vscode/1.85.0", receivedHeaders.Get("Editor-Version")) + assert.Equal(t, "test-integration", receivedHeaders.Get("Copilot-Integration-Id")) + // Copilot uses per-user tokens: the client's Authorization must reach upstream as-is. + assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization"), "client Authorization must be used as provider key") + assert.Empty(t, receivedHeaders.Get("X-Api-Key"), "X-Api-Key must not be set upstream") + }) + t.Run("ErrUnknownRoute", func(t *testing.T) { t.Parallel() From ab3f61790d21d193c925b68442e3c64a9717f8a0 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 1 Jul 2026 14:05:19 +0200 Subject: [PATCH 2/2] test(aibridge/provider): drop redundant Copilot messages auth test The Authorization check in CreateInterceptor runs before route dispatch and is route-independent, so it is already covered by the existing MissingAuthorizationHeader test. --- aibridge/provider/copilot_internal_test.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/aibridge/provider/copilot_internal_test.go b/aibridge/provider/copilot_internal_test.go index 0735ce4957336..aa67fc109b51a 100644 --- a/aibridge/provider/copilot_internal_test.go +++ b/aibridge/provider/copilot_internal_test.go @@ -311,20 +311,6 @@ func TestCopilot_CreateInterceptor(t *testing.T) { assert.True(t, interceptor.Streaming()) }) - t.Run("Messages_MissingAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - body := `{"model": "claude-sonnet-4.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}]}` - req := httptest.NewRequest(http.MethodPost, routeCopilotMessages, bytes.NewBufferString(body)) - w := httptest.NewRecorder() - - interceptor, err := provider.CreateInterceptor(w, req, testTracer) - - require.Error(t, err) - require.Nil(t, interceptor) - assert.Contains(t, err.Error(), "missing Copilot authorization: Authorization header not found or invalid") - }) - t.Run("Messages_InvalidRequestBody", func(t *testing.T) { t.Parallel()