Repository: envoyproxy/ai-gateway
Pull request #325 (2025-02-11 16:30:21)

extproc: custom processors per path and serve /v1/models
**Commit Message**

extproc: custom processors per path and serve /v1/models

Refactors the server processing to allow registering custom Processors for different request paths,
and adds a custom processor for requests to `/v1/models` that returns an immediate response based
on the models that are configured in the filter configuration.

**Related Issues/PRs (if applicable)**

Related discussion: https://github.com/envoyproxy/ai-gateway/issues/186

**Special notes for reviewers (if applicable)**

~~Opening as a draft to discuss on the proposed approach.~~

In order to return a direct response for some paths, we need to be able to customize the behaviour. Instead of messing
up with the translator, this PR proposes a change to refactor the Server and allow it to define custom processors per request path. This would make it easier to extend the functionality of the filter in the future, when more endpoints are supported.

This is used in the current PR to serve the `/v1/models` endpoint and return a computed list of models based on the configured routes. Right now we take advantage of exact header matching being the only option, but should be good enough for a first implementation.

~~The current PR doesn't add a feature flag to enable/disable this endpoint handling, but it should be easy to add if we feel the approach proposed in this PR is good.~~

nacx: I think that's correct. We don't need the factory as it is today, but we still need to let the processor decide what translator to use, depending on the selected backend. I can refactor and clean that up in a follow-up PR if that makes sense?
mathetake: yeah let's do a follow up refactor - that makes sense
nacx: OK, I've addressed all the review comments and rebased to the latest `main`. I squashed the commits because otherwise there were too many conflicts to resolve. I hope it doesn't complicate much the final review.

