feat: support Anthropic /v1/messages route on Copilot (#26911)

Implements
[AIGOV-481](https://linear.app/codercom/issue/AIGOV-481/featai-gateway-support-anthropic-v1messages-route-on-the-copilot).
Adds the Anthropic-style `/v1/messages` route to the Copilot provider.

GitHub Copilot CLI (1.0.65) using the default `Claude Sonnet 4.6
(default)` model sends Anthropic-style `/v1/messages` requests through
`aibridgeproxyd` to the Copilot provider. The provider only registered
the OpenAI-compatible `/chat/completions` and `/responses` routes, so
these requests failed:

```
CAPIError: 404 404 404 route not supported: POST /copilot/v1/messages
```

*This PR was produced by opencode (agent) using the
`anthropic/claude-opus-4-8` model, under human direction and review.*
This commit is contained in:
Danny Kopping
2026-07-01 12:16:32 +00:00
committed by GitHub
parent 2839cd2427
commit cf75dd0f46
2 changed files with 110 additions and 0 deletions
+19
View File
@@ -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
@@ -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()