mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user