Patch:
diff --git a/cmd/extproc/mainlib/main.go b/cmd/extproc/mainlib/main.go
index da5c3e1..9b67fb1 100644
--- a/cmd/extproc/mainlib/main.go
+++ b/cmd/extproc/mainlib/main.go
@@ -2,6 +2,7 @@ package mainlib
 
 import (
 	"context"
+	"errors"
 	"flag"
 	"fmt"
 	"log"
@@ -21,59 +22,66 @@ import (
 	"github.com/envoyproxy/ai-gateway/internal/version"
 )
 
-// parseAndValidateFlags parses and validates the flags passed to the external processor.
-func parseAndValidateFlags(args []string) (configPath, addr string, logLevel slog.Level, err error) {
-	fs := flag.NewFlagSet("AI Gateway External Processor", flag.ContinueOnError)
-	configPathPtr := fs.String(
+// extProcFlags is the struct that holds the flags passed to the external processor.
+type extProcFlags struct {
+	configPath  string     // path to the configuration file.
+	extProcAddr string     // gRPC address for the external processor.
+	logLevel    slog.Level // log level for the external processor.
+}
+
+// parseAndValidateFlags parses and validates the flas passed to the external processor.
+func parseAndValidateFlags(args []string) (extProcFlags, error) {
+	var (
+		flags extProcFlags
+		errs  []error
+		fs    = flag.NewFlagSet("AI Gateway External Processor", flag.ContinueOnError)
+	)
+
+	fs.StringVar(&flags.configPath,
 		"configPath",
 		"",
 		"path to the configuration file. The file must be in YAML format specified in filterapi.Config type. "+
 			"The configuration file is watched for changes.",
 	)
-	extProcAddrPtr := fs.String(
+	fs.StringVar(&flags.extProcAddr,
 		"extProcAddr",
 		":1063",
 		"gRPC address for the external processor. For example, :1063 or unix:///tmp/ext_proc.sock",
 	)
 	logLevelPtr := fs.String(
 		"logLevel",
-		"info", "log level for the external processor. One of 'debug', 'info', 'warn', or 'error'.",
+		"info",
+		"log level for the external processor. One of 'debug', 'info', 'warn', or 'error'.",
 	)
 
-	if err = fs.Parse(args); err != nil {
-		err = fmt.Errorf("failed to parse flags: %w", err)
-		return
+	if err := fs.Parse(args); err != nil {
+		return extProcFlags{}, fmt.Errorf("failed to parse extProcFlags: %w", err)
 	}
 
-	if *configPathPtr == "" {
-		err = fmt.Errorf("configPath must be provided")
-		return
+	if flags.configPath == "" {
+		errs = append(errs, fmt.Errorf("configPath must be provided"))
 	}
-
-	if err = logLevel.UnmarshalText([]byte(*logLevelPtr)); err != nil {
-		err = fmt.Errorf("failed to unmarshal log level: %w", err)
-		return
+	if err := flags.logLevel.UnmarshalText([]byte(*logLevelPtr)); err != nil {
+		errs = append(errs, fmt.Errorf("failed to unmarshal log level: %w", err))
 	}
 
-	configPath = *configPathPtr
-	addr = *extProcAddrPtr
-	return
+	return flags, errors.Join(errs...)
 }
 
 // Main is a main function for the external processor exposed
 // for allowing users to build their own external processor.
 func Main() {
-	configPath, extProcAddr, level, err := parseAndValidateFlags(os.Args[1:])
+	flags, err := parseAndValidateFlags(os.Args[1:])
 	if err != nil {
-		log.Fatalf("failed to parse and validate flags: %v", err)
+		log.Fatalf("failed to parse and validate extProcFlags: %v", err)
 	}
 
-	l := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
+	l := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: flags.logLevel}))
 
 	l.Info("starting external processor",
 		slog.String("version", version.Version),
-		slog.String("address", extProcAddr),
-		slog.String("configPath", configPath),
+		slog.String("address", flags.extProcAddr),
+		slog.String("configPath", flags.configPath),
 	)
 
 	ctx, cancel := context.WithCancel(context.Background())
@@ -84,17 +92,19 @@ func Main() {
 		cancel()
 	}()
 
-	lis, err := net.Listen(listenAddress(extProcAddr))
+	lis, err := net.Listen(listenAddress(flags.extProcAddr))
 	if err != nil {
 		log.Fatalf("failed to listen: %v", err)
 	}
 
-	server, err := extproc.NewServer[*extproc.Processor](l, extproc.NewProcessor)
+	server, err := extproc.NewServer(l)
 	if err != nil {
 		log.Fatalf("failed to create external processor server: %v", err)
 	}
+	server.Register("/v1/chat/completions", extproc.NewChatCompletionProcessor)
+	server.Register("/v1/models", extproc.NewModelsProcessor)
 
-	if err := extproc.StartConfigWatcher(ctx, configPath, server, l, time.Second*5); err != nil {
+	if err := extproc.StartConfigWatcher(ctx, flags.configPath, server, l, time.Second*5); err != nil {
 		log.Fatalf("failed to start config watcher: %v", err)
 	}
 
diff --git a/internal/apischema/openai/openai.go b/internal/apischema/openai/openai.go
index 8d43767..d383767 100644
--- a/internal/apischema/openai/openai.go
+++ b/internal/apischema/openai/openai.go
@@ -6,7 +6,9 @@ package openai
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
 	"strings"
+	"time"
 )
 
 // Chat message role defined by the OpenAI API.
@@ -723,3 +725,43 @@ type ErrorType struct {
 	// The event_id of the client event that caused the error, if applicable.
 	EventID *string `json:"event_id,omitempty"`
 }
+
+// ModelList is described in the OpenAI API documentation
+// https://platform.openai.com/docs/api-reference/models/list
+type ModelList struct {
+	// Data is a list of models.
+	Data []Model `json:"data"`
+	// Object is the object type, which is always "list".
+	Object string `json:"object"`
+}
+
+// Model is described in the OpenAI API documentation
+// https://platform.openai.com/docs/api-reference/models/object
+type Model struct {
+	// ID is the model identifier, which can be referenced in the API endpoints.
+	ID string `json:"id"`
+	// Created is the Unix timestamp (in seconds) when the model was created.
+	Created JSONUNIXTime `json:"created"`
+	// Object is the object type, which is always "model".
+	Object string `json:"object"`
+	// OwnedBy is the organization that owns the model.
+	OwnedBy string `json:"owned_by"`
+}
+
+// JSONUNIXTime is a helper type to marshal/unmarshal time.Time UNIX timestamps.
+type JSONUNIXTime time.Time
+
+// MarshalJSON implements [json.Marshaler].
+func (t JSONUNIXTime) MarshalJSON() ([]byte, error) {
+	return []byte(strconv.FormatInt(time.Time(t).Unix(), 10)), nil
+}
+
+// UnmarshalJSON implements [json.Unmarshaler].
+func (t *JSONUNIXTime) UnmarshalJSON(s []byte) error {
+	q, err := strconv.ParseInt(string(s), 10, 64)
+	if err != nil {
+		return err
+	}
+	*(*time.Time)(t) = time.Unix(q, 0)
+	return nil
+}
diff --git a/internal/extproc/chatcompletion_processor.go b/internal/extproc/chatcompletion_processor.go
new file mode 100644
index 0000000..2e310e0
--- /dev/null
+++ b/internal/extproc/chatcompletion_processor.go
@@ -0,0 +1,226 @@
+package extproc
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io"
+	"log/slog"
+
+	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	"google.golang.org/protobuf/types/known/structpb"
+
+	"github.com/envoyproxy/ai-gateway/filterapi"
+	"github.com/envoyproxy/ai-gateway/internal/extproc/translator"
+	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
+)
+
+// NewChatCompletionProcessor implements [ProcessorIface] for the /chat/completions endpoint.
+func NewChatCompletionProcessor(config *processorConfig, requestHeaders map[string]string, logger *slog.Logger) ProcessorIface {
+	return &chatCompletionProcessor{
+		config:         config,
+		requestHeaders: requestHeaders,
+		logger:         logger,
+	}
+}
+
+// chatCompletionProcessor handles the processing of the request and response messages for a single stream.
+type chatCompletionProcessor struct {
+	logger           *slog.Logger
+	config           *processorConfig
+	requestHeaders   map[string]string
+	responseHeaders  map[string]string
+	responseEncoding string
+	translator       translator.Translator
+	// cost is the cost of the request that is accumulated during the processing of the response.
+	costs translator.LLMTokenUsage
+}
+
+// ProcessRequestHeaders implements [ProcessorIface.ProcessRequestHeaders].
+func (p *chatCompletionProcessor) ProcessRequestHeaders(_ context.Context, _ *corev3.HeaderMap) (res *extprocv3.ProcessingResponse, err error) {
+	// The request headers have already been at the time the processor was created
+	return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{
+		RequestHeaders: &extprocv3.HeadersResponse{},
+	}}, nil
+}
+
+// ProcessRequestBody implements [ProcessorIface.ProcessRequestBody].
+func (p *chatCompletionProcessor) ProcessRequestBody(ctx context.Context, rawBody *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) {
+	path := p.requestHeaders[":path"]
+	model, body, err := p.config.bodyParser(path, rawBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse request body: %w", err)
+	}
+	p.logger.Info("Processing request", "path", path, "model", model)
+
+	p.requestHeaders[p.config.modelNameHeaderKey] = model
+	b, err := p.config.router.Calculate(p.requestHeaders)
+	if err != nil {
+		return nil, fmt.Errorf("failed to calculate route: %w", err)
+	}
+	p.logger.Info("Selected backend", "backend", b.Name)
+
+	factory, ok := p.config.factories[b.Schema]
+	if !ok {
+		return nil, fmt.Errorf("failed to find factory for output schema %q", b.Schema)
+	}
+
+	t, err := factory(path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create translator: %w", err)
+	}
+	p.translator = t
+
+	headerMutation, bodyMutation, override, err := p.translator.RequestBody(body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to transform request: %w", err)
+	}
+
+	if headerMutation == nil {
+		headerMutation = &extprocv3.HeaderMutation{}
+	}
+	// Set the model name to the request header with the key `x-ai-gateway-llm-model-name`.
+	headerMutation.SetHeaders = append(headerMutation.SetHeaders, &corev3.HeaderValueOption{
+		Header: &corev3.HeaderValue{Key: p.config.modelNameHeaderKey, RawValue: []byte(model)},
+	}, &corev3.HeaderValueOption{
+		Header: &corev3.HeaderValue{Key: p.config.selectedBackendHeaderKey, RawValue: []byte(b.Name)},
+	})
+
+	if authHandler, ok := p.config.backendAuthHandlers[b.Name]; ok {
+		if err := authHandler.Do(ctx, p.requestHeaders, headerMutation, bodyMutation); err != nil {
+			return nil, fmt.Errorf("failed to do auth request: %w", err)
+		}
+	}
+
+	resp := &extprocv3.ProcessingResponse{
+		Response: &extprocv3.ProcessingResponse_RequestBody{
+			RequestBody: &extprocv3.BodyResponse{
+				Response: &extprocv3.CommonResponse{
+					HeaderMutation:  headerMutation,
+					BodyMutation:    bodyMutation,
+					ClearRouteCache: true,
+				},
+			},
+		},
+		ModeOverride: override,
+	}
+	return resp, nil
+}
+
+// ProcessResponseHeaders implements [ProcessorIface.ProcessResponseHeaders].
+func (p *chatCompletionProcessor) ProcessResponseHeaders(_ context.Context, headers *corev3.HeaderMap) (res *extprocv3.ProcessingResponse, err error) {
+	p.responseHeaders = headersToMap(headers)
+	if enc := p.responseHeaders["content-encoding"]; enc != "" {
+		p.responseEncoding = enc
+	}
+	// The translator can be nil as there could be response event generated by previous ext proc without
+	// getting the request event.
+	if p.translator == nil {
+		return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{
+			ResponseHeaders: &extprocv3.HeadersResponse{},
+		}}, nil
+	}
+	headerMutation, err := p.translator.ResponseHeaders(p.responseHeaders)
+	if err != nil {
+		return nil, fmt.Errorf("failed to transform response headers: %w", err)
+	}
+	return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{
+		ResponseHeaders: &extprocv3.HeadersResponse{
+			Response: &extprocv3.CommonResponse{HeaderMutation: headerMutation},
+		},
+	}}, nil
+}
+
+// ProcessResponseBody implements [ProcessorIface.ProcessResponseBody].
+func (p *chatCompletionProcessor) ProcessResponseBody(_ context.Context, body *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) {
+	var br io.Reader
+	switch p.responseEncoding {
+	case "gzip":
+		br, err = gzip.NewReader(bytes.NewReader(body.Body))
+		if err != nil {
+			return nil, fmt.Errorf("failed to decode gzip: %w", err)
+		}
+	default:
+		br = bytes.NewReader(body.Body)
+	}
+	// The translator can be nil as there could be response event generated by previous ext proc without
+	// getting the request event.
+	if p.translator == nil {
+		return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseBody{}}, nil
+	}
+
+	headerMutation, bodyMutation, tokenUsage, err := p.translator.ResponseBody(p.responseHeaders, br, body.EndOfStream)
+	if err != nil {
+		return nil, fmt.Errorf("failed to transform response: %w", err)
+	}
+
+	resp := &extprocv3.ProcessingResponse{
+		Response: &extprocv3.ProcessingResponse_ResponseBody{
+			ResponseBody: &extprocv3.BodyResponse{
+				Response: &extprocv3.CommonResponse{
+					HeaderMutation: headerMutation,
+					BodyMutation:   bodyMutation,
+				},
+			},
+		},
+	}
+
+	// TODO: this is coupled with "LLM" specific logic. Once we have another use case, we need to refactor this.
+	p.costs.InputTokens += tokenUsage.InputTokens
+	p.costs.OutputTokens += tokenUsage.OutputTokens
+	p.costs.TotalTokens += tokenUsage.TotalTokens
+	if body.EndOfStream && len(p.config.requestCosts) > 0 {
+		resp.DynamicMetadata, err = p.maybeBuildDynamicMetadata()
+		if err != nil {
+			return nil, fmt.Errorf("failed to build dynamic metadata: %w", err)
+		}
+	}
+	return resp, nil
+}
+
+func (p *chatCompletionProcessor) maybeBuildDynamicMetadata() (*structpb.Struct, error) {
+	metadata := make(map[string]*structpb.Value, len(p.config.requestCosts))
+	for i := range p.config.requestCosts {
+		c := &p.config.requestCosts[i]
+		var cost uint32
+		switch c.Type {
+		case filterapi.LLMRequestCostTypeInputToken:
+			cost = p.costs.InputTokens
+		case filterapi.LLMRequestCostTypeOutputToken:
+			cost = p.costs.OutputTokens
+		case filterapi.LLMRequestCostTypeTotalToken:
+			cost = p.costs.TotalTokens
+		case filterapi.LLMRequestCostTypeCELExpression:
+			costU64, err := llmcostcel.EvaluateProgram(
+				c.celProg,
+				p.requestHeaders[p.config.modelNameHeaderKey],
+				p.requestHeaders[p.config.selectedBackendHeaderKey],
+				p.costs.InputTokens,
+				p.costs.OutputTokens,
+				p.costs.TotalTokens,
+			)
+			if err != nil {
+				return nil, fmt.Errorf("failed to evaluate CEL expression: %w", err)
+			}
+			cost = uint32(costU64) //nolint:gosec
+		default:
+			return nil, fmt.Errorf("unknown request cost kind: %s", c.Type)
+		}
+		p.logger.Info("Setting request cost metadata", "type", c.Type, "cost", cost, "metadataKey", c.MetadataKey)
+		metadata[c.MetadataKey] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(cost)}}
+	}
+	if len(metadata) == 0 {
+		return nil, nil
+	}
+	return &structpb.Struct{
+		Fields: map[string]*structpb.Value{
+			p.config.metadataNamespace: {
+				Kind: &structpb.Value_StructValue{
+					StructValue: &structpb.Struct{Fields: metadata},
+				},
+			},
+		},
+	}, nil
+}
diff --git a/internal/extproc/models_processor.go b/internal/extproc/models_processor.go
new file mode 100644
index 0000000..af842d2
--- /dev/null
+++ b/internal/extproc/models_processor.go
@@ -0,0 +1,97 @@
+package extproc
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"time"
+
+	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
+	"google.golang.org/grpc/codes"
+
+	"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
+)
+
+// modelsProcessor implements [ProcessorIface] for the `/v1/models` endpoint.
+// This processor returns an immediate response with the list of models that are declared in the filter
+// configuration.
+// Since it returns an immediate response after processing the headers, the rest of the methods of the
+// ProcessorIface are not implemented. Those should never be called.
+type modelsProcessor struct {
+	logger *slog.Logger
+	models openai.ModelList
+}
+
+var _ ProcessorIface = (*modelsProcessor)(nil)
+
+// NewModelsProcessor creates a new processor that returns the list of declared models
+func NewModelsProcessor(config *processorConfig, _ map[string]string, logger *slog.Logger) ProcessorIface {
+	models := openai.ModelList{
+		Object: "list",
+		Data:   make([]openai.Model, 0, len(config.declaredModels)),
+	}
+	for _, m := range config.declaredModels {
+		models.Data = append(models.Data, openai.Model{
+			ID:      m,
+			Object:  "model",
+			OwnedBy: "Envoy AI Gateway",              // TODO(nacx): make this configurable when we need more flexibility
+			Created: openai.JSONUNIXTime(time.Now()), // TODO(nacx): does this really matter here?
+		})
+	}
+	return &modelsProcessor{logger: logger, models: models}
+}
+
+// ProcessRequestHeaders implements [ProcessorIface.ProcessRequestHeaders].
+func (m *modelsProcessor) ProcessRequestHeaders(_ context.Context, _ *corev3.HeaderMap) (*extprocv3.ProcessingResponse, error) {
+	m.logger.Info("Serving list of declared models")
+
+	body, err := json.Marshal(m.models)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal body: %w", err)
+	}
+
+	headerMutation := &extprocv3.HeaderMutation{}
+	setHeader(headerMutation, "content-length", fmt.Sprintf("%d", len(body)))
+	setHeader(headerMutation, "content-type", "application/json")
+
+	return &extprocv3.ProcessingResponse{
+		Response: &extprocv3.ProcessingResponse_ImmediateResponse{
+			ImmediateResponse: &extprocv3.ImmediateResponse{
+				Status:     &typev3.HttpStatus{Code: typev3.StatusCode_OK},
+				Headers:    headerMutation,
+				Body:       body,
+				GrpcStatus: &extprocv3.GrpcStatus{Status: uint32(codes.OK)},
+			},
+		},
+	}, nil
+}
+
+var errUnexpectedCall = errors.New("unexpected method call")
+
+// ProcessRequestBody implements [ProcessorIface.ProcessRequestBody].
+func (m *modelsProcessor) ProcessRequestBody(context.Context, *extprocv3.HttpBody) (*extprocv3.ProcessingResponse, error) {
+	return nil, fmt.Errorf("%w: ProcessRequestBody", errUnexpectedCall)
+}
+
+// ProcessResponseHeaders implements [ProcessorIface.ProcessResponseHeaders].
+func (m *modelsProcessor) ProcessResponseHeaders(context.Context, *corev3.HeaderMap) (*extprocv3.ProcessingResponse, error) {
+	return nil, fmt.Errorf("%w: ProcessResponseHeaders", errUnexpectedCall)
+}
+
+// ProcessResponseBody implements [ProcessorIface.ProcessResponseBody].
+func (m *modelsProcessor) ProcessResponseBody(context.Context, *extprocv3.HttpBody) (*extprocv3.ProcessingResponse, error) {
+	return nil, fmt.Errorf("%w: ProcessResponseBody", errUnexpectedCall)
+}
+
+func setHeader(headers *extprocv3.HeaderMutation, key, value string) {
+	headers.SetHeaders = append(headers.SetHeaders, &corev3.HeaderValueOption{
+		Header: &corev3.HeaderValue{
+			Key:      key,
+			RawValue: []byte(value),
+		},
+	})
+}
diff --git a/internal/extproc/processor.go b/internal/extproc/processor.go
index bf1610e..3952109 100644
--- a/internal/extproc/processor.go
+++ b/internal/extproc/processor.go
@@ -1,25 +1,18 @@
 package extproc
 
 import (
-	"bytes"
-	"compress/gzip"
 	"context"
-	"fmt"
-	"io"
 	"log/slog"
-	"unicode/utf8"
 
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
 	"github.com/google/cel-go/cel"
-	"google.golang.org/protobuf/types/known/structpb"
 
 	"github.com/envoyproxy/ai-gateway/filterapi"
 	"github.com/envoyproxy/ai-gateway/filterapi/x"
 	"github.com/envoyproxy/ai-gateway/internal/extproc/backendauth"
 	"github.com/envoyproxy/ai-gateway/internal/extproc/router"
 	"github.com/envoyproxy/ai-gateway/internal/extproc/translator"
-	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
 )
 
 // processorConfig is the configuration for the processor.
