mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user