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..aa67fc109b51a 100644 --- a/aibridge/provider/copilot_internal_test.go +++ b/aibridge/provider/copilot_internal_test.go @@ -281,6 +281,97 @@ 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_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()