@@ -33,13 +26,18 @@ type processorConfig struct {
 	backendAuthHandlers                          map[string]backendauth.Handler
 	metadataNamespace                            string
 	requestCosts                                 []processorConfigRequestCost
+	declaredModels                               []string
 }
 
+// processorConfigRequestCost is the configuration for the request cost.
 type processorConfigRequestCost struct {
 	*filterapi.LLMRequestCost
 	celProg cel.Program
 }
 
+// ProcessorFactory is the factory function used to create new instances of a processor.
+type ProcessorFactory func(*processorConfig, map[string]string, *slog.Logger) ProcessorIface
+
 // ProcessorIface is the interface for the processor.
 // This decouples the processor implementation detail from the server implementation.
 type ProcessorIface interface {
@@ -52,222 +50,3 @@ type ProcessorIface interface {
 	// ProcessResponseBody processes the response body message.
 	ProcessResponseBody(context.Context, *extprocv3.HttpBody) (*extprocv3.ProcessingResponse, error)
 }
-
-// NewProcessor creates a new processor.
-func NewProcessor(config *processorConfig, logger *slog.Logger) *Processor {
-	return &Processor{config: config, logger: logger}
-}
-
-// Processor handles the processing of the request and response messages for a single stream.
-type Processor struct {
-	logger           *slog.Logger
-	config           *processorConfig
-	requestHeaders   map[string]string
-	responseHeaders  map[string]string
-	responseEncoding string
-	translator       translator.Translator
-	// cost is the cost of the request that is accumulated during the processing of the response.
-	costs translator.LLMTokenUsage
-}
-
-// ProcessRequestHeaders implements [Processor.ProcessRequestHeaders].
-func (p *Processor) ProcessRequestHeaders(_ context.Context, headers *corev3.HeaderMap) (res *extprocv3.ProcessingResponse, err error) {
-	p.requestHeaders = headersToMap(headers)
-	resp := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{
-		RequestHeaders: &extprocv3.HeadersResponse{},
-	}}
-	return resp, nil
-}
-
-// ProcessRequestBody implements [Processor.ProcessRequestBody].
-func (p *Processor) ProcessRequestBody(ctx context.Context, rawBody *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) {
-	path := p.requestHeaders[":path"]
-	model, body, err := p.config.bodyParser(path, rawBody)
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse request body: %w", err)
-	}
-	p.logger.Info("Processing request", "path", path, "model", model)
-
-	p.requestHeaders[p.config.modelNameHeaderKey] = model
-	b, err := p.config.router.Calculate(p.requestHeaders)
-	if err != nil {
-		return nil, fmt.Errorf("failed to calculate route: %w", err)
-	}
-	p.logger.Info("Selected backend", "backend", b.Name)
-
-	factory, ok := p.config.factories[b.Schema]
-	if !ok {
-		return nil, fmt.Errorf("failed to find factory for output schema %q", b.Schema)
-	}
-
-	t, err := factory(path)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create translator: %w", err)
-	}
-	p.translator = t
-
-	headerMutation, bodyMutation, override, err := p.translator.RequestBody(body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to transform request: %w", err)
-	}
-
-	if headerMutation == nil {
-		headerMutation = &extprocv3.HeaderMutation{}
-	}
-	// Set the model name to the request header with the key `x-ai-gateway-llm-model-name`.
-	headerMutation.SetHeaders = append(headerMutation.SetHeaders, &corev3.HeaderValueOption{
-		Header: &corev3.HeaderValue{Key: p.config.modelNameHeaderKey, RawValue: []byte(model)},
-	}, &corev3.HeaderValueOption{
-		Header: &corev3.HeaderValue{Key: p.config.selectedBackendHeaderKey, RawValue: []byte(b.Name)},
-	})
-
-	if authHandler, ok := p.config.backendAuthHandlers[b.Name]; ok {
-		if err := authHandler.Do(ctx, p.requestHeaders, headerMutation, bodyMutation); err != nil {
-			return nil, fmt.Errorf("failed to do auth request: %w", err)
-		}
-	}
-
-	resp := &extprocv3.ProcessingResponse{
-		Response: &extprocv3.ProcessingResponse_RequestBody{
-			RequestBody: &extprocv3.BodyResponse{
-				Response: &extprocv3.CommonResponse{
-					HeaderMutation:  headerMutation,
-					BodyMutation:    bodyMutation,
-					ClearRouteCache: true,
-				},
-			},
-		},
-		ModeOverride: override,
-	}
-	return resp, nil
-}
-
-// ProcessResponseHeaders implements [Processor.ProcessResponseHeaders].
-func (p *Processor) ProcessResponseHeaders(_ context.Context, headers *corev3.HeaderMap) (res *extprocv3.ProcessingResponse, err error) {
-	p.responseHeaders = headersToMap(headers)
-	if enc := p.responseHeaders["content-encoding"]; enc != "" {
-		p.responseEncoding = enc
-	}
-	// The translator can be nil as there could be response event generated by previous ext proc without
-	// getting the request event.
-	if p.translator == nil {
-		return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{
-			ResponseHeaders: &extprocv3.HeadersResponse{},
-		}}, nil
-	}
-	headerMutation, err := p.translator.ResponseHeaders(p.responseHeaders)
-	if err != nil {
-		return nil, fmt.Errorf("failed to transform response headers: %w", err)
-	}
-	return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{
-		ResponseHeaders: &extprocv3.HeadersResponse{
-			Response: &extprocv3.CommonResponse{HeaderMutation: headerMutation},
-		},
-	}}, nil
-}
-
-// ProcessResponseBody implements [Processor.ProcessResponseBody].
-func (p *Processor) ProcessResponseBody(_ context.Context, body *extprocv3.HttpBody) (res *extprocv3.ProcessingResponse, err error) {
-	var br io.Reader
-	switch p.responseEncoding {
-	case "gzip":
-		br, err = gzip.NewReader(bytes.NewReader(body.Body))
-		if err != nil {
-			return nil, fmt.Errorf("failed to decode gzip: %w", err)
-		}
-	default:
-		br = bytes.NewReader(body.Body)
-	}
-	// The translator can be nil as there could be response event generated by previous ext proc without
-	// getting the request event.
-	if p.translator == nil {
-		return &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseBody{}}, nil
-	}
-
-	headerMutation, bodyMutation, tokenUsage, err := p.translator.ResponseBody(p.responseHeaders, br, body.EndOfStream)
-	if err != nil {
-		return nil, fmt.Errorf("failed to transform response: %w", err)
-	}
-
-	resp := &extprocv3.ProcessingResponse{
-		Response: &extprocv3.ProcessingResponse_ResponseBody{
-			ResponseBody: &extprocv3.BodyResponse{
-				Response: &extprocv3.CommonResponse{
-					HeaderMutation: headerMutation,
-					BodyMutation:   bodyMutation,
-				},
-			},
-		},
-	}
-
-	// TODO: this is coupled with "LLM" specific logic. Once we have another use case, we need to refactor this.
-	p.costs.InputTokens += tokenUsage.InputTokens
-	p.costs.OutputTokens += tokenUsage.OutputTokens
-	p.costs.TotalTokens += tokenUsage.TotalTokens
-	if body.EndOfStream && len(p.config.requestCosts) > 0 {
-		resp.DynamicMetadata, err = p.maybeBuildDynamicMetadata()
-		if err != nil {
-			return nil, fmt.Errorf("failed to build dynamic metadata: %w", err)
-		}
-	}
-	return resp, nil
-}
-
-func (p *Processor) maybeBuildDynamicMetadata() (*structpb.Struct, error) {
-	metadata := make(map[string]*structpb.Value, len(p.config.requestCosts))
-	for i := range p.config.requestCosts {
-		c := &p.config.requestCosts[i]
-		var cost uint32
-		switch c.Type {
-		case filterapi.LLMRequestCostTypeInputToken:
-			cost = p.costs.InputTokens
-		case filterapi.LLMRequestCostTypeOutputToken:
-			cost = p.costs.OutputTokens
-		case filterapi.LLMRequestCostTypeTotalToken:
-			cost = p.costs.TotalTokens
-		case filterapi.LLMRequestCostTypeCELExpression:
-			costU64, err := llmcostcel.EvaluateProgram(
-				c.celProg,
-				p.requestHeaders[p.config.modelNameHeaderKey],
-				p.requestHeaders[p.config.selectedBackendHeaderKey],
-				p.costs.InputTokens,
-				p.costs.OutputTokens,
-				p.costs.TotalTokens,
-			)
-			if err != nil {
-				return nil, fmt.Errorf("failed to evaluate CEL expression: %w", err)
-			}
-			cost = uint32(costU64) //nolint:gosec
-		default:
-			return nil, fmt.Errorf("unknown request cost kind: %s", c.Type)
-		}
-		p.logger.Info("Setting request cost metadata", "type", c.Type, "cost", cost, "metadataKey", c.MetadataKey)
-		metadata[c.MetadataKey] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(cost)}}
-	}
-	if len(metadata) == 0 {
-		return nil, nil
-	}
-	return &structpb.Struct{
-		Fields: map[string]*structpb.Value{
-			p.config.metadataNamespace: {
-				Kind: &structpb.Value_StructValue{
-					StructValue: &structpb.Struct{Fields: metadata},
-				},
-			},
-		},
-	}, nil
-}
-
-// headersToMap converts a [corev3.HeaderMap] to a Go map for easier processing.
-func headersToMap(headers *corev3.HeaderMap) map[string]string {
-	// TODO: handle multiple headers with the same key.
-	hdrs := make(map[string]string)
-	for _, h := range headers.GetHeaders() {
-		if len(h.Value) > 0 {
-			hdrs[h.GetKey()] = h.Value
-		} else if utf8.Valid(h.RawValue) {
-			hdrs[h.GetKey()] = string(h.RawValue)
-		}
-	}
-	return hdrs
-}
diff --git a/internal/extproc/server.go b/internal/extproc/server.go
index 68627d7..463411f 100644
--- a/internal/extproc/server.go
+++ b/internal/extproc/server.go
@@ -8,6 +8,7 @@ import (
 	"log/slog"
 	"slices"
 	"strings"
+	"unicode/utf8"
 
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
@@ -15,6 +16,7 @@ import (
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/health/grpc_health_v1"
 	"google.golang.org/grpc/status"
+	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
 
 	"github.com/envoyproxy/ai-gateway/filterapi"
 	"github.com/envoyproxy/ai-gateway/filterapi/x"
@@ -31,20 +33,23 @@ const (
 var sensitiveHeaderKeys = []string{"authorization"}
 
 // Server implements the external process server.
-type Server[P ProcessorIface] struct {
-	logger       *slog.Logger
-	config       *processorConfig
-	newProcessor func(*processorConfig, *slog.Logger) P
+type Server struct {
+	logger     *slog.Logger
+	config     *processorConfig
+	processors map[string]ProcessorFactory
 }
 
 // NewServer creates a new external processor server.
-func NewServer[P ProcessorIface](logger *slog.Logger, newProcessor func(*processorConfig, *slog.Logger) P) (*Server[P], error) {
-	srv := &Server[P]{logger: logger, newProcessor: newProcessor}
+func NewServer(logger *slog.Logger) (*Server, error) {
+	srv := &Server{
+		logger:     logger,
+		processors: make(map[string]ProcessorFactory),
+	}
 	return srv, nil
 }
 
 // LoadConfig updates the configuration of the external processor.
-func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) error {
+func (s *Server) LoadConfig(ctx context.Context, config *filterapi.Config) error {
 	bodyParser, err := router.NewRequestBodyParser(config.Schema)
 	if err != nil {
 		return fmt.Errorf("cannot create request body parser: %w", err)
@@ -54,8 +59,11 @@ func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) er
 		return fmt.Errorf("cannot create router: %w", err)
 	}
 
-	factories := make(map[filterapi.VersionedAPISchema]translator.Factory)
-	backendAuthHandlers := make(map[string]backendauth.Handler)
+	var (
+		factories           = make(map[filterapi.VersionedAPISchema]translator.Factory)
+		backendAuthHandlers = make(map[string]backendauth.Handler)
+		declaredModels      []string
+	)
 	for _, r := range config.Rules {
 		for _, b := range r.Backends {
 			if _, ok := factories[b.Schema]; !ok {
@@ -72,6 +80,18 @@ func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) er
 				}
 			}
 		}
+		// Collect declared models from configured header routes. These will be used to
+		// serve requests to the /v1/models endpoint.
+		// TODO(nacx): note that currently we only support exact matching in the headers. When
+		// header matching is extended, this will need to be updated.
+		for _, h := range r.Headers {
+			// If explicitly set to something that is not an exact match, skip.
+			// If not set, we assume it's an exact match.
+			if h.Type != nil && *h.Type != gwapiv1.HeaderMatchExact {
+				continue
+			}
+			declaredModels = append(declaredModels, h.Value)
+		}
 	}
 
 	costs := make([]processorConfigRequestCost, 0, len(config.LLMRequestCosts))
@@ -96,20 +116,37 @@ func (s *Server[P]) LoadConfig(ctx context.Context, config *filterapi.Config) er
 		backendAuthHandlers:      backendAuthHandlers,
 		metadataNamespace:        config.MetadataNamespace,
 		requestCosts:             costs,
+		declaredModels:           declaredModels,
 	}
 	s.config = newConfig // This is racey, but we don't care.
 	return nil
 }
 
-// Process implements [extprocv3.ExternalProcessorServer].
-func (s *Server[P]) Process(stream extprocv3.ExternalProcessor_ProcessServer) error {
-	p := s.newProcessor(s.config, s.logger)
-	s.logger.Debug("handling a new stream", slog.Any("config_uuid", s.config.uuid))
-	return s.process(p, stream)
+// Register a new processor for the given request path.
+func (s *Server) Register(path string, newProcessor ProcessorFactory) {
+	s.processors[path] = newProcessor
 }
 
-func (s *Server[P]) process(p P, stream extprocv3.ExternalProcessor_ProcessServer) error {
+// processorForPath returns the processor for the given path.
+// Only exact path matching is supported currently
+func (s *Server) processorForPath(requestHeaders map[string]string) (ProcessorIface, error) {
+	path := requestHeaders[":path"]
+	newProcessor, ok := s.processors[path]
+	if !ok {
+		return nil, fmt.Errorf("no processor defined for path: %v", path)
+	}
+	return newProcessor(s.config, requestHeaders, s.logger), nil
+}
+
+// Process implements [extprocv3.ExternalProcessorServer].
+func (s *Server) Process(stream extprocv3.ExternalProcessor_ProcessServer) error {
+	s.logger.Debug("handling a new stream", slog.Any("config_uuid", s.config.uuid))
 	ctx := stream.Context()
+
+	// The processor will be instantiated when the first message containing the request headers is received.
+	// The :path header is used to determine the processor to use, based on the registered ones.
+	var p ProcessorIface
+
 	for {
 		select {
 		case <-ctx.Done():
@@ -125,6 +162,19 @@ func (s *Server[P]) process(p P, stream extprocv3.ExternalProcessor_ProcessServe
 			return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err)
 		}
 
+		// If we're processing the request headers, read the :path header to instantiate the
+		// right processor.
+		// Note that `req.GetRequestHeaders()` will only return non-nil if the request is
+		// of type `ProcessingRequest_RequestHeaders`, so this will be executed only once per
+		// request, and the processor will be instantiated only once.
+		if headers := req.GetRequestHeaders().GetHeaders(); headers != nil {
+			p, err = s.processorForPath(headersToMap(headers))
+			if err != nil {
+				s.logger.Error("cannot get processor", slog.String("error", err.Error()))
+				return status.Error(codes.NotFound, err.Error())
+			}
+		}
+
 		resp, err := s.processMsg(ctx, p, req)
 		if err != nil {
 			s.logger.Error("error processing request message", slog.String("error", err.Error()))
@@ -137,7 +187,7 @@ func (s *Server[P]) process(p P, stream extprocv3.ExternalProcessor_ProcessServe
 	}
 }
 
-func (s *Server[P]) processMsg(ctx context.Context, p P, req *extprocv3.ProcessingRequest) (*extprocv3.ProcessingResponse, error) {
+func (s *Server) processMsg(ctx context.Context, p ProcessorIface, req *extprocv3.ProcessingRequest) (*extprocv3.ProcessingResponse, error) {
 	switch value := req.Request.(type) {
 	case *extprocv3.ProcessingRequest_RequestHeaders:
 		requestHdrs := req.GetRequestHeaders().Headers
@@ -188,12 +238,12 @@ func (s *Server[P]) processMsg(ctx context.Context, p P, req *extprocv3.Processi
 }
 
 // Check implements [grpc_health_v1.HealthServer].
-func (s *Server[P]) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
+func (s *Server) Check(context.Context, *grpc_health_v1.HealthCheckRequest) (*grpc_health_v1.HealthCheckResponse, error) {
 	return &grpc_health_v1.HealthCheckResponse{Status: grpc_health_v1.HealthCheckResponse_SERVING}, nil
 }
 
 // Watch implements [grpc_health_v1.HealthServer].
-func (s *Server[P]) Watch(*grpc_health_v1.HealthCheckRequest, grpc_health_v1.Health_WatchServer) error {
+func (s *Server) Watch(*grpc_health_v1.HealthCheckRequest, grpc_health_v1.Health_WatchServer) error {
 	return status.Error(codes.Unimplemented, "Watch is not implemented")
 }
 
@@ -251,3 +301,17 @@ func filterSensitiveBody(resp *extprocv3.ProcessingResponse, logger *slog.Logger
 	}
 	return filteredResp
 }
+
+// headersToMap converts a [corev3.HeaderMap] to a Go map for easier processing.
+func headersToMap(headers *corev3.HeaderMap) map[string]string {
+	// TODO: handle multiple headers with the same key.
+	hdrs := make(map[string]string)
+	for _, h := range headers.GetHeaders() {
+		if len(h.Value) > 0 {
+			hdrs[h.GetKey()] = h.Value
+		} else if utf8.Valid(h.RawValue) {
+			hdrs[h.GetKey()] = string(h.RawValue)
+		}
+	}
+	return hdrs
+}


Test patch:
diff --git a/cmd/extproc/mainlib/main_test.go b/cmd/extproc/mainlib/main_test.go
index 4aa24b0..59b0350 100644
--- a/cmd/extproc/mainlib/main_test.go
+++ b/cmd/extproc/mainlib/main_test.go
@@ -5,10 +5,11 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func Test_parseAndValidateFlags(t *testing.T) {
-	t.Run("ok flags", func(t *testing.T) {
+	t.Run("ok extProcFlags", func(t *testing.T) {
 		for _, tc := range []struct {
 			name       string
 			args       []string
@@ -17,7 +18,7 @@ func Test_parseAndValidateFlags(t *testing.T) {
 			logLevel   slog.Level
 		}{
 			{
-				name:       "minimal flags",
+				name:       "minimal extProcFlags",
 				args:       []string{"-configPath", "/path/to/config.yaml"},
 				configPath: "/path/to/config.yaml",
 				addr:       ":1063",
@@ -52,44 +53,31 @@ func Test_parseAndValidateFlags(t *testing.T) {
 				logLevel:   slog.LevelError,
 			},
 			{
-				name:       "all flags",
-				args:       []string{"-configPath", "/path/to/config.yaml", "-extProcAddr", "unix:///tmp/ext_proc.sock", "-logLevel", "debug"},
+				name: "all extProcFlags",
+				args: []string{
+					"-configPath", "/path/to/config.yaml",
+					"-extProcAddr", "unix:///tmp/ext_proc.sock",
+					"-logLevel", "debug",
+				},
 				configPath: "/path/to/config.yaml",
 				addr:       "unix:///tmp/ext_proc.sock",
 				logLevel:   slog.LevelDebug,
 			},
 		} {
 			t.Run(tc.name, func(t *testing.T) {
-				configPath, addr, logLevel, err := parseAndValidateFlags(tc.args)
-				assert.Equal(t, tc.configPath, configPath)
-				assert.Equal(t, tc.addr, addr)
-				assert.Equal(t, tc.logLevel, logLevel)
-				assert.NoError(t, err)
+				flags, err := parseAndValidateFlags(tc.args)
+				require.NoError(t, err)
+				assert.Equal(t, tc.configPath, flags.configPath)
+				assert.Equal(t, tc.addr, flags.extProcAddr)
+				assert.Equal(t, tc.logLevel, flags.logLevel)
 			})
 		}
 	})
-	t.Run("invalid flags", func(t *testing.T) {
-		for _, tc := range []struct {
-			name   string
-			flags  []string
-			expErr string
-		}{
-			{
-				name:   "missing configPath",
-				flags:  []string{"-extProcAddr", ":1063"},
-				expErr: "configPath must be provided",
-			},
-			{
-				name:   "invalid logLevel",
-				flags:  []string{"-configPath", "/path/to/config.yaml", "-logLevel", "invalid"},
-				expErr: `failed to unmarshal log level: slog: level string "invalid": unknown name`,
-			},
-		} {
-			t.Run(tc.name, func(t *testing.T) {
-				_, _, _, err := parseAndValidateFlags(tc.flags)
-				assert.EqualError(t, err, tc.expErr)
-			})
-		}
+
+	t.Run("invalid extProcFlags", func(t *testing.T) {
+		_, err := parseAndValidateFlags([]string{"-logLevel", "invalid"})
+		assert.EqualError(t, err, `configPath must be provided
+failed to unmarshal log level: slog: level string "invalid": unknown name`)
 	})
 }
 
diff --git a/filterapi/filterconfig_test.go b/filterapi/filterconfig_test.go
index 826528a..830a363 100644
--- a/filterapi/filterconfig_test.go
+++ b/filterapi/filterconfig_test.go
@@ -14,7 +14,7 @@ import (
 )
 
 func TestDefaultConfig(t *testing.T) {
-	server, err := extproc.NewServer(slog.Default(), extproc.NewProcessor)
+	server, err := extproc.NewServer(slog.Default())
 	require.NoError(t, err)
 	require.NotNil(t, server)
 
diff --git a/internal/apischema/openai/openai_test.go b/internal/apischema/openai/openai_test.go
index 431a0e6..f57b36b 100644
--- a/internal/apischema/openai/openai_test.go
+++ b/internal/apischema/openai/openai_test.go
@@ -3,6 +3,7 @@ package openai
 import (
 	"encoding/json"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/openai/openai-go"
@@ -234,3 +235,30 @@ func TestOpenAIChatCompletionMessageUnmarshal(t *testing.T) {
 		})
 	}
 }
+
+func TestModelListMarshal(t *testing.T) {
+	var (
+		model = Model{
+			ID:      "gpt-3.5-turbo",
+			Object:  "model",
+			OwnedBy: "tetrate",
+			Created: JSONUNIXTime(time.Date(2025, 0o1, 0o1, 0, 0, 0, 0, time.UTC)),
+		}
+		list = ModelList{Object: "list", Data: []Model{model}}
+		raw  = `{"object":"list","data":[{"id":"gpt-3.5-turbo","object":"model","owned_by":"tetrate","created":1735689600}]}`
+	)
+
+	b, err := json.Marshal(list)
+	require.NoError(t, err)
+	require.JSONEq(t, raw, string(b))
+
+	var out ModelList
+	require.NoError(t, json.Unmarshal([]byte(raw), &out))
+	require.Len(t, out.Data, 1)
+	require.Equal(t, "list", out.Object)
+	require.Equal(t, model.ID, out.Data[0].ID)
+	require.Equal(t, model.Object, out.Data[0].Object)
+	require.Equal(t, model.OwnedBy, out.Data[0].OwnedBy)
+	// Unmarshalling initializes other fields in time.Time we're not interested with. Just compare the actual time.
+	require.Equal(t, time.Time(model.Created).Unix(), time.Time(out.Data[0].Created).Unix())
+}
diff --git a/internal/extproc/processor_test.go b/internal/extproc/chatcompletion_processor_test.go
similarity index 88%
rename from internal/extproc/processor_test.go
rename to internal/extproc/chatcompletion_processor_test.go
index dbb4174..5c38dc7 100644
--- a/internal/extproc/processor_test.go
+++ b/internal/extproc/chatcompletion_processor_test.go
@@ -16,21 +16,20 @@ import (
 	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
 )
 
-func TestProcessor_ProcessRequestHeaders(t *testing.T) {
-	p := &Processor{}
+func TestChatCompletion_ProcessRequestHeaders(t *testing.T) {
+	p := &chatCompletionProcessor{}
 	res, err := p.ProcessRequestHeaders(t.Context(), &corev3.HeaderMap{
 		Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}},
 	})
 	require.NoError(t, err)
 	_, ok := res.Response.(*extprocv3.ProcessingResponse_RequestHeaders)
 	require.True(t, ok)
-	require.Equal(t, map[string]string{"foo": "bar"}, p.requestHeaders)
 }
 
-func TestProcessor_ProcessResponseHeaders(t *testing.T) {
+func TestChatCompletion_ProcessResponseHeaders(t *testing.T) {
 	t.Run("error translation", func(t *testing.T) {
 		mt := &mockTranslator{t: t, expHeaders: make(map[string]string)}
-		p := &Processor{translator: mt}
+		p := &chatCompletionProcessor{translator: mt}
 		mt.retErr = errors.New("test error")
 		_, err := p.ProcessResponseHeaders(t.Context(), nil)
 		require.ErrorContains(t, err, "test error")
@@ -41,7 +40,7 @@ func TestProcessor_ProcessResponseHeaders(t *testing.T) {
 		}
 		expHeaders := map[string]string{"foo": "bar", "dog": "cat"}
 		mt := &mockTranslator{t: t, expHeaders: expHeaders}
-		p := &Processor{translator: mt}
+		p := &chatCompletionProcessor{translator: mt}
 		res, err := p.ProcessResponseHeaders(t.Context(), inHeaders)
 		require.NoError(t, err)
 		commonRes := res.Response.(*extprocv3.ProcessingResponse_ResponseHeaders).ResponseHeaders.Response
@@ -49,10 +48,10 @@ func TestProcessor_ProcessResponseHeaders(t *testing.T) {
 	})
 }
 
-func TestProcessor_ProcessResponseBody(t *testing.T) {
+func TestChatCompletion_ProcessResponseBody(t *testing.T) {
 	t.Run("error translation", func(t *testing.T) {
 		mt := &mockTranslator{t: t}
-		p := &Processor{translator: mt}
+		p := &chatCompletionProcessor{translator: mt}
 		mt.retErr = errors.New("test error")
 		_, err := p.ProcessResponseBody(t.Context(), &extprocv3.HttpBody{})
 		require.ErrorContains(t, err, "test error")
@@ -71,7 +70,7 @@ func TestProcessor_ProcessResponseBody(t *testing.T) {
 		require.NoError(t, err)
 		celProgUint, err := llmcostcel.NewProgram("uint(9999)")
 		require.NoError(t, err)
-		p := &Processor{translator: mt, logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), config: &processorConfig{
+		p := &chatCompletionProcessor{translator: mt, logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), config: &processorConfig{
 			metadataNamespace: "ai_gateway_llm_ns",
 			requestCosts: []processorConfigRequestCost{
 				{LLMRequestCost: &filterapi.LLMRequestCost{Type: filterapi.LLMRequestCostTypeOutputToken, MetadataKey: "output_token_usage"}},
@@ -105,10 +104,10 @@ func TestProcessor_ProcessResponseBody(t *testing.T) {
 	})
 }
 
-func TestProcessor_ProcessRequestBody(t *testing.T) {
+func TestChatCompletion_ProcessRequestBody(t *testing.T) {
 	t.Run("body parser error", func(t *testing.T) {
 		rbp := mockRequestBodyParser{t: t, retErr: errors.New("test error")}
-		p := &Processor{config: &processorConfig{bodyParser: rbp.impl}}
+		p := &chatCompletionProcessor{config: &processorConfig{bodyParser: rbp.impl}}
 		_, err := p.ProcessRequestBody(t.Context(), &extprocv3.HttpBody{})
 		require.ErrorContains(t, err, "failed to parse request body: test error")
 	})
@@ -116,7 +115,7 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 		headers := map[string]string{":path": "/foo"}
 		rbp := mockRequestBodyParser{t: t, retModelName: "some-model", expPath: "/foo"}
 		rt := mockRouter{t: t, expHeaders: headers, retErr: errors.New("test error")}
-		p := &Processor{config: &processorConfig{bodyParser: rbp.impl, router: rt}, requestHeaders: headers, logger: slog.Default()}
+		p := &chatCompletionProcessor{config: &processorConfig{bodyParser: rbp.impl, router: rt}, requestHeaders: headers, logger: slog.Default()}
 		_, err := p.ProcessRequestBody(t.Context(), &extprocv3.HttpBody{})
 		require.ErrorContains(t, err, "failed to calculate route: test error")
 	})
@@ -127,7 +126,7 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 			t: t, expHeaders: headers, retBackendName: "some-backend",
 			retVersionedAPISchema: filterapi.VersionedAPISchema{Name: "some-schema", Version: "v10.0"},
 		}
-		p := &Processor{config: &processorConfig{
+		p := &chatCompletionProcessor{config: &processorConfig{
 			bodyParser: rbp.impl, router: rt,
 			factories: make(map[filterapi.VersionedAPISchema]translator.Factory),
 		}, requestHeaders: headers, logger: slog.Default()}
@@ -142,7 +141,7 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 			retVersionedAPISchema: filterapi.VersionedAPISchema{Name: "some-schema", Version: "v10.0"},
 		}
 		factory := mockTranslatorFactory{t: t, retErr: errors.New("test error"), expPath: "/foo"}
-		p := &Processor{config: &processorConfig{
+		p := &chatCompletionProcessor{config: &processorConfig{
 			bodyParser: rbp.impl, router: rt,
 			factories: map[filterapi.VersionedAPISchema]translator.Factory{
 				{Name: "some-schema", Version: "v10.0"}: factory.impl,
@@ -159,7 +158,7 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 			retVersionedAPISchema: filterapi.VersionedAPISchema{Name: "some-schema", Version: "v10.0"},
 		}
 		factory := mockTranslatorFactory{t: t, retTranslator: mockTranslator{t: t, retErr: errors.New("test error")}, expPath: "/foo"}
-		p := &Processor{config: &processorConfig{
+		p := &chatCompletionProcessor{config: &processorConfig{
 			bodyParser: rbp.impl, router: rt,
 			factories: map[filterapi.VersionedAPISchema]translator.Factory{
 				{Name: "some-schema", Version: "v10.0"}: factory.impl,
@@ -180,7 +179,7 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 		bodyMut := &extprocv3.BodyMutation{}
 		mt := mockTranslator{t: t, expRequestBody: someBody, retHeaderMutation: headerMut, retBodyMutation: bodyMut}
 		factory := mockTranslatorFactory{t: t, retTranslator: mt, expPath: "/foo"}
-		p := &Processor{config: &processorConfig{
+		p := &chatCompletionProcessor{config: &processorConfig{
 			bodyParser: rbp.impl, router: rt,
 			factories: map[filterapi.VersionedAPISchema]translator.Factory{
 				{Name: "some-schema", Version: "v10.0"}: factory.impl,
@@ -205,14 +204,3 @@ func TestProcessor_ProcessRequestBody(t *testing.T) {
 		require.Equal(t, "some-backend", string(hdrs[1].Header.RawValue))
 	})
 }
-
-func Test_headersToMap(t *testing.T) {
-	hm := &corev3.HeaderMap{
-		Headers: []*corev3.HeaderValue{
-			{Key: "foo", Value: "bar"},
-			{Key: "dog", RawValue: []byte("cat")},
-		},
-	}
-	m := headersToMap(hm)
-	require.Equal(t, map[string]string{"foo": "bar", "dog": "cat"}, m)
-}
diff --git a/internal/extproc/mocks_test.go b/internal/extproc/mocks_test.go
index c731d80..2f581cb 100644
--- a/internal/extproc/mocks_test.go
+++ b/internal/extproc/mocks_test.go
@@ -24,7 +24,7 @@ var (
 	_ x.Router              = &mockRouter{}
 )
 
-func newMockProcessor(_ *processorConfig, _ *slog.Logger) *mockProcessor {
+func newMockProcessor(_ *processorConfig, _ *slog.Logger) ProcessorIface {
 	return &mockProcessor{}
 }
 
@@ -37,25 +37,25 @@ type mockProcessor struct {
 	retErr                error
 }
 
-// ProcessRequestHeaders implements [Processor.ProcessRequestHeaders].
+// ProcessRequestHeaders implements [ProcessorIface.ProcessRequestHeaders].
 func (m mockProcessor) ProcessRequestHeaders(_ context.Context, headerMap *corev3.HeaderMap) (*extprocv3.ProcessingResponse, error) {
 	require.Equal(m.t, m.expHeaderMap, headerMap)
 	return m.retProcessingResponse, m.retErr
 }
 
-// ProcessRequestBody implements [Processor.ProcessRequestBody].
+// ProcessRequestBody implements [ProcessorIface.ProcessRequestBody].
 func (m mockProcessor) ProcessRequestBody(_ context.Context, body *extprocv3.HttpBody) (*extprocv3.ProcessingResponse, error) {
 	require.Equal(m.t, m.expBody, body)
 	return m.retProcessingResponse, m.retErr
 }
 
-// ProcessResponseHeaders implements [Processor.ProcessResponseHeaders].
+// ProcessResponseHeaders implements [ProcessorIface.ProcessResponseHeaders].
 func (m mockProcessor) ProcessResponseHeaders(_ context.Context, headerMap *corev3.HeaderMap) (*extprocv3.ProcessingResponse, error) {
 	require.Equal(m.t, m.expHeaderMap, headerMap)
 	return m.retProcessingResponse, m.retErr
 }
 
-// ProcessResponseBody implements [Processor.ProcessResponseBody].
+// ProcessResponseBody implements [ProcessorIface.ProcessResponseBody].
 func (m mockProcessor) ProcessResponseBody(_ context.Context, body *extprocv3.HttpBody) (*extprocv3.ProcessingResponse, error) {
 	require.Equal(m.t, m.expBody, body)
 	return m.retProcessingResponse, m.retErr
diff --git a/internal/extproc/models_processor_test.go b/internal/extproc/models_processor_test.go
new file mode 100644
index 0000000..5a1615d
--- /dev/null
+++ b/internal/extproc/models_processor_test.go
@@ -0,0 +1,61 @@
+package extproc
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"testing"
+	"time"
+
+	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
+	"github.com/stretchr/testify/require"
+
+	"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
+)
+
+func TestModels_ProcessRequestHeaders(t *testing.T) {
+	cfg := &processorConfig{declaredModels: []string{"openai", "aws-bedrock"}}
+	p := NewModelsProcessor(cfg, nil, slog.Default())
+	res, err := p.ProcessRequestHeaders(context.Background(), &corev3.HeaderMap{
+		Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}},
+	})
+	require.NoError(t, err)
+
+	ir, ok := res.Response.(*extprocv3.ProcessingResponse_ImmediateResponse)
+	require.True(t, ok)
+	require.Equal(t, typev3.StatusCode(200), ir.ImmediateResponse.Status.Code)
+	require.Equal(t, uint32(0), ir.ImmediateResponse.GrpcStatus.Status)
+
+	respHeaders := headers(ir.ImmediateResponse.Headers.SetHeaders)
+	require.Equal(t, "application/json", respHeaders["content-type"])
+
+	var models openai.ModelList
+	require.NoError(t, json.Unmarshal(ir.ImmediateResponse.Body, &models))
+	require.Equal(t, "list", models.Object)
+	require.Len(t, models.Data, len(cfg.declaredModels))
+	for i, m := range cfg.declaredModels {
+		require.Equal(t, m, models.Data[i].ID)
+		require.Equal(t, "model", models.Data[i].Object)
+		require.False(t, time.Time(models.Data[i].Created).IsZero())
+	}
+}
+
+func TestModels_UnimplementedMethods(t *testing.T) {
+	p := &modelsProcessor{}
+	_, err := p.ProcessRequestBody(context.Background(), &extprocv3.HttpBody{})
+	require.ErrorIs(t, err, errUnexpectedCall)
+	_, err = p.ProcessResponseHeaders(context.Background(), &corev3.HeaderMap{})
+	require.ErrorIs(t, err, errUnexpectedCall)
+	_, err = p.ProcessResponseBody(context.Background(), &extprocv3.HttpBody{})
+	require.ErrorIs(t, err, errUnexpectedCall)
+}
+
+func headers(in []*corev3.HeaderValueOption) map[string]string {
+	h := make(map[string]string)
+	for _, v := range in {
+		h[v.Header.Key] = string(v.Header.RawValue)
+	}
+	return h
+}
diff --git a/internal/extproc/server_test.go b/internal/extproc/server_test.go
index c63209a..53cf81c 100644
--- a/internal/extproc/server_test.go
+++ b/internal/extproc/server_test.go
@@ -19,17 +19,21 @@ import (
 	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
 )
 
-func requireNewServerWithMockProcessor(t *testing.T) *Server[*mockProcessor] {
-	s, err := NewServer[*mockProcessor](slog.Default(), newMockProcessor)
+func requireNewServerWithMockProcessor(t *testing.T) (*Server, *mockProcessor) {
+	s, err := NewServer(slog.Default())
 	require.NoError(t, err)
 	require.NotNil(t, s)
 	s.config = &processorConfig{}
-	return s
+
+	m := newMockProcessor(s.config, s.logger)
+	s.Register("/", func(*processorConfig, map[string]string, *slog.Logger) ProcessorIface { return m })
+
+	return s, m.(*mockProcessor)
 }
 
 func TestServer_LoadConfig(t *testing.T) {
 	t.Run("invalid input schema", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		err := s.LoadConfig(t.Context(), &filterapi.Config{
 			Schema: filterapi.VersionedAPISchema{Name: "some-invalid-schema"},
 		})
@@ -72,7 +76,7 @@ func TestServer_LoadConfig(t *testing.T) {
 				},
 			},
 		}
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		err := s.LoadConfig(t.Context(), config)
 		require.NoError(t, err)
 
@@ -100,7 +104,7 @@ func TestServer_LoadConfig(t *testing.T) {
 }
 
 func TestServer_Check(t *testing.T) {
-	s := requireNewServerWithMockProcessor(t)
+	s, _ := requireNewServerWithMockProcessor(t)
 
 	res, err := s.Check(t.Context(), nil)
 	require.NoError(t, err)
@@ -109,7 +113,7 @@ func TestServer_Check(t *testing.T) {
 }
 
 func TestServer_Watch(t *testing.T) {
-	s := requireNewServerWithMockProcessor(t)
+	s, _ := requireNewServerWithMockProcessor(t)
 
 	err := s.Watch(nil, nil)
 	require.Error(t, err)
@@ -118,14 +122,12 @@ func TestServer_Watch(t *testing.T) {
 
 func TestServer_processMsg(t *testing.T) {
 	t.Run("unknown request type", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 		_, err := s.processMsg(t.Context(), p, &extprocv3.ProcessingRequest{})
 		require.ErrorContains(t, err, "unknown request type")
 	})
 	t.Run("request headers", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 
 		hm := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}}}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{}}
@@ -141,8 +143,7 @@ func TestServer_processMsg(t *testing.T) {
 		require.Equal(t, expResponse, resp)
 	})
 	t.Run("request body", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 
 		reqBody := &extprocv3.HttpBody{}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestBody{}}
@@ -158,8 +159,7 @@ func TestServer_processMsg(t *testing.T) {
 		require.Equal(t, expResponse, resp)
 	})
 	t.Run("response headers", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 
 		hm := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}}}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{}}
@@ -175,8 +175,7 @@ func TestServer_processMsg(t *testing.T) {
 		require.Equal(t, expResponse, resp)
 	})
 	t.Run("error response headers", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 
 		hm := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":status", Value: "504"}}}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseHeaders{}}
@@ -192,8 +191,7 @@ func TestServer_processMsg(t *testing.T) {
 		require.Equal(t, expResponse, resp)
 	})
 	t.Run("response body", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
-		p := s.newProcessor(nil, slog.Default())
+		s, p := requireNewServerWithMockProcessor(t)
 
 		reqBody := &extprocv3.HttpBody{}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_ResponseBody{}}
@@ -212,7 +210,7 @@ func TestServer_processMsg(t *testing.T) {
 
 func TestServer_Process(t *testing.T) {
 	t.Run("context done", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		ctx, cancel := context.WithCancel(t.Context())
 		cancel()
 
@@ -221,32 +219,31 @@ func TestServer_Process(t *testing.T) {
 		require.ErrorContains(t, err, "context canceled")
 	})
 	t.Run("recv iof", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		ms := &mockExternalProcessingStream{t: t, retErr: io.EOF, ctx: t.Context()}
 		err := s.Process(ms)
 		require.NoError(t, err)
 	})
 	t.Run("recv canceled", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		ms := &mockExternalProcessingStream{t: t, retErr: status.Error(codes.Canceled, "someerror"), ctx: t.Context()}
 		err := s.Process(ms)
 		require.NoError(t, err)
 	})
 	t.Run("recv generic error", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, _ := requireNewServerWithMockProcessor(t)
 		ms := &mockExternalProcessingStream{t: t, retErr: errors.New("some error"), ctx: t.Context()}
 		err := s.Process(ms)
 		require.ErrorContains(t, err, "some error")
 	})
 
 	t.Run("ok", func(t *testing.T) {
-		s := requireNewServerWithMockProcessor(t)
+		s, p := requireNewServerWithMockProcessor(t)
 
 		ctx, cancel := context.WithTimeout(t.Context(), time.Second)
 		defer cancel()
 
-		p := s.newProcessor(nil, slog.Default())
-		hm := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: "foo", Value: "bar"}}}
+		hm := &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":path", Value: "/"}, {Key: "foo", Value: "bar"}}}
 		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{}}
 		p.t = t
 		p.expHeaderMap = hm
@@ -256,8 +253,64 @@ func TestServer_Process(t *testing.T) {
 			Request: &extprocv3.ProcessingRequest_RequestHeaders{RequestHeaders: &extprocv3.HttpHeaders{Headers: hm}},
 		}
 		ms := &mockExternalProcessingStream{t: t, ctx: ctx, retRecv: req, expResponseOnSend: expResponse}
-		err := s.process(p, ms)
-		require.Error(t, err, "context canceled")
+		err := s.Process(ms)
+		require.ErrorContains(t, err, "context deadline exceeded")
+	})
+}
+
+func TestServer_ProcessorSelection(t *testing.T) {
+	s, err := NewServer(slog.Default())
+	require.NoError(t, err)
+	require.NotNil(t, s)
+
+	s.config = &processorConfig{}
+	s.Register("/one", func(*processorConfig, map[string]string, *slog.Logger) ProcessorIface {
+		// Returning nil guarantees that the test will fail if this processor is selected
+		return nil
+	})
+	s.Register("/two", func(*processorConfig, map[string]string, *slog.Logger) ProcessorIface {
+		return &mockProcessor{
+			t:                     t,
+			expHeaderMap:          &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":path", Value: "/two"}}},
+			retProcessingResponse: &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{}},
+		}
+	})
+
+	t.Run("unknown path", func(t *testing.T) {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+		defer cancel()
+
+		req := &extprocv3.ProcessingRequest{
+			Request: &extprocv3.ProcessingRequest_RequestHeaders{
+				RequestHeaders: &extprocv3.HttpHeaders{
+					Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":path", Value: "/unknown"}}},
+				},
+			},
+		}
+		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{}}
+		ms := &mockExternalProcessingStream{t: t, ctx: ctx, retRecv: req, expResponseOnSend: expResponse}
+
+		err = s.Process(ms)
+		require.Equal(t, codes.NotFound, status.Convert(err).Code())
+		require.ErrorContains(t, err, "no processor defined for path: /unknown")
+	})
+
+	t.Run("known path", func(t *testing.T) {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+		defer cancel()
+
+		req := &extprocv3.ProcessingRequest{
+			Request: &extprocv3.ProcessingRequest_RequestHeaders{
+				RequestHeaders: &extprocv3.HttpHeaders{
+					Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":path", Value: "/two"}}},
+				},
+			},
+		}
+		expResponse := &extprocv3.ProcessingResponse{Response: &extprocv3.ProcessingResponse_RequestHeaders{}}
+		ms := &mockExternalProcessingStream{t: t, ctx: ctx, retRecv: req, expResponseOnSend: expResponse}
+
+		err = s.Process(ms)
+		require.ErrorContains(t, err, "context deadline exceeded")
 	})
 }
 
@@ -308,3 +361,14 @@ func TestFilterSensitiveBody(t *testing.T) {
 	}
 	require.Contains(t, buf.String(), "filtering sensitive header")
 }
+
+func Test_headersToMap(t *testing.T) {
+	hm := &corev3.HeaderMap{
+		Headers: []*corev3.HeaderValue{
+			{Key: "foo", Value: "bar"},
+			{Key: "dog", RawValue: []byte("cat")},
+		},
+	}
+	m := headersToMap(hm)
+	require.Equal(t, map[string]string{"foo": "bar", "dog": "cat"}, m)
+}
diff --git a/tests/extproc/testupstream_test.go b/tests/extproc/testupstream_test.go
index eb426fe..f14c074 100644
--- a/tests/extproc/testupstream_test.go
+++ b/tests/extproc/testupstream_test.go
@@ -4,6 +4,7 @@ package extproc
 
 import (
 	"encoding/base64"
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
@@ -16,6 +17,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/envoyproxy/ai-gateway/filterapi"
+	"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
 	"github.com/envoyproxy/ai-gateway/tests/internal/testupstreamlib"
 )
 
@@ -50,6 +52,14 @@ func TestWithTestUpstream(t *testing.T) {
 		},
 	})
 
