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
+3 -2
View File
@@ -3240,8 +3240,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;
@@ -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");
},
};
@@ -16,6 +16,9 @@ interface AddProviderPageViewProps {
provider: AddableProvider;
}
const indefiniteArticle = (word: string): string =>
/^[aeiou]/i.test(word) ? "an" : "a";
const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
provider,
}) => {
@@ -38,7 +41,9 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
size="lg"
src={getProviderIcon(provider.value)}
/>
<SettingsHeaderTitle>{`Add a ${provider.label} provider`}</SettingsHeaderTitle>
<SettingsHeaderTitle>{`Add ${indefiniteArticle(
provider.label,
)} ${provider.label} provider`}</SettingsHeaderTitle>
</div>
<p className="text-sm text-content-secondary m-0">
Configure connection details and credentials.
@@ -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: {
@@ -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;
@@ -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,
@@ -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<ProviderFormProps> = ({
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:{" "}
<code>https://api.individual.githubcopilot.com</code>,{" "}
<code>https://api.business.githubcopilot.com</code>, or{" "}
<code>https://api.enterprise.githubcopilot.com</code>.
</>
) : (
"The base URL where the provider's API is hosted."
)
}
className="w-full"
placeholder={baseUrlPlaceholder(form.values.type)}
/>
<CredentialField
required
label="API key"
helpers={getFieldHelpers("apiKey")}
onFocus={() => handleCredentialFocus("apiKey")}
autoComplete="new-password"
placeholder={apiKeyPlaceholder(form.values.type)}
/>
{typeSelectValue === "copilot" ? (
<p className="text-sm text-content-secondary m-0">
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.
</p>
) : (
<CredentialField
required
label="API key"
helpers={getFieldHelpers("apiKey")}
onFocus={() => handleCredentialFocus("apiKey")}
autoComplete="new-password"
placeholder={apiKeyPlaceholder(form.values.type)}
/>
)}
</>
)}
@@ -409,7 +450,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
</Button>
</Link>
<Button
disabled={isLoading || !form.dirty || !form.isValid}
disabled={isLoading || !form.isValid || (editing && !form.dirty)}
type="submit"
>
<Spinner loading={isLoading} />
@@ -27,6 +27,12 @@ export const Bedrock: Story = {
},
};
export const Copilot: Story = {
args: {
provider: "copilot",
},
};
export const Azure: Story = {
args: {
provider: "azure",
@@ -15,6 +15,8 @@ export const getProviderIcon = (provider: string): string | undefined => {
return "/icon/aws.svg";
case "azure":
return "/icon/azure.svg";
case "copilot":
return "/icon/github-copilot.svg";
case "google":
return "/icon/google.svg";
case "vercel":
@@ -34,6 +36,8 @@ const getProviderName = (provider: string): string => {
return "AWS Bedrock";
case "azure":
return "Azure OpenAI";
case "copilot":
return "GitHub Copilot";
case "google":
return "Google";
case "openai-compat":
@@ -9,6 +9,7 @@ export const addableProviders: readonly AddableProvider[] = [
{ value: "anthropic", label: "Anthropic" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "azure", label: "Azure OpenAI" },
{ value: "copilot", label: "GitHub Copilot" },
{ value: "google", label: "Google" },
{ value: "openai", label: "OpenAI" },
{ value: "openai-compat", label: "OpenAI-compatible" },
@@ -3,6 +3,7 @@ import type { AIProvider } from "#/api/typesGenerated";
import {
MockAIProviderAnthropic,
MockAIProviderBedrock,
MockAIProviderCopilot,
MockAIProviderOpenAI,
} from "#/testHelpers/entities";
import {
@@ -45,6 +46,19 @@ const baseBedrockFormValues: ProviderFormValues = {
enabled: true,
};
const baseCopilotFormValues: ProviderFormValues = {
type: "copilot",
name: "copilot",
displayName: "GitHub Copilot",
baseUrl: "https://api.business.githubcopilot.com",
model: "",
smallFastModel: "",
accessKey: "",
accessKeySecret: "",
apiKey: "",
enabled: true,
};
// Cast a plain object to AIProvider's discriminated `settings` shape;
// the generated TS interface is empty and the wire form carries the
// discriminator keys flattened in alongside the variant fields.
@@ -173,6 +187,10 @@ describe("getProviderDisplayType", () => {
expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai");
});
it("returns copilot for a Copilot provider", () => {
expect(getProviderDisplayType(MockAIProviderCopilot)).toBe("copilot");
});
it.each([
["azure", "https://my-resource.openai.azure.com/openai/v1"],
["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"],
@@ -333,6 +351,31 @@ describe("providerFormValuesToCreate", () => {
expect(req.api_keys).toBeUndefined();
});
});
describe("Copilot", () => {
it("maps to a distinct wire type with no api_keys", () => {
const req = providerFormValuesToCreate(baseCopilotFormValues);
expect(req.type).toBe("copilot");
expect(req.base_url).toBe("https://api.business.githubcopilot.com");
expect(req.api_keys).toBeUndefined();
});
it("never sends api_keys even if the field carries a value", () => {
const req = providerFormValuesToCreate({
...baseCopilotFormValues,
apiKey: "should-be-ignored",
});
expect(req.api_keys).toBeUndefined();
});
it("omits display_name when blank so the server stores NULL", () => {
const req = providerFormValuesToCreate({
...baseCopilotFormValues,
displayName: "",
});
expect(req.display_name).toBeUndefined();
});
});
});
describe("providerFormValuesToUpdate", () => {
@@ -460,6 +503,18 @@ describe("providerFormValuesToUpdate", () => {
expect(s.access_key_secret).toBeUndefined();
});
});
describe("Copilot", () => {
it("patches only the base fields and never sends api_keys", () => {
const req = providerFormValuesToUpdate(
{ ...baseCopilotFormValues, apiKey: "should-be-ignored" },
MockAIProviderCopilot,
);
expect(req.api_keys).toBeUndefined();
expect(req.settings).toBeUndefined();
expect(req.base_url).toBe("https://api.business.githubcopilot.com");
});
});
});
describe("aiProviderToFormValues", () => {
@@ -486,6 +541,14 @@ describe("aiProviderToFormValues", () => {
expect(values.accessKeySecret).toBe("");
});
it("seeds Copilot form values without a credential field", () => {
const values = aiProviderToFormValues(MockAIProviderCopilot);
expect(values.type).toBe("copilot");
expect(values.name).toBe(MockAIProviderCopilot.name);
expect(values.baseUrl).toBe(MockAIProviderCopilot.base_url);
expect(values.apiKey).toBeUndefined();
});
it("falls back to the slug when display_name is empty", () => {
const provider: AIProvider = {
...MockAIProviderOpenAI,
@@ -98,6 +98,9 @@ export const getProviderDisplayType = (
if (provider.type === "anthropic") {
return "anthropic";
}
if (provider.type === "copilot") {
return "copilot";
}
const host = parseProviderHost(provider.base_url ?? "");
const match = displayTypeHosts.find(([h]) => matchesHost(host, h));
return match?.[1] ?? provider.type;
@@ -125,12 +128,16 @@ const buildBedrockSettings = (
export const providerFormValuesToCreate = (
values: ProviderFormValues,
): CreateAIProviderRequest => {
const name = values.name.trim();
const displayName = values.displayName.trim();
const baseUrl = values.baseUrl.trim();
const base: Omit<CreateAIProviderRequest, "type"> = {
name: values.name.trim(),
...(displayName ? { display_name: displayName } : {}),
base_url: values.baseUrl.trim(),
enabled: values.enabled,
};
if (values.type === "bedrock") {
const region = parseBedrockRegionFromBaseUrl(baseUrl);
const region = parseBedrockRegionFromBaseUrl(base.base_url);
const settings = buildBedrockSettings(
region,
values.model.trim(),
@@ -140,17 +147,18 @@ export const providerFormValuesToCreate = (
);
return {
type: "anthropic",
name,
...(displayName ? { display_name: displayName } : {}),
base_url: baseUrl,
enabled: values.enabled,
...base,
settings: settings as AIProviderSettings,
};
}
if (values.type === "copilot") {
return { type: "copilot", ...base };
}
const apiKey = sanitizeCredential(values.apiKey);
// `""` is unreachable here (Yup blocks it, Bedrock branched out), but the
// union still includes it; narrow so TS stays honest.
// `""` is unreachable here (Yup blocks it, Bedrock and Copilot branched
// out), but the union still includes it; narrow so TS stays honest.
if (values.type === "") {
throw new Error("provider type is required");
}
@@ -160,10 +168,7 @@ export const providerFormValuesToCreate = (
values.type === "anthropic" ? "anthropic" : "openai";
return {
type: wireType,
name,
...(displayName ? { display_name: displayName } : {}),
base_url: baseUrl,
enabled: values.enabled,
...base,
...(apiKey ? { api_keys: [apiKey] } : {}),
};
};
@@ -182,6 +187,10 @@ export const providerFormValuesToUpdate = (
base_url: values.baseUrl.trim(),
};
if (values.type === "copilot") {
return base;
}
if (values.type !== "bedrock") {
// If the user didn't touch the input, the form still holds the seeded
// mask and sanitizes to `""` (no rotation).
@@ -240,8 +249,18 @@ export const aiProviderToFormValues = (
};
}
// Wire `type` is only `openai` or `anthropic`; the dropdown's richer
// labels apply only on create.
if (provider.type === "copilot") {
return {
type: "copilot",
name: provider.name,
displayName,
baseUrl: provider.base_url,
enabled: provider.enabled,
};
}
// Wire `type` is otherwise only `openai` or `anthropic`; the dropdown's
// richer labels apply only on create.
return {
type: provider.type === "anthropic" ? "anthropic" : "openai",
name: provider.name,
+14
View File
@@ -5574,8 +5574,22 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = {
updated_at: "2026-05-14T10:00:00Z",
};
export const MockAIProviderCopilot: TypesGen.AIProvider = {
id: "b3f0d2c8-6a4e-4d11-8c2f-1e9a7c5b4d31",
type: "copilot",
name: "copilot",
display_name: "GitHub Copilot",
base_url: "https://api.business.githubcopilot.com",
enabled: true,
api_keys: [],
settings: null as unknown as TypesGen.AIProviderSettings,
created_at: "2026-05-14T10:00:00Z",
updated_at: "2026-05-14T10:00:00Z",
};
export const MockAIProviders: TypesGen.AIProvider[] = [
MockAIProviderOpenAI,
MockAIProviderAnthropic,
MockAIProviderBedrock,
MockAIProviderCopilot,
];