feat: support adding GitHub Copilot AI provider via UI (#25888)

Copilot is the only AI provider type that could not be added through the `/ai/settings` UI. The aibridge runtime and the env-var seeding path already supported it, but the runtime CRUD API rejected `type=copilot` and the UI omitted it entirely. The root cause is that Copilot's auth model (a per-request GitHub OAuth token, with no pre-shared key) does not fit the credential-centric add-provider flow that every other provider uses.

## Backend

Allow `type=copilot` in `CreateAIProviderRequest.Validate()`, and reject `api_keys` for Copilot on both create (validation) and update (handler sentinel), mirroring the existing Bedrock guards. Copilot carries no stored credential.

## Frontend

Add Copilot to the provider type picker (with the `github-copilot.svg` icon) and give the form a credential-free branch: name, display name, and a free-text endpoint defaulting to `https://api.business.githubcopilot.com`, with copy explaining that authentication happens via the user's GitHub token at request time. Copilot maps to the distinct `copilot` wire type rather than collapsing to `openai`, and the edit flow recovers it correctly.

The endpoint stays required with a business-tier default; users on the individual or enterprise endpoints edit the field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Danny Kopping
2026-06-01 15:26:37 +02:00
committed by GitHub
parent 82752844bc
commit a85462bd49
18 changed files with 457 additions and 36 deletions
+16
View File
@@ -340,6 +340,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
return errBedrockRejectsAPIKeys
}
if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
return errCopilotRejectsAPIKeys
}
displayName := old.DisplayName
if req.DisplayName != nil {
// Empty string clears the column.
@@ -383,6 +387,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
if errors.Is(err, errCopilotRejectsAPIKeys) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Copilot providers do not accept api_keys; they authenticate via request-time GitHub OAuth tokens.",
})
return
}
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
@@ -483,6 +493,12 @@ func (api *API) publishAIProvidersChanged(ctx context.Context) {
// Bedrock-typed provider; the outer handler translates it into a 400.
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
// errCopilotRejectsAPIKeys is the sentinel returned from inside the
// update transaction when a caller attempts to attach api_keys to a
// Copilot-typed provider; the outer handler translates it into a 400.
// Copilot authenticates via request-time GitHub OAuth tokens.
var errCopilotRejectsAPIKeys = xerrors.New("copilot providers do not accept api_keys")
// errAIProviderBedrockTypeMismatch is the sentinel returned from
// inside the update transaction when the post-merge settings carry a
// Bedrock block but the provider is not anthropic- or bedrock-typed;
+69
View File
@@ -889,6 +889,75 @@ func TestAIProvidersKeyManagement(t *testing.T) {
require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys")
})
t.Run("CopilotCreateWithoutKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
require.Equal(t, codersdk.AIProviderTypeCopilot, provider.Type)
require.Empty(t, provider.APIKeys)
})
t.Run("CopilotRejectsCreateWithKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot-create",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "api_keys", sdkErr.Validations[0].Field)
require.Contains(t, sdkErr.Validations[0].Detail, "type=copilot does not accept api_keys")
})
t.Run("CopilotRejectsUpdateWithKeys", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is the audience for this endpoint.
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeCopilot,
Name: "keys-copilot-update",
Enabled: true,
BaseURL: "https://api.business.githubcopilot.com",
})
require.NoError(t, err)
rejected := []codersdk.AIProviderKeyMutation{
{APIKey: ptr.Ref("sk-copilot-no")}, //nolint:gosec // test fixture, not a real credential
}
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
APIKeys: &rejected,
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "Copilot providers do not accept api_keys")
})
t.Run("EmptyKeyRejected", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)