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

Cherry-pick of https://github.com/coder/coder/pull/25888

Original PR: #25888 — feat: support adding GitHub Copilot AI provider
via UI
Merge commit: a85462bd49
Requested by: @dannykopping

Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
github-actions[bot]
2026-06-01 13:40:25 -04:00
committed by GitHub
parent ea971d54f3
commit ec2d20a7f1
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)
+10 -2
View File
@@ -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
}
+33 -2
View File
@@ -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.
@@ -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))
})
}
+3 -2
View File
@@ -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;
@@ -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,
];