From ec2d20a7f1ab3b189e83f7482a09496ba50c78c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:40:25 -0400 Subject: [PATCH] feat: support adding GitHub Copilot AI provider via UI (#25888) (#25902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/25888 Original PR: #25888 — feat: support adding GitHub Copilot AI provider via UI Merge commit: a85462bd4973efd96e2d43761266ccc8c3ebc60f Requested by: @dannykopping Co-authored-by: Danny Kopping --- coderd/ai_providers.go | 16 +++++ coderd/ai_providers_test.go | 69 +++++++++++++++++++ codersdk/aiproviders.go | 12 +++- enterprise/aibridgeproxyd/aibridgeproxyd.go | 35 +++++++++- .../aibridgeproxyd_internal_test.go | 68 ++++++++++++++++++ site/src/api/typesGenerated.ts | 5 +- .../AddProviderPageView.stories.tsx | 23 +++++++ .../AddProviderPage/AddProviderPageView.tsx | 7 +- .../UpdateProviderPageView.stories.tsx | 16 +++++ .../UpdateProviderPageView.tsx | 12 ++-- .../components/ProviderForm.stories.tsx | 32 +++++++++ .../ProvidersPage/components/ProviderForm.tsx | 61 +++++++++++++--- .../components/ProviderIcon.stories.tsx | 6 ++ .../ProvidersPage/components/ProviderIcon.tsx | 4 ++ .../components/addableProviderTypes.ts | 1 + .../components/providerFormApiMap.test.ts | 63 +++++++++++++++++ .../components/providerFormApiMap.ts | 49 +++++++++---- site/src/testHelpers/entities.ts | 14 ++++ 18 files changed, 457 insertions(+), 36 deletions(-) create mode 100644 enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index 78ca50ecc9..0637822592 100644 --- a/coderd/ai_providers.go +++ b/coderd/ai_providers.go @@ -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; diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index c020f90c26..e4f4f27a06 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -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) diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index fe34b9c03c..7b513340bc 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -188,8 +188,9 @@ type AIProviderKey struct { // CreateAIProviderRequest is the payload for creating a new AI // provider. Name and Type are required. APIKeys carries the plaintext -// keys for OpenAI/Anthropic providers; Bedrock providers authenticate -// via Settings and must omit APIKeys. +// keys for OpenAI/Anthropic providers; Bedrock and Copilot providers +// must omit APIKeys (Bedrock authenticates via Settings, Copilot via +// request-time GitHub OAuth tokens). type CreateAIProviderRequest struct { Type AIProviderType `json:"type"` Name string `json:"name"` @@ -209,6 +210,7 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { AIProviderTypeAnthropic, AIProviderTypeAzure, AIProviderTypeBedrock, + AIProviderTypeCopilot, AIProviderTypeGoogle, AIProviderTypeOpenAICompat, AIProviderTypeOpenrouter, @@ -244,6 +246,12 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { Detail: "type=bedrock does not accept api_keys", }) } + if req.Type == AIProviderTypeCopilot && len(req.APIKeys) > 0 { + validations = append(validations, ValidationError{ + Field: "api_keys", + Detail: "type=copilot does not accept api_keys", + }) + } return validations } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index cfcb2071c4..438d7c46b7 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -1076,9 +1076,11 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt switch { case resp.StatusCode >= http.StatusInternalServerError: - logger.Error(s.ctx, "received error response from aibridged") + logger.Error(s.ctx, "received error response from aibridged", + slog.F("response_body", s.readErrorBodyForLog(resp, logger))) case resp.StatusCode >= http.StatusBadRequest: - logger.Warn(s.ctx, "received error response from aibridged") + logger.Warn(s.ctx, "received error response from aibridged", + slog.F("response_body", s.readErrorBodyForLog(resp, logger))) default: logger.Debug(s.ctx, "received response from aibridged") } @@ -1101,6 +1103,35 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt return resp } +// maxLoggedErrorBodyBytes bounds how much of an aibridged error response +// body is rendered into a log line, so a large upstream error payload +// cannot blow up log volume. +const maxLoggedErrorBodyBytes = 16 << 10 // 16 KiB + +// readErrorBodyForLog reads resp.Body for diagnostic logging and restores +// it with an equivalent reader, so the proxy still forwards the body +// downstream and the response dumper can read it again. The returned +// string is truncated to maxLoggedErrorBodyBytes; the restored body is +// always complete. +func (s *Server) readErrorBodyForLog(resp *http.Response, logger slog.Logger) string { + if resp.Body == nil { + return "" + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + // Restore the full body even on a read error: the proxy and dumper + // downstream still expect a readable body, and a partial body is + // better than a nil one. + resp.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil { + logger.Warn(s.ctx, "failed to read aibridged error response body", slog.Error(err)) + } + if len(body) > maxLoggedErrorBodyBytes { + return string(body[:maxLoggedErrorBodyBytes]) + "...(truncated)" + } + return string(body) +} + // Handler returns an HTTP handler for the AI Bridge Proxy's HTTP endpoints. // This is separate from the proxy server itself and is used by coderd to // serve endpoints like the CA certificate. diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go new file mode 100644 index 0000000000..397ed1cf3e --- /dev/null +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_internal_test.go @@ -0,0 +1,68 @@ +package aibridgeproxyd + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" +) + +// TestReadErrorBodyForLog verifies that reading an aibridged error +// response body for logging leaves the body intact for downstream +// consumers (the proxy forwards it, and the response dumper reads it +// again), and that the logged rendering is capped. +func TestReadErrorBodyForLog(t *testing.T) { + t.Parallel() + + newResponse := func(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(body)), + } + } + + t.Run("ReturnsBodyAndRestores", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + resp := newResponse(`{"error":"bad request"}`) + + got := s.readErrorBodyForLog(resp, s.logger) + require.Equal(t, `{"error":"bad request"}`, got) + + // The body must still be readable in full for the proxy and the + // response dumper. + restored, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, `{"error":"bad request"}`, string(restored)) + }) + + t.Run("TruncatesLargeBodyButRestoresFull", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + full := bytes.Repeat([]byte("a"), maxLoggedErrorBodyBytes+512) + resp := newResponse(string(full)) + + got := s.readErrorBodyForLog(resp, s.logger) + require.Len(t, got, maxLoggedErrorBodyBytes+len("...(truncated)")) + require.True(t, strings.HasSuffix(got, "...(truncated)")) + + // Truncation only affects the log string; the restored body is + // the complete payload. + restored, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, full, restored) + }) + + t.Run("NilBody", func(t *testing.T) { + t.Parallel() + s := &Server{ctx: t.Context(), logger: slogtest.Make(t, nil)} + resp := &http.Response{StatusCode: http.StatusInternalServerError, Body: nil} + + require.Equal(t, "", s.readErrorBodyForLog(resp, s.logger)) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 76b0959842..01ccc251ac 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3232,8 +3232,9 @@ export interface ConvertLoginRequest { /** * CreateAIProviderRequest is the payload for creating a new AI * provider. Name and Type are required. APIKeys carries the plaintext - * keys for OpenAI/Anthropic providers; Bedrock providers authenticate - * via Settings and must omit APIKeys. + * keys for OpenAI/Anthropic providers; Bedrock and Copilot providers + * must omit APIKeys (Bedrock authenticates via Settings, Copilot via + * request-time GitHub OAuth tokens). */ export interface CreateAIProviderRequest { readonly type: AIProviderType; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx index f7bbab9761..176f8fb9ed 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { withToaster } from "#/testHelpers/storybook"; import { addableProviders } from "../components/addableProviderTypes"; @@ -26,16 +27,38 @@ export const AddAnthropic: Story = { args: { provider: addableProviders.find((p) => p.value === "anthropic")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an Anthropic provider"); + }, }; export const AddOpenAI: Story = { args: { provider: addableProviders.find((p) => p.value === "openai")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an OpenAI provider"); + }, }; export const AddBedrock: Story = { args: { provider: addableProviders.find((p) => p.value === "bedrock")!, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add an AWS Bedrock provider"); + }, +}; + +export const AddCopilot: Story = { + args: { + provider: addableProviders.find((p) => p.value === "copilot")!, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Add a GitHub Copilot provider"); + }, }; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx index 6f21156077..dbdb31b36a 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.tsx @@ -16,6 +16,9 @@ interface AddProviderPageViewProps { provider: AddableProvider; } +const indefiniteArticle = (word: string): string => + /^[aeiou]/i.test(word) ? "an" : "a"; + const AddProviderPageView: React.FC = ({ provider, }) => { @@ -38,7 +41,9 @@ const AddProviderPageView: React.FC = ({ size="lg" src={getProviderIcon(provider.value)} /> - {`Add a ${provider.label} provider`} + {`Add ${indefiniteArticle( + provider.label, + )} ${provider.label} provider`}

Configure connection details and credentials. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx index 15713428f9..4d212d2b14 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx @@ -5,6 +5,7 @@ import type { AIProvider } from "#/api/typesGenerated"; import { MockAIProviderAnthropic, MockAIProviderBedrock, + MockAIProviderCopilot, MockAIProviderOpenAI, } from "#/testHelpers/entities"; import { withToaster } from "#/testHelpers/storybook"; @@ -53,6 +54,21 @@ export const Bedrock: Story = { }, }; +// Copilot has no stored credential, so the edit form renders no API key +// field and keeps the immutable name disabled. +export const Copilot: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderCopilot.name}`), + ...seed(MockAIProviderCopilot), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const name = await canvas.findByLabelText(/^name/i); + expect(name).toBeDisabled(); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + // No seeded query: the page renders the loader while useQuery fetches. export const Loading: Story = { parameters: { diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dbeb16003d..9991c3b8f6 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -43,8 +43,12 @@ const UpdateProviderPageView: React.FC = () => { }); const provider = providerQuery.data; - const providerIsOpenAiAnthropic = - provider !== undefined && !isBedrockProvider(provider); + // Copilot has no stored credential, and Bedrock keeps its secrets in + // settings, so only the remaining types surface the api_keys UI. + const providerUsesApiKeys = + provider !== undefined && + !isBedrockProvider(provider) && + provider.type !== "copilot"; const updateMutation = useMutation( updateAIProviderMutation(queryClient, providerId ?? ""), @@ -109,8 +113,8 @@ const UpdateProviderPageView: React.FC = () => { } const openAiAnthropicSavedApiKey = - providerIsOpenAiAnthropic && provider.api_keys.length > 0; - const openAiAnthropicMaskedApiKey = providerIsOpenAiAnthropic + providerUsesApiKeys && provider.api_keys.length > 0; + const openAiAnthropicMaskedApiKey = providerUsesApiKeys ? provider.api_keys[0]?.masked : undefined; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 8fda28fae2..181a786a32 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -64,6 +64,38 @@ export const EditBedrockKeepCredentials: Story = { }, }; +export const AddCopilot: Story = { + args: { + // The real add flow passes only the type; the form fills name and + // endpoint from the copilot defaults. + initialValues: { type: "copilot" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByLabelText(/endpoint/i); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + +export const EditCopilot: Story = { + args: { + editing: true, + initialValues: { + type: "copilot", + name: "copilot", + displayName: "GitHub Copilot", + baseUrl: "https://api.business.githubcopilot.com", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const name = await canvas.findByLabelText(/^name/i); + expect(name).toBeDisabled(); + expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument(); + }, +}; + export const EditProvider: Story = { args: { editing: true, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 0a609de2ac..7468d39b86 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -81,6 +81,10 @@ const providerDefaults: Partial< name: "azure", baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1", }, + copilot: { + name: "copilot", + baseUrl: "https://api.business.githubcopilot.com", + }, google: { name: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -164,6 +168,20 @@ const makeBedrockSchema = (editing: boolean) => enabled: Yup.boolean(), }); +const makeCopilotSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf(["copilot"] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.") + .required("Endpoint is required"), + enabled: Yup.boolean(), + }); + const getProviderFormSchema = (editing: boolean) => Yup.lazy((value: { type?: AIProviderType } | undefined) => { switch (value?.type) { @@ -177,6 +195,8 @@ const getProviderFormSchema = (editing: boolean) => return makeOpenAiAnthropicSchema(editing); case "bedrock": return makeBedrockSchema(editing); + case "copilot": + return makeCopilotSchema(editing); default: return Yup.object({ type: Yup.string() @@ -185,6 +205,7 @@ const getProviderFormSchema = (editing: boolean) => "anthropic", "bedrock", "azure", + "copilot", "google", "openai-compat", "openrouter", @@ -319,18 +340,38 @@ export const ProviderForm: FC = ({ required field={getFieldHelpers("baseUrl")} label="Endpoint" - description="The base URL where the provider's API is hosted." + description={ + typeSelectValue === "copilot" ? ( + <> + The base URL for your Copilot tier:{" "} + https://api.individual.githubcopilot.com,{" "} + https://api.business.githubcopilot.com, or{" "} + https://api.enterprise.githubcopilot.com. + + ) : ( + "The base URL where the provider's API is hosted." + ) + } className="w-full" placeholder={baseUrlPlaceholder(form.values.type)} /> - handleCredentialFocus("apiKey")} - autoComplete="new-password" - placeholder={apiKeyPlaceholder(form.values.type)} - /> + {typeSelectValue === "copilot" ? ( +

+ Copilot authenticates with each user's GitHub OAuth token at + request time, so there is no API key to configure here. This + requires a GitHub external authentication provider to be + configured. +

+ ) : ( + handleCredentialFocus("apiKey")} + autoComplete="new-password" + placeholder={apiKeyPlaceholder(form.values.type)} + /> + )} )} @@ -409,7 +450,7 @@ export const ProviderForm: FC = ({