+	expectedModels := openai.ModelList{
+		Object: "list",
+		Data: []openai.Model{
+			{ID: "openai", Object: "model", OwnedBy: "Envoy AI Gateway"},
+			{ID: "aws-bedrock", Object: "model", OwnedBy: "Envoy AI Gateway"},
+		},
+	}
+
 	requireExtProc(t, os.Stdout, extProcExecutablePath(), configPath)
 
 	for _, tc := range []struct {
@@ -82,6 +92,8 @@ func TestWithTestUpstream(t *testing.T) {
 		expStatus int
 		// expResponseBody is the expected body from the gateway to the client.
 		expResponseBody string
+		// expResponseBodyFunc is a function to check the response body. This can be used instead of the expResponseBody field.
+		expResponseBodyFunc func(require.TestingT, []byte)
 	}{
 		{
 			name:           "unknown path",
@@ -200,6 +212,14 @@ data: [DONE]
 			responseBody:    `{"message": "aws bedrock rate limit exceeded"}`,
 			expResponseBody: `{"type":"error","error":{"type":"ThrottledException","code":"429","message":"aws bedrock rate limit exceeded"}}`,
 		},
+		{
+			name:                "openai - /v1/models",
+			backend:             "openai",
+			path:                "/v1/models",
+			method:              http.MethodGet,
+			expStatus:           http.StatusOK,
+			expResponseBodyFunc: checkModelsIgnoringTimestamps(expectedModels),
+		},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			require.Eventually(t, func() bool {
@@ -229,6 +249,7 @@ data: [DONE]
 					t.Logf("unexpected status code: %d", resp.StatusCode)
 					return false
 				}
+
 				if tc.expResponseBody != "" {
 					body, err := io.ReadAll(resp.Body)
 					require.NoError(t, err)
@@ -236,9 +257,26 @@ data: [DONE]
 						fmt.Printf("unexpected response:\n%s", cmp.Diff(string(body), tc.expResponseBody))
 						return false
 					}
+				} else if tc.expResponseBodyFunc != nil {
+					body, err := io.ReadAll(resp.Body)
+					require.NoError(t, err)
+					tc.expResponseBodyFunc(t, body)
 				}
+
 				return true
 			}, 10*time.Second, 500*time.Millisecond)
 		})
 	}
 }
+
+func checkModelsIgnoringTimestamps(want openai.ModelList) func(t require.TestingT, body []byte) {
+	return func(t require.TestingT, body []byte) {
+		var models openai.ModelList
+		require.NoError(t, json.Unmarshal(body, &models))
+		require.Len(t, models.Data, len(want.Data))
+		for i := range models.Data {
+			models.Data[i].Created = want.Data[i].Created
+		}
+		require.Equal(t, want, models)
+	}
+}