mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
committed by
GitHub
parent
ea971d54f3
commit
ec2d20a7f1
@@ -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)
|
||||
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
Generated
+3
-2
@@ -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;
|
||||
|
||||
+23
@@ -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.
|
||||
|
||||
+16
@@ -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: {
|
||||
|
||||
+8
-4
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user