mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
feat: support adding GitHub Copilot AI provider via UI (#25888)
Copilot is the only AI provider type that could not be added through the `/ai/settings` UI. The aibridge runtime and the env-var seeding path already supported it, but the runtime CRUD API rejected `type=copilot` and the UI omitted it entirely. The root cause is that Copilot's auth model (a per-request GitHub OAuth token, with no pre-shared key) does not fit the credential-centric add-provider flow that every other provider uses. ## Backend Allow `type=copilot` in `CreateAIProviderRequest.Validate()`, and reject `api_keys` for Copilot on both create (validation) and update (handler sentinel), mirroring the existing Bedrock guards. Copilot carries no stored credential. ## Frontend Add Copilot to the provider type picker (with the `github-copilot.svg` icon) and give the form a credential-free branch: name, display name, and a free-text endpoint defaulting to `https://api.business.githubcopilot.com`, with copy explaining that authentication happens via the user's GitHub token at request time. Copilot maps to the distinct `copilot` wire type rather than collapsing to `openai`, and the edit flow recovers it correctly. The endpoint stays required with a business-tier default; users on the individual or enterprise endpoints edit the field. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -340,6 +340,10 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return errBedrockRejectsAPIKeys
|
return errBedrockRejectsAPIKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.APIKeys != nil && old.Type == database.AiProviderTypeCopilot && len(*req.APIKeys) > 0 {
|
||||||
|
return errCopilotRejectsAPIKeys
|
||||||
|
}
|
||||||
|
|
||||||
displayName := old.DisplayName
|
displayName := old.DisplayName
|
||||||
if req.DisplayName != nil {
|
if req.DisplayName != nil {
|
||||||
// Empty string clears the column.
|
// Empty string clears the column.
|
||||||
@@ -383,6 +387,12 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
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) {
|
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
Message: "Bedrock settings are only valid for type=anthropic or type=bedrock.",
|
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.
|
// Bedrock-typed provider; the outer handler translates it into a 400.
|
||||||
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
|
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
|
// errAIProviderBedrockTypeMismatch is the sentinel returned from
|
||||||
// inside the update transaction when the post-merge settings carry a
|
// inside the update transaction when the post-merge settings carry a
|
||||||
// Bedrock block but the provider is not anthropic- or bedrock-typed;
|
// 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")
|
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.Run("EmptyKeyRejected", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
client := coderdtest.New(t, nil)
|
client := coderdtest.New(t, nil)
|
||||||
|
|||||||
+10
-2
@@ -188,8 +188,9 @@ type AIProviderKey struct {
|
|||||||
|
|
||||||
// CreateAIProviderRequest is the payload for creating a new AI
|
// CreateAIProviderRequest is the payload for creating a new AI
|
||||||
// provider. Name and Type are required. APIKeys carries the plaintext
|
// provider. Name and Type are required. APIKeys carries the plaintext
|
||||||
// keys for OpenAI/Anthropic providers; Bedrock providers authenticate
|
// keys for OpenAI/Anthropic providers; Bedrock and Copilot providers
|
||||||
// via Settings and must omit APIKeys.
|
// must omit APIKeys (Bedrock authenticates via Settings, Copilot via
|
||||||
|
// request-time GitHub OAuth tokens).
|
||||||
type CreateAIProviderRequest struct {
|
type CreateAIProviderRequest struct {
|
||||||
Type AIProviderType `json:"type"`
|
Type AIProviderType `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -209,6 +210,7 @@ func (req CreateAIProviderRequest) Validate() []ValidationError {
|
|||||||
AIProviderTypeAnthropic,
|
AIProviderTypeAnthropic,
|
||||||
AIProviderTypeAzure,
|
AIProviderTypeAzure,
|
||||||
AIProviderTypeBedrock,
|
AIProviderTypeBedrock,
|
||||||
|
AIProviderTypeCopilot,
|
||||||
AIProviderTypeGoogle,
|
AIProviderTypeGoogle,
|
||||||
AIProviderTypeOpenAICompat,
|
AIProviderTypeOpenAICompat,
|
||||||
AIProviderTypeOpenrouter,
|
AIProviderTypeOpenrouter,
|
||||||
@@ -244,6 +246,12 @@ func (req CreateAIProviderRequest) Validate() []ValidationError {
|
|||||||
Detail: "type=bedrock does not accept api_keys",
|
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
|
return validations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1076,9 +1076,11 @@ func (s *Server) handleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *htt
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode >= http.StatusInternalServerError:
|
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:
|
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:
|
default:
|
||||||
logger.Debug(s.ctx, "received response from aibridged")
|
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
|
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.
|
// 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
|
// This is separate from the proxy server itself and is used by coderd to
|
||||||
// serve endpoints like the CA certificate.
|
// 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
@@ -3240,8 +3240,9 @@ export interface ConvertLoginRequest {
|
|||||||
/**
|
/**
|
||||||
* CreateAIProviderRequest is the payload for creating a new AI
|
* CreateAIProviderRequest is the payload for creating a new AI
|
||||||
* provider. Name and Type are required. APIKeys carries the plaintext
|
* provider. Name and Type are required. APIKeys carries the plaintext
|
||||||
* keys for OpenAI/Anthropic providers; Bedrock providers authenticate
|
* keys for OpenAI/Anthropic providers; Bedrock and Copilot providers
|
||||||
* via Settings and must omit APIKeys.
|
* must omit APIKeys (Bedrock authenticates via Settings, Copilot via
|
||||||
|
* request-time GitHub OAuth tokens).
|
||||||
*/
|
*/
|
||||||
export interface CreateAIProviderRequest {
|
export interface CreateAIProviderRequest {
|
||||||
readonly type: AIProviderType;
|
readonly type: AIProviderType;
|
||||||
|
|||||||
+23
@@ -1,4 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { within } from "storybook/test";
|
||||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||||
import { withToaster } from "#/testHelpers/storybook";
|
import { withToaster } from "#/testHelpers/storybook";
|
||||||
import { addableProviders } from "../components/addableProviderTypes";
|
import { addableProviders } from "../components/addableProviderTypes";
|
||||||
@@ -26,16 +27,38 @@ export const AddAnthropic: Story = {
|
|||||||
args: {
|
args: {
|
||||||
provider: addableProviders.find((p) => p.value === "anthropic")!,
|
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 = {
|
export const AddOpenAI: Story = {
|
||||||
args: {
|
args: {
|
||||||
provider: addableProviders.find((p) => p.value === "openai")!,
|
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 = {
|
export const AddBedrock: Story = {
|
||||||
args: {
|
args: {
|
||||||
provider: addableProviders.find((p) => p.value === "bedrock")!,
|
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;
|
provider: AddableProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const indefiniteArticle = (word: string): string =>
|
||||||
|
/^[aeiou]/i.test(word) ? "an" : "a";
|
||||||
|
|
||||||
const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
|
const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
|
||||||
provider,
|
provider,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -38,7 +41,9 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
src={getProviderIcon(provider.value)}
|
src={getProviderIcon(provider.value)}
|
||||||
/>
|
/>
|
||||||
<SettingsHeaderTitle>{`Add a ${provider.label} provider`}</SettingsHeaderTitle>
|
<SettingsHeaderTitle>{`Add ${indefiniteArticle(
|
||||||
|
provider.label,
|
||||||
|
)} ${provider.label} provider`}</SettingsHeaderTitle>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-content-secondary m-0">
|
<p className="text-sm text-content-secondary m-0">
|
||||||
Configure connection details and credentials.
|
Configure connection details and credentials.
|
||||||
|
|||||||
+16
@@ -5,6 +5,7 @@ import type { AIProvider } from "#/api/typesGenerated";
|
|||||||
import {
|
import {
|
||||||
MockAIProviderAnthropic,
|
MockAIProviderAnthropic,
|
||||||
MockAIProviderBedrock,
|
MockAIProviderBedrock,
|
||||||
|
MockAIProviderCopilot,
|
||||||
MockAIProviderOpenAI,
|
MockAIProviderOpenAI,
|
||||||
} from "#/testHelpers/entities";
|
} from "#/testHelpers/entities";
|
||||||
import { withToaster } from "#/testHelpers/storybook";
|
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.
|
// No seeded query: the page renders the loader while useQuery fetches.
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|||||||
+8
-4
@@ -43,8 +43,12 @@ const UpdateProviderPageView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const provider = providerQuery.data;
|
const provider = providerQuery.data;
|
||||||
const providerIsOpenAiAnthropic =
|
// Copilot has no stored credential, and Bedrock keeps its secrets in
|
||||||
provider !== undefined && !isBedrockProvider(provider);
|
// settings, so only the remaining types surface the api_keys UI.
|
||||||
|
const providerUsesApiKeys =
|
||||||
|
provider !== undefined &&
|
||||||
|
!isBedrockProvider(provider) &&
|
||||||
|
provider.type !== "copilot";
|
||||||
|
|
||||||
const updateMutation = useMutation(
|
const updateMutation = useMutation(
|
||||||
updateAIProviderMutation(queryClient, providerId ?? ""),
|
updateAIProviderMutation(queryClient, providerId ?? ""),
|
||||||
@@ -109,8 +113,8 @@ const UpdateProviderPageView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAiAnthropicSavedApiKey =
|
const openAiAnthropicSavedApiKey =
|
||||||
providerIsOpenAiAnthropic && provider.api_keys.length > 0;
|
providerUsesApiKeys && provider.api_keys.length > 0;
|
||||||
const openAiAnthropicMaskedApiKey = providerIsOpenAiAnthropic
|
const openAiAnthropicMaskedApiKey = providerUsesApiKeys
|
||||||
? provider.api_keys[0]?.masked
|
? provider.api_keys[0]?.masked
|
||||||
: undefined;
|
: 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 = {
|
export const EditProvider: Story = {
|
||||||
args: {
|
args: {
|
||||||
editing: true,
|
editing: true,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ const providerDefaults: Partial<
|
|||||||
name: "azure",
|
name: "azure",
|
||||||
baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1",
|
baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1",
|
||||||
},
|
},
|
||||||
|
copilot: {
|
||||||
|
name: "copilot",
|
||||||
|
baseUrl: "https://api.business.githubcopilot.com",
|
||||||
|
},
|
||||||
google: {
|
google: {
|
||||||
name: "google",
|
name: "google",
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
@@ -164,6 +168,20 @@ const makeBedrockSchema = (editing: boolean) =>
|
|||||||
enabled: Yup.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) =>
|
const getProviderFormSchema = (editing: boolean) =>
|
||||||
Yup.lazy((value: { type?: AIProviderType } | undefined) => {
|
Yup.lazy((value: { type?: AIProviderType } | undefined) => {
|
||||||
switch (value?.type) {
|
switch (value?.type) {
|
||||||
@@ -177,6 +195,8 @@ const getProviderFormSchema = (editing: boolean) =>
|
|||||||
return makeOpenAiAnthropicSchema(editing);
|
return makeOpenAiAnthropicSchema(editing);
|
||||||
case "bedrock":
|
case "bedrock":
|
||||||
return makeBedrockSchema(editing);
|
return makeBedrockSchema(editing);
|
||||||
|
case "copilot":
|
||||||
|
return makeCopilotSchema(editing);
|
||||||
default:
|
default:
|
||||||
return Yup.object({
|
return Yup.object({
|
||||||
type: Yup.string()
|
type: Yup.string()
|
||||||
@@ -185,6 +205,7 @@ const getProviderFormSchema = (editing: boolean) =>
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
"azure",
|
"azure",
|
||||||
|
"copilot",
|
||||||
"google",
|
"google",
|
||||||
"openai-compat",
|
"openai-compat",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
@@ -319,18 +340,38 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
|||||||
required
|
required
|
||||||
field={getFieldHelpers("baseUrl")}
|
field={getFieldHelpers("baseUrl")}
|
||||||
label="Endpoint"
|
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"
|
className="w-full"
|
||||||
placeholder={baseUrlPlaceholder(form.values.type)}
|
placeholder={baseUrlPlaceholder(form.values.type)}
|
||||||
/>
|
/>
|
||||||
<CredentialField
|
{typeSelectValue === "copilot" ? (
|
||||||
required
|
<p className="text-sm text-content-secondary m-0">
|
||||||
label="API key"
|
Copilot authenticates with each user's GitHub OAuth token at
|
||||||
helpers={getFieldHelpers("apiKey")}
|
request time, so there is no API key to configure here. This
|
||||||
onFocus={() => handleCredentialFocus("apiKey")}
|
requires a GitHub external authentication provider to be
|
||||||
autoComplete="new-password"
|
configured.
|
||||||
placeholder={apiKeyPlaceholder(form.values.type)}
|
</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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
disabled={isLoading || !form.dirty || !form.isValid}
|
disabled={isLoading || !form.isValid || (editing && !form.dirty)}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<Spinner loading={isLoading} />
|
<Spinner loading={isLoading} />
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export const Bedrock: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Copilot: Story = {
|
||||||
|
args: {
|
||||||
|
provider: "copilot",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Azure: Story = {
|
export const Azure: Story = {
|
||||||
args: {
|
args: {
|
||||||
provider: "azure",
|
provider: "azure",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const getProviderIcon = (provider: string): string | undefined => {
|
|||||||
return "/icon/aws.svg";
|
return "/icon/aws.svg";
|
||||||
case "azure":
|
case "azure":
|
||||||
return "/icon/azure.svg";
|
return "/icon/azure.svg";
|
||||||
|
case "copilot":
|
||||||
|
return "/icon/github-copilot.svg";
|
||||||
case "google":
|
case "google":
|
||||||
return "/icon/google.svg";
|
return "/icon/google.svg";
|
||||||
case "vercel":
|
case "vercel":
|
||||||
@@ -34,6 +36,8 @@ const getProviderName = (provider: string): string => {
|
|||||||
return "AWS Bedrock";
|
return "AWS Bedrock";
|
||||||
case "azure":
|
case "azure":
|
||||||
return "Azure OpenAI";
|
return "Azure OpenAI";
|
||||||
|
case "copilot":
|
||||||
|
return "GitHub Copilot";
|
||||||
case "google":
|
case "google":
|
||||||
return "Google";
|
return "Google";
|
||||||
case "openai-compat":
|
case "openai-compat":
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const addableProviders: readonly AddableProvider[] = [
|
|||||||
{ value: "anthropic", label: "Anthropic" },
|
{ value: "anthropic", label: "Anthropic" },
|
||||||
{ value: "bedrock", label: "AWS Bedrock" },
|
{ value: "bedrock", label: "AWS Bedrock" },
|
||||||
{ value: "azure", label: "Azure OpenAI" },
|
{ value: "azure", label: "Azure OpenAI" },
|
||||||
|
{ value: "copilot", label: "GitHub Copilot" },
|
||||||
{ value: "google", label: "Google" },
|
{ value: "google", label: "Google" },
|
||||||
{ value: "openai", label: "OpenAI" },
|
{ value: "openai", label: "OpenAI" },
|
||||||
{ value: "openai-compat", label: "OpenAI-compatible" },
|
{ value: "openai-compat", label: "OpenAI-compatible" },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AIProvider } from "#/api/typesGenerated";
|
|||||||
import {
|
import {
|
||||||
MockAIProviderAnthropic,
|
MockAIProviderAnthropic,
|
||||||
MockAIProviderBedrock,
|
MockAIProviderBedrock,
|
||||||
|
MockAIProviderCopilot,
|
||||||
MockAIProviderOpenAI,
|
MockAIProviderOpenAI,
|
||||||
} from "#/testHelpers/entities";
|
} from "#/testHelpers/entities";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,19 @@ const baseBedrockFormValues: ProviderFormValues = {
|
|||||||
enabled: true,
|
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;
|
// Cast a plain object to AIProvider's discriminated `settings` shape;
|
||||||
// the generated TS interface is empty and the wire form carries the
|
// the generated TS interface is empty and the wire form carries the
|
||||||
// discriminator keys flattened in alongside the variant fields.
|
// discriminator keys flattened in alongside the variant fields.
|
||||||
@@ -173,6 +187,10 @@ describe("getProviderDisplayType", () => {
|
|||||||
expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai");
|
expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns copilot for a Copilot provider", () => {
|
||||||
|
expect(getProviderDisplayType(MockAIProviderCopilot)).toBe("copilot");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["azure", "https://my-resource.openai.azure.com/openai/v1"],
|
["azure", "https://my-resource.openai.azure.com/openai/v1"],
|
||||||
["azure", "https://YOUR-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();
|
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", () => {
|
describe("providerFormValuesToUpdate", () => {
|
||||||
@@ -460,6 +503,18 @@ describe("providerFormValuesToUpdate", () => {
|
|||||||
expect(s.access_key_secret).toBeUndefined();
|
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", () => {
|
describe("aiProviderToFormValues", () => {
|
||||||
@@ -486,6 +541,14 @@ describe("aiProviderToFormValues", () => {
|
|||||||
expect(values.accessKeySecret).toBe("");
|
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", () => {
|
it("falls back to the slug when display_name is empty", () => {
|
||||||
const provider: AIProvider = {
|
const provider: AIProvider = {
|
||||||
...MockAIProviderOpenAI,
|
...MockAIProviderOpenAI,
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export const getProviderDisplayType = (
|
|||||||
if (provider.type === "anthropic") {
|
if (provider.type === "anthropic") {
|
||||||
return "anthropic";
|
return "anthropic";
|
||||||
}
|
}
|
||||||
|
if (provider.type === "copilot") {
|
||||||
|
return "copilot";
|
||||||
|
}
|
||||||
const host = parseProviderHost(provider.base_url ?? "");
|
const host = parseProviderHost(provider.base_url ?? "");
|
||||||
const match = displayTypeHosts.find(([h]) => matchesHost(host, h));
|
const match = displayTypeHosts.find(([h]) => matchesHost(host, h));
|
||||||
return match?.[1] ?? provider.type;
|
return match?.[1] ?? provider.type;
|
||||||
@@ -125,12 +128,16 @@ const buildBedrockSettings = (
|
|||||||
export const providerFormValuesToCreate = (
|
export const providerFormValuesToCreate = (
|
||||||
values: ProviderFormValues,
|
values: ProviderFormValues,
|
||||||
): CreateAIProviderRequest => {
|
): CreateAIProviderRequest => {
|
||||||
const name = values.name.trim();
|
|
||||||
const displayName = values.displayName.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") {
|
if (values.type === "bedrock") {
|
||||||
const region = parseBedrockRegionFromBaseUrl(baseUrl);
|
const region = parseBedrockRegionFromBaseUrl(base.base_url);
|
||||||
const settings = buildBedrockSettings(
|
const settings = buildBedrockSettings(
|
||||||
region,
|
region,
|
||||||
values.model.trim(),
|
values.model.trim(),
|
||||||
@@ -140,17 +147,18 @@ export const providerFormValuesToCreate = (
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
type: "anthropic",
|
type: "anthropic",
|
||||||
name,
|
...base,
|
||||||
...(displayName ? { display_name: displayName } : {}),
|
|
||||||
base_url: baseUrl,
|
|
||||||
enabled: values.enabled,
|
|
||||||
settings: settings as AIProviderSettings,
|
settings: settings as AIProviderSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.type === "copilot") {
|
||||||
|
return { type: "copilot", ...base };
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = sanitizeCredential(values.apiKey);
|
const apiKey = sanitizeCredential(values.apiKey);
|
||||||
// `""` is unreachable here (Yup blocks it, Bedrock branched out), but the
|
// `""` is unreachable here (Yup blocks it, Bedrock and Copilot branched
|
||||||
// union still includes it; narrow so TS stays honest.
|
// out), but the union still includes it; narrow so TS stays honest.
|
||||||
if (values.type === "") {
|
if (values.type === "") {
|
||||||
throw new Error("provider type is required");
|
throw new Error("provider type is required");
|
||||||
}
|
}
|
||||||
@@ -160,10 +168,7 @@ export const providerFormValuesToCreate = (
|
|||||||
values.type === "anthropic" ? "anthropic" : "openai";
|
values.type === "anthropic" ? "anthropic" : "openai";
|
||||||
return {
|
return {
|
||||||
type: wireType,
|
type: wireType,
|
||||||
name,
|
...base,
|
||||||
...(displayName ? { display_name: displayName } : {}),
|
|
||||||
base_url: baseUrl,
|
|
||||||
enabled: values.enabled,
|
|
||||||
...(apiKey ? { api_keys: [apiKey] } : {}),
|
...(apiKey ? { api_keys: [apiKey] } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -182,6 +187,10 @@ export const providerFormValuesToUpdate = (
|
|||||||
base_url: values.baseUrl.trim(),
|
base_url: values.baseUrl.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (values.type === "copilot") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.type !== "bedrock") {
|
if (values.type !== "bedrock") {
|
||||||
// If the user didn't touch the input, the form still holds the seeded
|
// If the user didn't touch the input, the form still holds the seeded
|
||||||
// mask and sanitizes to `""` (no rotation).
|
// mask and sanitizes to `""` (no rotation).
|
||||||
@@ -240,8 +249,18 @@ export const aiProviderToFormValues = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire `type` is only `openai` or `anthropic`; the dropdown's richer
|
if (provider.type === "copilot") {
|
||||||
// labels apply only on create.
|
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 {
|
return {
|
||||||
type: provider.type === "anthropic" ? "anthropic" : "openai",
|
type: provider.type === "anthropic" ? "anthropic" : "openai",
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
|
|||||||
@@ -5574,8 +5574,22 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = {
|
|||||||
updated_at: "2026-05-14T10:00:00Z",
|
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[] = [
|
export const MockAIProviders: TypesGen.AIProvider[] = [
|
||||||
MockAIProviderOpenAI,
|
MockAIProviderOpenAI,
|
||||||
MockAIProviderAnthropic,
|
MockAIProviderAnthropic,
|
||||||
MockAIProviderBedrock,
|
MockAIProviderBedrock,
|
||||||
|
MockAIProviderCopilot,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user