mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat(site): configure Claude Platform for AWS in the provider form (#29050)
This PR adds the required UI changes for configuring Claude Platform for AWS. - Adds a **Platform** dropdown to the Anthropic form, using the same shared select component as Bedrock: **Anthropic API** or **Claude Platform for AWS**. Claude Platform remains a settings variant, not a new provider type. - Marks Claude Platform **Experimental** and exposes region, workspace ID, endpoint, and an optional workspace API key. There is no authentication-mode selector or per-provider AWS access key, Role ARN, or external ID. - Explains credential precedence: allowed BYOK, then stored provider keys, then the gateway's ambient AWS credentials. Rejected keys or an exhausted pool do not fall back to IAM. Keyless providers share the gateway's ambient identity, while retaining distinct workspace IDs and Coder provider attribution. - Preserves every stored key ID when an edit leaves the key field blank or masked. Entering a replacement key retains the existing replacement behavior. No new key-removal control or pool editor is introduced. - Keeps generated endpoints aligned with region changes without rewriting custom proxy URLs. The platform remains fixed during editing. List/edit icons distinguish Claude Platform with the AWS glyph. - Makes stored keys optional for ordinary Anthropic BYOK configuration without relaxing other providers' requirements or adding anonymous Messages support. Bedrock credential controls remain unchanged. ## Validation - 114 Vitest form/mapping tests passed, including multi-key preservation, optional keys, platform selection, validation, and endpoint behavior. - 37 browser-backed Storybook tests passed across the form and update page. - Smoke-tested against an AWS sandbox account. Generated by Coder Agents on behalf of @johnstcn and @Shelnutt2. Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
@@ -16,6 +16,8 @@ export const getProviderIcon = (provider: string): string | undefined => {
|
||||
return "/icon/anthropic.svg";
|
||||
case "bedrock":
|
||||
return "/icon/aws.svg";
|
||||
case "claude-platform-aws":
|
||||
return "/icon/aws.svg";
|
||||
case "azure":
|
||||
return "/icon/azure.svg";
|
||||
case "copilot":
|
||||
|
||||
+20
@@ -5,6 +5,8 @@ import type { AIProvider } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockAIProviderAnthropic,
|
||||
MockAIProviderBedrock,
|
||||
MockAIProviderClaudePlatformAWS,
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
MockAIProviderCopilot,
|
||||
MockAIProviderOpenAI,
|
||||
} from "#/testHelpers/entities";
|
||||
@@ -77,6 +79,24 @@ export const Copilot: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ClaudePlatformKeyless: Story = {
|
||||
parameters: {
|
||||
reactRouter: routingFor(
|
||||
`/ai/settings/providers/${MockAIProviderClaudePlatformAWS.name}`,
|
||||
),
|
||||
...seed(MockAIProviderClaudePlatformAWS),
|
||||
},
|
||||
};
|
||||
|
||||
export const ClaudePlatformSavedWorkspaceKey: Story = {
|
||||
parameters: {
|
||||
reactRouter: routingFor(
|
||||
`/ai/settings/providers/${MockAIProviderClaudePlatformAWSAPIKey.name}`,
|
||||
),
|
||||
...seed(MockAIProviderClaudePlatformAWSAPIKey),
|
||||
},
|
||||
};
|
||||
|
||||
// No seeded query: the page renders the loader while useQuery fetches.
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
|
||||
+9
-13
@@ -45,8 +45,8 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
});
|
||||
|
||||
const provider = providerQuery.data;
|
||||
// Copilot has no stored credential, and Bedrock keeps its secrets in
|
||||
// settings, so only the remaining types surface the api_keys UI.
|
||||
// Claude Platform has no stored AWS credential fields. Its API key pool
|
||||
// is the only optional persisted credential and is detected directly.
|
||||
const providerUsesApiKeys =
|
||||
provider !== undefined &&
|
||||
!isBedrockProvider(provider) &&
|
||||
@@ -114,11 +114,7 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
return <Navigate to={BACK_HREF} replace />;
|
||||
}
|
||||
|
||||
const openAiAnthropicSavedApiKey =
|
||||
providerUsesApiKeys && provider.api_keys.length > 0;
|
||||
const openAiAnthropicMaskedApiKey = providerUsesApiKeys
|
||||
? provider.api_keys[0]?.masked
|
||||
: undefined;
|
||||
const hasSavedApiKey = providerUsesApiKeys && provider.api_keys.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,12 +192,12 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
<ProviderForm
|
||||
editing
|
||||
key={provider.id}
|
||||
bedrockSavedAccessCredentials={hasBedrockStoredCredentials(
|
||||
provider,
|
||||
)}
|
||||
bedrockExternalId={bedrockExternalId(provider)}
|
||||
openAiAnthropicSavedApiKey={openAiAnthropicSavedApiKey}
|
||||
openAiAnthropicMaskedApiKey={openAiAnthropicMaskedApiKey}
|
||||
awsSavedAccessCredentials={hasBedrockStoredCredentials(provider)}
|
||||
awsExternalId={bedrockExternalId(provider)}
|
||||
hasSavedApiKey={hasSavedApiKey}
|
||||
// The single-key field shows only the first key; entering a replacement replaces the whole pool.
|
||||
// TODO: Support per-key editing: https://linear.app/codercom/issue/AIGOV-419
|
||||
savedApiKeyMask={provider.api_keys[0]?.masked}
|
||||
initialValues={aiProviderToFormValues(provider)}
|
||||
isLoading={updateMutation.isPending}
|
||||
submitError={updateMutation.error}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useId } from "react";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Link as DocsLink } from "#/components/Link/Link";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { docs } from "#/utils/docs";
|
||||
import type { FormHelpers } from "#/utils/formUtils";
|
||||
import { CredentialField } from "./CredentialField";
|
||||
import {
|
||||
CLAUDE_PLATFORM_DEFAULT_REGION,
|
||||
claudePlatformBaseUrl,
|
||||
} from "./claudePlatform";
|
||||
|
||||
/** Claude Platform is a settings variant of the Anthropic provider. */
|
||||
export type AnthropicAuthMethod = "api_key" | "claude_platform_aws";
|
||||
|
||||
type FieldHelpersGetter = (
|
||||
fieldName: string,
|
||||
options?: { backendFieldName?: string },
|
||||
) => FormHelpers;
|
||||
|
||||
type AnthropicAuthMethodFieldProps = {
|
||||
value: AnthropicAuthMethod;
|
||||
disabled?: boolean;
|
||||
onChange: (method: AnthropicAuthMethod) => void;
|
||||
};
|
||||
|
||||
export const AnthropicAuthMethodField: React.FC<
|
||||
AnthropicAuthMethodFieldProps
|
||||
> = ({ value, disabled = false, onChange }) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={id}>Platform</Label>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next === "api_key" || next === "claude_platform_aws")
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="api_key">Anthropic API</SelectItem>
|
||||
<SelectItem value="claude_platform_aws">
|
||||
Claude Platform for AWS
|
||||
<Badge variant="default" className="ml-2">
|
||||
Experimental
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{disabled && (
|
||||
<p className="text-xs text-content-secondary m-0">
|
||||
The platform is fixed after creation. Add a new provider to use
|
||||
another platform.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ClaudePlatformFieldsProps = {
|
||||
getFieldHelpers: FieldHelpersGetter;
|
||||
onRegionChange: (region: string) => void;
|
||||
onCredentialBlur: (field: "apiKey") => void;
|
||||
onCredentialFocus: (field: "apiKey") => void;
|
||||
};
|
||||
|
||||
export const ClaudePlatformFields: React.FC<ClaudePlatformFieldsProps> = ({
|
||||
getFieldHelpers,
|
||||
onRegionChange,
|
||||
onCredentialBlur,
|
||||
onCredentialFocus,
|
||||
}) => (
|
||||
<>
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
Claude Platform for AWS support is experimental and may change.{" "}
|
||||
<DocsLink
|
||||
size="sm"
|
||||
href={docs("/ai-coder/ai-gateway/providers#claude-platform-for-aws")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View docs
|
||||
</DocsLink>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 items-start gap-4">
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("claudePlatformRegion", {
|
||||
backendFieldName: "settings.region",
|
||||
})}
|
||||
label="Region"
|
||||
description="AWS region serving the workspace and scoping request signatures."
|
||||
className="w-full"
|
||||
placeholder={CLAUDE_PLATFORM_DEFAULT_REGION}
|
||||
onChange={(event) => onRegionChange(event.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("claudePlatformWorkspaceId", {
|
||||
backendFieldName: "settings.workspace_id",
|
||||
})}
|
||||
label="Workspace ID"
|
||||
description="Claude Platform workspace used for every request."
|
||||
className="w-full"
|
||||
placeholder="wrkspc_..."
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("baseUrl", { backendFieldName: "base_url" })}
|
||||
label="Endpoint"
|
||||
description="Defaults to the regional Claude Platform endpoint. Override it to route through a proxy."
|
||||
className="w-full"
|
||||
placeholder={claudePlatformBaseUrl(CLAUDE_PLATFORM_DEFAULT_REGION)}
|
||||
/>
|
||||
<CredentialField
|
||||
label="Workspace API key"
|
||||
helpers={getFieldHelpers("apiKey")}
|
||||
onBlur={() => onCredentialBlur("apiKey")}
|
||||
onFocus={() => onCredentialFocus("apiKey")}
|
||||
autoComplete="new-password"
|
||||
placeholder="sk-ant-..."
|
||||
/>
|
||||
<p className="text-xs text-content-secondary m-0">
|
||||
Optional. Client keys take precedence when BYOK is enabled, followed by
|
||||
stored provider keys. Without either, requests use the gateway's ambient
|
||||
AWS credentials. AWS identity is configured on the gateway, not per
|
||||
provider.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AIProviderType } from "#/api/typesGenerated";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import type { FormHelpers } from "#/utils/formUtils";
|
||||
import { CredentialField } from "./CredentialField";
|
||||
|
||||
type FieldHelpersGetter = (
|
||||
fieldName: string,
|
||||
options?: { backendFieldName?: string },
|
||||
) => FormHelpers;
|
||||
|
||||
type ProviderConnectionFieldsProps = {
|
||||
providerType: AIProviderType | "";
|
||||
getFieldHelpers: FieldHelpersGetter;
|
||||
baseUrlPlaceholder?: string;
|
||||
apiKeyPlaceholder?: string;
|
||||
onCredentialBlur: (field: "apiKey") => void;
|
||||
onCredentialFocus: (field: "apiKey") => void;
|
||||
};
|
||||
|
||||
export const ProviderConnectionFields: React.FC<
|
||||
ProviderConnectionFieldsProps
|
||||
> = ({
|
||||
providerType,
|
||||
getFieldHelpers,
|
||||
baseUrlPlaceholder,
|
||||
apiKeyPlaceholder,
|
||||
onCredentialBlur,
|
||||
onCredentialFocus,
|
||||
}) => (
|
||||
<>
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("baseUrl", {
|
||||
backendFieldName: "base_url",
|
||||
})}
|
||||
label="Endpoint"
|
||||
description={
|
||||
providerType === "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}
|
||||
/>
|
||||
{providerType === "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={providerType !== "anthropic"}
|
||||
label="API key"
|
||||
helpers={getFieldHelpers("apiKey")}
|
||||
onBlur={() => onCredentialBlur("apiKey")}
|
||||
onFocus={() => onCredentialFocus("apiKey")}
|
||||
autoComplete="new-password"
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -2,7 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
|
||||
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
|
||||
import {
|
||||
MockAIProviderClaudePlatformAWS,
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
} from "#/testHelpers/entities";
|
||||
import { ProviderForm, SAVED_CREDENTIAL_MASK } from "./ProviderForm";
|
||||
import { aiProviderToFormValues } from "./providerFormApiMap";
|
||||
|
||||
const meta: Meta<typeof ProviderForm> = {
|
||||
title: "pages/AISettingsPage/ProviderForm",
|
||||
@@ -297,7 +302,9 @@ export const AddBedrockWithoutStaticCredentials: Story = {
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i);
|
||||
const accessKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /^access key$/i,
|
||||
});
|
||||
const accessKeySecretInput =
|
||||
await canvas.findByLabelText(/access key secret/i);
|
||||
|
||||
@@ -343,7 +350,9 @@ export const AddBedrockHalfCredentialPairBlocked: Story = {
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i);
|
||||
const accessKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /^access key$/i,
|
||||
});
|
||||
|
||||
await userEvent.type(accessKeyInput, "AKIAIOSFODNN7EXAMPLE");
|
||||
|
||||
@@ -390,7 +399,7 @@ export const EditBedrockKeepCredentials: Story = {
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
bedrockSavedAccessCredentials: true,
|
||||
awsSavedAccessCredentials: true,
|
||||
initialValues: {
|
||||
type: "bedrock",
|
||||
name: "bedrock",
|
||||
@@ -464,8 +473,8 @@ export const EditBedrockKeepCredentials: Story = {
|
||||
export const EditBedrockWithExternalId: Story = {
|
||||
args: {
|
||||
editing: true,
|
||||
bedrockSavedAccessCredentials: true,
|
||||
bedrockExternalId: "7QF3ZK2MLP4RS6TUVWXY2ABCDE",
|
||||
awsSavedAccessCredentials: true,
|
||||
awsExternalId: "7QF3ZK2MLP4RS6TUVWXY2ABCDE",
|
||||
initialValues: {
|
||||
type: "bedrock",
|
||||
name: "bedrock",
|
||||
@@ -501,7 +510,9 @@ export const AddCopilot: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByLabelText(/endpoint/i);
|
||||
expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("textbox", { name: /api key/i }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -520,15 +531,17 @@ export const EditCopilot: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
const name = await canvas.findByLabelText(/^name/i);
|
||||
expect(name).toBeDisabled();
|
||||
expect(canvas.queryByLabelText(/api key/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("textbox", { name: /api key/i }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EditProvider: Story = {
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: true,
|
||||
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
|
||||
hasSavedApiKey: true,
|
||||
savedApiKeyMask: "sk-ant-***\u2026***ABCD",
|
||||
initialValues: {
|
||||
type: "anthropic",
|
||||
name: "production-anthropic",
|
||||
@@ -543,7 +556,7 @@ export const EditProvider: Story = {
|
||||
export const EditOpenAiAnthropicNoSavedKey: Story = {
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: false,
|
||||
hasSavedApiKey: false,
|
||||
initialValues: {
|
||||
type: "anthropic",
|
||||
name: "production-anthropic",
|
||||
@@ -580,8 +593,8 @@ export const CredentialFocusClear: Story = {
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: true,
|
||||
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
|
||||
hasSavedApiKey: true,
|
||||
savedApiKeyMask: "sk-ant-***\u2026***ABCD",
|
||||
initialValues: {
|
||||
type: "anthropic",
|
||||
name: "production-anthropic",
|
||||
@@ -593,7 +606,9 @@ export const CredentialFocusClear: Story = {
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
const apiKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /api key/i,
|
||||
});
|
||||
|
||||
expect(apiKeyInput).toHaveProperty("type", "text");
|
||||
expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD");
|
||||
@@ -644,8 +659,8 @@ export const FailedSubmitKeepsCredential: Story = {
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: true,
|
||||
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
|
||||
hasSavedApiKey: true,
|
||||
savedApiKeyMask: "sk-ant-***\u2026***ABCD",
|
||||
initialValues: {
|
||||
type: "anthropic",
|
||||
name: "production-anthropic",
|
||||
@@ -657,7 +672,9 @@ export const FailedSubmitKeepsCredential: Story = {
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
const apiKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /api key/i,
|
||||
});
|
||||
|
||||
await userEvent.click(apiKeyInput);
|
||||
await waitFor(() => expect(apiKeyInput).toHaveValue(""));
|
||||
@@ -699,8 +716,8 @@ export const ExternalLoadingKeepsCredential: Story = {
|
||||
},
|
||||
args: {
|
||||
editing: true,
|
||||
openAiAnthropicSavedApiKey: true,
|
||||
openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD",
|
||||
hasSavedApiKey: true,
|
||||
savedApiKeyMask: "sk-ant-***\u2026***ABCD",
|
||||
initialValues: {
|
||||
type: "anthropic",
|
||||
name: "production-anthropic",
|
||||
@@ -712,7 +729,9 @@ export const ExternalLoadingKeepsCredential: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
const apiKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /api key/i,
|
||||
});
|
||||
const submitButton = canvas.getByRole("button", {
|
||||
name: /update provider/i,
|
||||
});
|
||||
@@ -776,7 +795,9 @@ export const AddOpenAICompatSingleLabelHost: Story = {
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
const apiKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /api key/i,
|
||||
});
|
||||
await userEvent.type(apiKeyInput, "sk-local");
|
||||
|
||||
const submitButton = canvas.getByRole("button", { name: /add provider/i });
|
||||
@@ -803,7 +824,9 @@ export const AddOpenAICompatEmptyEndpointBlocked: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
const apiKeyInput = await canvas.findByRole("textbox", {
|
||||
name: /api key/i,
|
||||
});
|
||||
await userEvent.type(apiKeyInput, "sk-local");
|
||||
|
||||
const submitButton = canvas.getByRole("button", { name: /add provider/i });
|
||||
@@ -811,3 +834,62 @@ export const AddOpenAICompatEmptyEndpointBlocked: Story = {
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
},
|
||||
};
|
||||
|
||||
const claudePlatformCreateValues = {
|
||||
type: "anthropic" as const,
|
||||
authMethod: "claude_platform_aws" as const,
|
||||
name: "claude-platform",
|
||||
displayName: "Claude Platform",
|
||||
baseUrl: "https://aws-external-anthropic.us-east-1.api.aws",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "wrkspc_123",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const AddClaudePlatformKeyless: Story = {
|
||||
args: { initialValues: claudePlatformCreateValues },
|
||||
};
|
||||
|
||||
export const AddClaudePlatformWithWorkspaceKey: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...claudePlatformCreateValues,
|
||||
apiKey: "sk-ant-workspace",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EditClaudePlatformSavedKey: Story = {
|
||||
args: {
|
||||
editing: true,
|
||||
hasSavedApiKey: true,
|
||||
savedApiKeyMask: MockAIProviderClaudePlatformAWSAPIKey.api_keys[0].masked,
|
||||
initialValues: aiProviderToFormValues(
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ClaudePlatformValidationError: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...claudePlatformCreateValues,
|
||||
claudePlatformRegion: "",
|
||||
claudePlatformWorkspaceId: "",
|
||||
baseUrl: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ClaudePlatformSubmitError: Story = {
|
||||
render: (args) => (
|
||||
<ProviderForm
|
||||
{...args}
|
||||
submitError={new Error(errorSubmitMessage)}
|
||||
onSubmit={fn()}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
initialValues: aiProviderToFormValues(MockAIProviderClaudePlatformAWS),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MockAIProviderClaudePlatformAWSAPIKey } from "#/testHelpers/entities";
|
||||
import { render } from "#/testHelpers/renderHelpers";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
SAVED_CREDENTIAL_MASK,
|
||||
} from "./ProviderForm";
|
||||
import {
|
||||
aiProviderToFormValues,
|
||||
providerFormValuesToUpdate,
|
||||
} from "./providerFormApiMap";
|
||||
|
||||
describe("Claude Platform provider form", () => {
|
||||
it.each(["", "workspace-key"])(
|
||||
"submits with optional key %j and no auth mode",
|
||||
async (key) => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(values: ProviderFormValues) => void>();
|
||||
render(<ProviderForm onSubmit={onSubmit} />);
|
||||
await user.click(screen.getByRole("combobox", { name: "Platform" }));
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: /claude platform for aws/i }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /workspace id/i }),
|
||||
"wrkspc_test",
|
||||
);
|
||||
if (key) {
|
||||
await user.type(screen.getByLabelText(/^workspace api key/i), key);
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authMethod: "claude_platform_aws",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "wrkspc_test",
|
||||
apiKey: key,
|
||||
}),
|
||||
);
|
||||
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty(
|
||||
"claudePlatformAuthMode",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "an API-supplied mask",
|
||||
savedApiKeyMask:
|
||||
MockAIProviderClaudePlatformAWSAPIKey.api_keys[0]?.masked,
|
||||
expectedApiKey: MockAIProviderClaudePlatformAWSAPIKey.api_keys[0]?.masked,
|
||||
},
|
||||
{
|
||||
name: "the generic mask when no API mask is supplied",
|
||||
savedApiKeyMask: undefined,
|
||||
expectedApiKey: SAVED_CREDENTIAL_MASK,
|
||||
},
|
||||
])(
|
||||
"preserves a saved key after focus and blur with $name during an unrelated edit",
|
||||
async ({ savedApiKeyMask, expectedApiKey }) => {
|
||||
const user = userEvent.setup();
|
||||
const provider = MockAIProviderClaudePlatformAWSAPIKey;
|
||||
const onSubmit = vi.fn<(values: ProviderFormValues) => void>();
|
||||
render(
|
||||
<ProviderForm
|
||||
editing
|
||||
hasSavedApiKey
|
||||
savedApiKeyMask={savedApiKeyMask}
|
||||
initialValues={aiProviderToFormValues(provider)}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByLabelText(/^workspace api key/i));
|
||||
await user.click(screen.getByRole("textbox", { name: "Display name" }));
|
||||
await user.click(screen.getByRole("combobox", { name: "Platform" }));
|
||||
await user.keyboard("{ArrowUp}{Enter}");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Display name" }),
|
||||
" updated",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Update provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0].apiKey).toBe(expectedApiKey);
|
||||
const request = providerFormValuesToUpdate(
|
||||
onSubmit.mock.calls[0][0],
|
||||
provider,
|
||||
);
|
||||
expect(request.settings).toEqual(provider.settings);
|
||||
expect(request.api_keys).toEqual(
|
||||
provider.api_keys.map(({ id }) => ({ id })),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("updates regional endpoints but preserves a custom endpoint in the submitted values", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(values: ProviderFormValues) => void>();
|
||||
render(
|
||||
<ProviderForm
|
||||
initialValues={{
|
||||
authMethod: "claude_platform_aws",
|
||||
claudePlatformWorkspaceId: "wrkspc_test",
|
||||
baseUrl: "https://aws-external-anthropic.us-east-1.api.aws",
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
const region = screen.getByRole("textbox", { name: /^Region/ });
|
||||
await user.clear(region);
|
||||
await user.type(region, "_invalid");
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await user.clear(region);
|
||||
await user.type(region, "eu-west-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://aws-external-anthropic.eu-west-1.api.aws",
|
||||
}),
|
||||
);
|
||||
const endpoint = screen.getByRole("textbox", { name: /^Endpoint/ });
|
||||
await user.clear(endpoint);
|
||||
await user.type(endpoint, "https://proxy.example.com/anthropic");
|
||||
await user.clear(region);
|
||||
await user.type(region, "us-west-2");
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
|
||||
expect(onSubmit.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://proxy.example.com/anthropic",
|
||||
claudePlatformRegion: "us-west-2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires workspace and region before submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(values: ProviderFormValues) => void>();
|
||||
render(
|
||||
<ProviderForm
|
||||
initialValues={{ authMethod: "claude_platform_aws" }}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
const region = screen.getByRole("textbox", { name: /^Region/ });
|
||||
const workspace = screen.getByRole("textbox", { name: /workspace id/i });
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await user.type(workspace, "wrkspc_test");
|
||||
|
||||
await user.clear(region);
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await user.type(region, "us-east-1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "anthropic", baseUrl: "https://api.anthropic.com", key: "" },
|
||||
{
|
||||
type: "openai",
|
||||
baseUrl: "https://api.openai.com/v1/",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "azure",
|
||||
baseUrl: "https://azure.example.com/openai/v1",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "openai-compat",
|
||||
baseUrl: "https://provider.example.com/v1",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "vercel",
|
||||
baseUrl: "https://ai-gateway.vercel.sh/v1",
|
||||
key: "provider-key",
|
||||
},
|
||||
{
|
||||
type: "copilot",
|
||||
baseUrl: "https://api.business.githubcopilot.com",
|
||||
key: "",
|
||||
},
|
||||
] as const)(
|
||||
"only relaxes key requirements for $type as appropriate",
|
||||
async ({ type, baseUrl, key }) => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(values: ProviderFormValues) => void>();
|
||||
render(
|
||||
<ProviderForm initialValues={{ type, baseUrl }} onSubmit={onSubmit} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
if (key) {
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await user.type(screen.getByLabelText(/^API key/i), key);
|
||||
await user.click(screen.getByRole("button", { name: "Add provider" }));
|
||||
}
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||
expect(onSubmit.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({ type, baseUrl, apiKey: key }),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -27,10 +27,25 @@ import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import {
|
||||
type AnthropicAuthMethod,
|
||||
AnthropicAuthMethodField,
|
||||
ClaudePlatformFields,
|
||||
} from "./AnthropicAuthFields";
|
||||
import { addableProviders } from "./addableProviderTypes";
|
||||
import { CredentialField } from "./CredentialField";
|
||||
import {
|
||||
CLAUDE_PLATFORM_DEFAULT_REGION,
|
||||
CLAUDE_PLATFORM_REGION_REGEX,
|
||||
claudePlatformBaseUrl,
|
||||
isCanonicalClaudePlatformUrl,
|
||||
} from "./claudePlatform";
|
||||
import { ProviderConnectionFields } from "./ProviderConnectionFields";
|
||||
|
||||
export type ProviderFormValues = {
|
||||
type: AIProviderType | "";
|
||||
/** Only meaningful when type is "anthropic". */
|
||||
authMethod: AnthropicAuthMethod;
|
||||
name: string;
|
||||
displayName: string;
|
||||
icon: string;
|
||||
@@ -38,6 +53,9 @@ export type ProviderFormValues = {
|
||||
protocol: AIProviderBedrockProtocol;
|
||||
model: string;
|
||||
smallFastModel: string;
|
||||
|
||||
claudePlatformRegion: string;
|
||||
claudePlatformWorkspaceId: string;
|
||||
accessKey: string;
|
||||
accessKeySecret: string;
|
||||
roleArn: string;
|
||||
@@ -84,6 +102,7 @@ const makeDisplayNameSchema = (_editing: boolean) => Yup.string();
|
||||
|
||||
const defaultInitialValues: ProviderFormValues = {
|
||||
type: "anthropic",
|
||||
authMethod: "api_key",
|
||||
name: "",
|
||||
displayName: "",
|
||||
icon: "",
|
||||
@@ -91,6 +110,9 @@ const defaultInitialValues: ProviderFormValues = {
|
||||
protocol: "invoke-model",
|
||||
model: "",
|
||||
smallFastModel: "",
|
||||
|
||||
claudePlatformRegion: CLAUDE_PLATFORM_DEFAULT_REGION,
|
||||
claudePlatformWorkspaceId: "",
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
@@ -117,11 +139,13 @@ const BEDROCK_DEFAULT_SMALL_FAST_MODEL =
|
||||
const BEDROCK_MODEL_CARDS_URL =
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html";
|
||||
|
||||
const ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com";
|
||||
|
||||
const providerDefaults: Partial<
|
||||
Record<AIProviderType, Partial<ProviderFormValues>>
|
||||
> = {
|
||||
openai: { name: "openai", baseUrl: "https://api.openai.com/v1/" },
|
||||
anthropic: { name: "anthropic", baseUrl: "https://api.anthropic.com" },
|
||||
anthropic: { name: "anthropic", baseUrl: ANTHROPIC_DEFAULT_BASE_URL },
|
||||
bedrock: {
|
||||
name: "bedrock",
|
||||
baseUrl: bedrockInvokeModelBaseUrl(BEDROCK_DEFAULT_REGION),
|
||||
@@ -167,9 +191,11 @@ const makeOpenAiAnthropicSchema = (editing: boolean) =>
|
||||
icon: Yup.string(),
|
||||
// URL shape is validated by the backend; the form only checks presence.
|
||||
baseUrl: Yup.string().required("Endpoint is required"),
|
||||
apiKey: editing
|
||||
? Yup.string()
|
||||
: Yup.string().required("API key is required"),
|
||||
apiKey: Yup.string().when("type", {
|
||||
is: (type: string) => type === "anthropic" || editing,
|
||||
then: (schema) => schema,
|
||||
otherwise: (schema) => schema.required("API key is required"),
|
||||
}),
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
@@ -179,7 +205,7 @@ const credentialFilled = (value: string | undefined): boolean => {
|
||||
return trimmed !== "" && trimmed !== SAVED_CREDENTIAL_MASK;
|
||||
};
|
||||
|
||||
const BEDROCK_ACCESS_KEY_PAIRED_MESSAGE =
|
||||
const AWS_ACCESS_KEY_PAIRED_MESSAGE =
|
||||
"Enter both access key and secret, or leave both blank to use AWS environment credentials.";
|
||||
|
||||
// Bedrock access keys are optional: when both are blank the server
|
||||
@@ -227,7 +253,7 @@ const makeBedrockSchema = (editing: boolean) =>
|
||||
}),
|
||||
accessKey: Yup.string().test(
|
||||
"access-key-paired",
|
||||
BEDROCK_ACCESS_KEY_PAIRED_MESSAGE,
|
||||
AWS_ACCESS_KEY_PAIRED_MESSAGE,
|
||||
function (value) {
|
||||
const secret = (this.parent as { accessKeySecret?: string })
|
||||
.accessKeySecret;
|
||||
@@ -236,7 +262,7 @@ const makeBedrockSchema = (editing: boolean) =>
|
||||
),
|
||||
accessKeySecret: Yup.string().test(
|
||||
"access-key-secret-paired",
|
||||
BEDROCK_ACCESS_KEY_PAIRED_MESSAGE,
|
||||
AWS_ACCESS_KEY_PAIRED_MESSAGE,
|
||||
function (value) {
|
||||
const accessKey = (this.parent as { accessKey?: string }).accessKey;
|
||||
return !(credentialFilled(accessKey) && !credentialFilled(value));
|
||||
@@ -245,6 +271,34 @@ const makeBedrockSchema = (editing: boolean) =>
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
const makeClaudePlatformSchema = (editing: boolean) =>
|
||||
Yup.object({
|
||||
type: Yup.string()
|
||||
.oneOf(["anthropic"] as const)
|
||||
.required(),
|
||||
authMethod: Yup.string()
|
||||
.oneOf(["claude_platform_aws"] as const)
|
||||
.required(),
|
||||
name: makeNameSchema(editing),
|
||||
displayName: makeDisplayNameSchema(editing),
|
||||
icon: Yup.string(),
|
||||
// The endpoint may be overridden for a proxy, so only presence is
|
||||
// checked; the region below, not the host, decides the signing scope.
|
||||
baseUrl: Yup.string().required("Endpoint is required"),
|
||||
|
||||
claudePlatformRegion: Yup.string()
|
||||
.matches(
|
||||
CLAUDE_PLATFORM_REGION_REGEX,
|
||||
"Region must be lowercase, hyphen-separated (e.g. 'us-east-1').",
|
||||
)
|
||||
.required("Region is required"),
|
||||
claudePlatformWorkspaceId: Yup.string().required(
|
||||
"Workspace ID is required",
|
||||
),
|
||||
apiKey: Yup.string(),
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
const makeCopilotSchema = (editing: boolean) =>
|
||||
Yup.object({
|
||||
type: Yup.string()
|
||||
@@ -258,49 +312,48 @@ const makeCopilotSchema = (editing: boolean) =>
|
||||
});
|
||||
|
||||
const getProviderFormSchema = (editing: boolean) =>
|
||||
Yup.lazy((value: { type?: AIProviderType } | undefined) => {
|
||||
switch (value?.type) {
|
||||
case "openai":
|
||||
case "anthropic":
|
||||
case "azure":
|
||||
case "google":
|
||||
case "openai-compat":
|
||||
case "openrouter":
|
||||
case "vercel":
|
||||
return makeOpenAiAnthropicSchema(editing);
|
||||
case "bedrock":
|
||||
return makeBedrockSchema(editing);
|
||||
case "copilot":
|
||||
return makeCopilotSchema(editing);
|
||||
default:
|
||||
return Yup.object({
|
||||
type: Yup.string()
|
||||
.oneOf([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"bedrock",
|
||||
"azure",
|
||||
"copilot",
|
||||
"google",
|
||||
"openai-compat",
|
||||
"openrouter",
|
||||
"vercel",
|
||||
])
|
||||
.required(),
|
||||
});
|
||||
}
|
||||
});
|
||||
Yup.lazy(
|
||||
(
|
||||
value:
|
||||
| Partial<Pick<ProviderFormValues, "type" | "authMethod">>
|
||||
| undefined,
|
||||
) => {
|
||||
switch (value?.type) {
|
||||
case "openai":
|
||||
case "azure":
|
||||
case "google":
|
||||
case "openai-compat":
|
||||
case "openrouter":
|
||||
case "vercel":
|
||||
return makeOpenAiAnthropicSchema(editing);
|
||||
case "anthropic":
|
||||
return value.authMethod === "claude_platform_aws"
|
||||
? makeClaudePlatformSchema(editing)
|
||||
: makeOpenAiAnthropicSchema(editing);
|
||||
case "bedrock":
|
||||
return makeBedrockSchema(editing);
|
||||
case "copilot":
|
||||
return makeCopilotSchema(editing);
|
||||
default:
|
||||
return Yup.object({
|
||||
type: Yup.string()
|
||||
.oneOf(addableProviders.map(({ value: type }) => type))
|
||||
.required(),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type ProviderFormProps = {
|
||||
editing?: boolean;
|
||||
/** When editing Bedrock and the API already has keys, show masked placeholders until cleared. */
|
||||
bedrockSavedAccessCredentials?: boolean;
|
||||
/** When editing an AWS-signed provider whose secrets are on file, show masked placeholders until cleared. */
|
||||
awsSavedAccessCredentials?: boolean;
|
||||
/** Server-generated STS external ID, shown read-only when a role is assumed. */
|
||||
bedrockExternalId?: string;
|
||||
/** When editing openai/anthropic and a key is on file, show a masked placeholder until cleared. */
|
||||
openAiAnthropicSavedApiKey?: boolean;
|
||||
/** Masked rendering of the saved openai/anthropic key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */
|
||||
openAiAnthropicMaskedApiKey?: string;
|
||||
awsExternalId?: string;
|
||||
/** A provider API key is on file: show a masked placeholder until cleared, and treat the key input as optional. */
|
||||
hasSavedApiKey?: boolean;
|
||||
/** Masked rendering of the saved key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */
|
||||
savedApiKeyMask?: string;
|
||||
initialValues?: Partial<ProviderFormValues>;
|
||||
/** Fires whenever the icon field changes, so page headers can preview it. */
|
||||
onIconChange?: (icon: string) => void;
|
||||
@@ -327,10 +380,10 @@ const baseUrlPlaceholder = (provider: string) =>
|
||||
|
||||
export const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
editing = false,
|
||||
bedrockSavedAccessCredentials = false,
|
||||
bedrockExternalId,
|
||||
openAiAnthropicSavedApiKey = false,
|
||||
openAiAnthropicMaskedApiKey,
|
||||
awsSavedAccessCredentials = false,
|
||||
awsExternalId,
|
||||
hasSavedApiKey = false,
|
||||
savedApiKeyMask,
|
||||
initialValues,
|
||||
onIconChange,
|
||||
onSubmit,
|
||||
@@ -341,18 +394,18 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
const typeDefaults =
|
||||
providerDefaults[resolvedType as keyof typeof providerDefaults];
|
||||
|
||||
// Seed Bedrock credentials with the mask when on file; focus clears it,
|
||||
// Seed AWS credentials with the mask when on file; focus clears it,
|
||||
// and a re-submitted "" tells the API mapping to keep the value.
|
||||
const maskedAccessKey = bedrockSavedAccessCredentials
|
||||
const maskedAccessKey = awsSavedAccessCredentials
|
||||
? SAVED_CREDENTIAL_MASK
|
||||
: "";
|
||||
const maskedAccessKeySecret = bedrockSavedAccessCredentials
|
||||
const maskedAccessKeySecret = awsSavedAccessCredentials
|
||||
? SAVED_CREDENTIAL_MASK
|
||||
: "";
|
||||
// Same pattern for openai/anthropic. Prefer the API-supplied masked
|
||||
// Same pattern for the provider API key. Prefer the API-supplied masked
|
||||
// rendering so the user sees the key's identifying suffix.
|
||||
const maskedApiKey = openAiAnthropicSavedApiKey
|
||||
? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK)
|
||||
const maskedApiKey = hasSavedApiKey
|
||||
? (savedApiKeyMask ?? SAVED_CREDENTIAL_MASK)
|
||||
: "";
|
||||
|
||||
const didSubmit = useRef(false);
|
||||
@@ -436,6 +489,57 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
|
||||
const isMantle = form.values.protocol === "mantle";
|
||||
|
||||
const isClaudePlatform =
|
||||
form.values.type === "anthropic" &&
|
||||
form.values.authMethod === "claude_platform_aws";
|
||||
|
||||
// Switching the authentication method swaps the endpoint to the other
|
||||
// method's default and drops credentials typed for the previous one. An
|
||||
// operator-supplied endpoint is left alone: only a value still equal to the
|
||||
// outgoing default is replaced.
|
||||
const handleAnthropicAuthMethodChange = (method: AnthropicAuthMethod) => {
|
||||
const region =
|
||||
form.values.claudePlatformRegion.trim() || CLAUDE_PLATFORM_DEFAULT_REGION;
|
||||
const toClaudePlatform = method === "claude_platform_aws";
|
||||
const replaceable = toClaudePlatform
|
||||
? form.values.baseUrl.trim() === "" ||
|
||||
form.values.baseUrl.trim() === ANTHROPIC_DEFAULT_BASE_URL
|
||||
: isCanonicalClaudePlatformUrl(form.values.baseUrl);
|
||||
void form.setValues({
|
||||
...form.values,
|
||||
authMethod: method,
|
||||
claudePlatformRegion: region,
|
||||
...(replaceable
|
||||
? {
|
||||
baseUrl: toClaudePlatform
|
||||
? claudePlatformBaseUrl(region)
|
||||
: ANTHROPIC_DEFAULT_BASE_URL,
|
||||
}
|
||||
: {}),
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
apiKey: "",
|
||||
});
|
||||
};
|
||||
|
||||
// The region sets the SigV4 signing scope, so it stays explicit. The
|
||||
// endpoint follows the region only while it is the canonical regional host;
|
||||
// an operator-supplied proxy URL is left untouched. A region that is not yet
|
||||
// a valid host label is not interpolated, so the endpoint stays canonical
|
||||
// and resumes tracking once the region is valid again.
|
||||
const handleClaudePlatformRegionChange = (region: string) => {
|
||||
const trimmed = region.trim();
|
||||
const tracksRegion =
|
||||
CLAUDE_PLATFORM_REGION_REGEX.test(trimmed) &&
|
||||
isCanonicalClaudePlatformUrl(form.values.baseUrl);
|
||||
void form.setValues({
|
||||
...form.values,
|
||||
claudePlatformRegion: region,
|
||||
...(tracksRegion ? { baseUrl: claudePlatformBaseUrl(trimmed) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// When the parent's mutation finishes without an error, treat the just-
|
||||
// submitted values as the new baseline so the unsaved-changes prompt does
|
||||
// not fire on subsequent navigations. React Query reports a missing error
|
||||
@@ -494,43 +598,28 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{iconField}
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("baseUrl", {
|
||||
backendFieldName: "base_url",
|
||||
})}
|
||||
label="Endpoint"
|
||||
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)}
|
||||
/>
|
||||
{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>
|
||||
{typeSelectValue === "anthropic" && (
|
||||
<AnthropicAuthMethodField
|
||||
value={form.values.authMethod}
|
||||
disabled={editing}
|
||||
onChange={handleAnthropicAuthMethodChange}
|
||||
/>
|
||||
)}
|
||||
{isClaudePlatform ? (
|
||||
<ClaudePlatformFields
|
||||
getFieldHelpers={getFieldHelpers}
|
||||
onRegionChange={handleClaudePlatformRegionChange}
|
||||
onCredentialBlur={handleCredentialBlur}
|
||||
onCredentialFocus={handleCredentialFocus}
|
||||
/>
|
||||
) : (
|
||||
<CredentialField
|
||||
required
|
||||
label="API key"
|
||||
helpers={getFieldHelpers("apiKey")}
|
||||
onBlur={() => handleCredentialBlur("apiKey")}
|
||||
onFocus={() => handleCredentialFocus("apiKey")}
|
||||
autoComplete="new-password"
|
||||
placeholder={apiKeyPlaceholder(form.values.type)}
|
||||
<ProviderConnectionFields
|
||||
providerType={form.values.type}
|
||||
getFieldHelpers={getFieldHelpers}
|
||||
baseUrlPlaceholder={baseUrlPlaceholder(form.values.type)}
|
||||
apiKeyPlaceholder={apiKeyPlaceholder(form.values.type)}
|
||||
onCredentialBlur={handleCredentialBlur}
|
||||
onCredentialFocus={handleCredentialFocus}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -673,10 +762,10 @@ export const ProviderForm: React.FC<ProviderFormProps> = ({
|
||||
Optional. When a role ARN is set, the gateway assumes that role
|
||||
(using the base identity) before calling Bedrock.
|
||||
</p>
|
||||
{editing && bedrockExternalId && (
|
||||
{editing && awsExternalId && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>External ID</Label>
|
||||
<CodeExample secret={false} code={bedrockExternalId} />
|
||||
<CodeExample secret={false} code={awsExternalId} />
|
||||
<p className="text-xs text-content-secondary m-0">
|
||||
Server-generated. Add it to the assumed role's trust policy as
|
||||
an <code>sts:ExternalId</code> condition so only this
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Claude Platform for AWS is Anthropic's Messages API hosted on AWS. It is an
|
||||
// authentication method on the `anthropic` provider type rather than a
|
||||
// provider type of its own, so these helpers are shared by the form, the
|
||||
// nested fields component, and the API mapping.
|
||||
|
||||
export const CLAUDE_PLATFORM_DEFAULT_REGION = "us-east-1";
|
||||
|
||||
// Claude Platform for AWS is an authentication method on `anthropic`, not a
|
||||
// provider type, so list rows and icons key off this synthetic value instead
|
||||
// of a real AIProviderType.
|
||||
export const CLAUDE_PLATFORM_DISPLAY_TYPE = "claude-platform-aws";
|
||||
|
||||
// AWS region names are lowercase alphanumerics and hyphens. The value is
|
||||
// interpolated into the regional host, so a malformed region would otherwise
|
||||
// produce an endpoint that no longer tracks later region edits.
|
||||
export const CLAUDE_PLATFORM_REGION_REGEX = /^[a-z0-9-]+$/;
|
||||
|
||||
// Regional endpoint, e.g. https://aws-external-anthropic.{region}.api.aws
|
||||
const CLAUDE_PLATFORM_URL_REGEX =
|
||||
/^https:\/\/aws-external-anthropic\.([a-z0-9-]+)\.api\.aws\/?$/i;
|
||||
|
||||
export const claudePlatformBaseUrl = (region: string) =>
|
||||
`https://aws-external-anthropic.${region}.api.aws`;
|
||||
|
||||
// Whether the endpoint is a generated regional host rather than an operator
|
||||
// supplied proxy. Only generated values are rewritten when the region changes,
|
||||
// so a proxy URL is never silently replaced.
|
||||
export const isCanonicalClaudePlatformUrl = (baseUrl: string): boolean =>
|
||||
CLAUDE_PLATFORM_URL_REGEX.test(baseUrl.trim());
|
||||
+198
-17
@@ -3,9 +3,12 @@ import type { AIProvider } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockAIProviderAnthropic,
|
||||
MockAIProviderBedrock,
|
||||
MockAIProviderClaudePlatformAWS,
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
MockAIProviderCopilot,
|
||||
MockAIProviderOpenAI,
|
||||
} from "#/testHelpers/entities";
|
||||
import { CLAUDE_PLATFORM_DISPLAY_TYPE } from "./claudePlatform";
|
||||
import {
|
||||
type ProviderFormValues,
|
||||
parseBedrockRegionFromBaseUrl,
|
||||
@@ -17,12 +20,14 @@ import {
|
||||
getProviderDisplayType,
|
||||
hasBedrockStoredCredentials,
|
||||
isBedrockProvider,
|
||||
isClaudePlatformProvider,
|
||||
providerFormValuesToCreate,
|
||||
providerFormValuesToUpdate,
|
||||
} from "./providerFormApiMap";
|
||||
|
||||
const baseOpenAIFormValues: ProviderFormValues = {
|
||||
type: "openai",
|
||||
authMethod: "api_key",
|
||||
name: "primary-openai",
|
||||
displayName: "Primary OpenAI",
|
||||
icon: "",
|
||||
@@ -30,6 +35,8 @@ const baseOpenAIFormValues: ProviderFormValues = {
|
||||
protocol: "invoke-model",
|
||||
model: "",
|
||||
smallFastModel: "",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "",
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
@@ -39,6 +46,7 @@ const baseOpenAIFormValues: ProviderFormValues = {
|
||||
|
||||
const baseBedrockFormValues: ProviderFormValues = {
|
||||
type: "bedrock",
|
||||
authMethod: "api_key",
|
||||
name: "primary-bedrock",
|
||||
displayName: "Primary Bedrock",
|
||||
icon: "",
|
||||
@@ -46,6 +54,8 @@ const baseBedrockFormValues: ProviderFormValues = {
|
||||
protocol: "invoke-model",
|
||||
model: "anthropic.claude-sonnet-4-5",
|
||||
smallFastModel: "anthropic.claude-haiku-4-5",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "",
|
||||
accessKey: "AKIA-test",
|
||||
accessKeySecret: "secret",
|
||||
roleArn: "",
|
||||
@@ -55,6 +65,7 @@ const baseBedrockFormValues: ProviderFormValues = {
|
||||
|
||||
const baseCopilotFormValues: ProviderFormValues = {
|
||||
type: "copilot",
|
||||
authMethod: "api_key",
|
||||
name: "copilot",
|
||||
displayName: "GitHub Copilot",
|
||||
icon: "",
|
||||
@@ -62,6 +73,27 @@ const baseCopilotFormValues: ProviderFormValues = {
|
||||
protocol: "invoke-model",
|
||||
model: "",
|
||||
smallFastModel: "",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "",
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
apiKey: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const baseClaudePlatformFormValues: ProviderFormValues = {
|
||||
type: "anthropic",
|
||||
authMethod: "claude_platform_aws",
|
||||
name: "claude-platform",
|
||||
displayName: "Claude Platform",
|
||||
icon: "",
|
||||
baseUrl: "https://aws-external-anthropic.us-east-1.api.aws",
|
||||
protocol: "invoke-model",
|
||||
model: "",
|
||||
smallFastModel: "",
|
||||
claudePlatformRegion: "us-east-1",
|
||||
claudePlatformWorkspaceId: "wrkspc_123",
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
@@ -200,13 +232,13 @@ describe("isBedrockProvider", () => {
|
||||
|
||||
describe("hasBedrockStoredCredentials", () => {
|
||||
it("is true whenever the provider is Bedrock", () => {
|
||||
// Bedrock secrets are write-only, so we cannot inspect their
|
||||
// presence; the helper assumes any persisted Bedrock config
|
||||
// implies credentials are on file.
|
||||
expect(hasBedrockStoredCredentials(MockAIProviderBedrock)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for non-Bedrock providers", () => {
|
||||
it("is false for Claude Platform and non-AWS providers", () => {
|
||||
expect(hasBedrockStoredCredentials(MockAIProviderClaudePlatformAWS)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasBedrockStoredCredentials(MockAIProviderOpenAI)).toBe(false);
|
||||
expect(hasBedrockStoredCredentials(MockAIProviderAnthropic)).toBe(false);
|
||||
});
|
||||
@@ -225,23 +257,56 @@ describe("bedrockExternalId", () => {
|
||||
expect(bedrockExternalId(provider)).toBe("7QF3ZK2MLP4RS6TUVWXY2ABCDE");
|
||||
});
|
||||
|
||||
it("returns undefined when the provider has no external ID", () => {
|
||||
expect(bedrockExternalId(MockAIProviderBedrock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the external ID is an empty string", () => {
|
||||
const provider: AIProvider = {
|
||||
...MockAIProviderBedrock,
|
||||
settings: settings({ _type: "bedrock", external_id: "" }),
|
||||
};
|
||||
expect(bedrockExternalId(provider)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-Bedrock provider", () => {
|
||||
it("returns undefined for Claude Platform and non-Bedrock providers", () => {
|
||||
expect(bedrockExternalId(MockAIProviderClaudePlatformAWS)).toBeUndefined();
|
||||
expect(bedrockExternalId(MockAIProviderOpenAI)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClaudePlatformProvider", () => {
|
||||
it("recognises the settings discriminator", () => {
|
||||
expect(isClaudePlatformProvider(MockAIProviderClaudePlatformAWS)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a plain Anthropic provider with null settings", () => {
|
||||
expect(isClaudePlatformProvider(MockAIProviderAnthropic)).toBe(false);
|
||||
expect(aiProviderToFormValues(MockAIProviderAnthropic).type).toBe(
|
||||
"anthropic",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a Bedrock provider", () => {
|
||||
expect(isClaudePlatformProvider(MockAIProviderBedrock)).toBe(false);
|
||||
expect(isBedrockProvider(MockAIProviderClaudePlatformAWS)).toBe(false);
|
||||
});
|
||||
|
||||
it("recognises discriminator-only settings", () => {
|
||||
const provider: AIProvider = {
|
||||
...MockAIProviderClaudePlatformAWS,
|
||||
settings: { _type: "claude_platform_aws" },
|
||||
};
|
||||
expect(isClaudePlatformProvider(provider)).toBe(true);
|
||||
expect(aiProviderToFormValues(provider)).toEqual(
|
||||
expect.objectContaining({
|
||||
claudePlatformRegion: "",
|
||||
claudePlatformWorkspaceId: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects the settings on a non-Anthropic type", () => {
|
||||
// The server only accepts Claude Platform settings on `anthropic`, so a
|
||||
// hand-crafted row on another type must not be treated as configured.
|
||||
const provider: AIProvider = {
|
||||
...MockAIProviderClaudePlatformAWS,
|
||||
type: "openai",
|
||||
};
|
||||
expect(isClaudePlatformProvider(provider)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderDisplayType", () => {
|
||||
it("returns bedrock for a Bedrock provider", () => {
|
||||
expect(getProviderDisplayType(MockAIProviderBedrock)).toBe("bedrock");
|
||||
@@ -251,6 +316,14 @@ describe("getProviderDisplayType", () => {
|
||||
expect(getProviderDisplayType(MockAIProviderAnthropic)).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("returns the synthetic Claude Platform type", () => {
|
||||
// Claude Platform is not an AIProviderType, so list rows key off a
|
||||
// synthetic value to pick the AWS glyph.
|
||||
expect(getProviderDisplayType(MockAIProviderClaudePlatformAWS)).toBe(
|
||||
CLAUDE_PLATFORM_DISPLAY_TYPE,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns openai for the canonical OpenAI host", () => {
|
||||
expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai");
|
||||
});
|
||||
@@ -883,3 +956,111 @@ describe("aiProviderToFormValues", () => {
|
||||
expect(values.type).toBe("bedrock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Platform for AWS mapping", () => {
|
||||
describe("providerFormValuesToCreate", () => {
|
||||
it("sends the exact backend Claude Platform settings shape", () => {
|
||||
const req = providerFormValuesToCreate(baseClaudePlatformFormValues);
|
||||
expect(req.type).toBe("anthropic");
|
||||
expect(req.api_keys).toBeUndefined();
|
||||
expect(req.settings).toEqual({
|
||||
_type: "claude_platform_aws",
|
||||
_version: 1,
|
||||
region: "us-east-1",
|
||||
workspace_id: "wrkspc_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an optional provider API key without changing settings", () => {
|
||||
const req = providerFormValuesToCreate({
|
||||
...baseClaudePlatformFormValues,
|
||||
apiKey: "sk-ant-workspace",
|
||||
});
|
||||
expect(req.api_keys).toEqual(["sk-ant-workspace"]);
|
||||
expect(req.settings).toEqual({
|
||||
_type: "claude_platform_aws",
|
||||
_version: 1,
|
||||
region: "us-east-1",
|
||||
workspace_id: "wrkspc_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a proxy endpoint while still sending the region", () => {
|
||||
const req = providerFormValuesToCreate({
|
||||
...baseClaudePlatformFormValues,
|
||||
baseUrl: "https://anthropic-proxy.internal.example.com",
|
||||
claudePlatformRegion: "eu-west-1",
|
||||
});
|
||||
expect(req.base_url).toBe("https://anthropic-proxy.internal.example.com");
|
||||
expect(req.settings).toEqual(
|
||||
expect.objectContaining({ region: "eu-west-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerFormValuesToUpdate", () => {
|
||||
it("sends the exact four-field settings payload", () => {
|
||||
const req = providerFormValuesToUpdate(
|
||||
baseClaudePlatformFormValues,
|
||||
MockAIProviderClaudePlatformAWS,
|
||||
);
|
||||
expect(req.settings).toEqual({
|
||||
_type: "claude_platform_aws",
|
||||
_version: 1,
|
||||
region: "us-east-1",
|
||||
workspace_id: "wrkspc_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves every saved key when the field is blank or masked", () => {
|
||||
const existingProvider: AIProvider = {
|
||||
...MockAIProviderClaudePlatformAWSAPIKey,
|
||||
api_keys: [
|
||||
...MockAIProviderClaudePlatformAWSAPIKey.api_keys,
|
||||
{
|
||||
id: "8e1f4a25-7b63-4d90-a2c8-9f0e5b6d7a81",
|
||||
masked: "sk-ant-***\\u2026***QRST",
|
||||
created_at: "2026-05-14T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
for (const apiKey of ["", SAVED_CREDENTIAL_MASK]) {
|
||||
const req = providerFormValuesToUpdate(
|
||||
{ ...baseClaudePlatformFormValues, apiKey },
|
||||
existingProvider,
|
||||
);
|
||||
expect(req.api_keys).toEqual(
|
||||
existingProvider.api_keys.map((key) => ({ id: key.id })),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates the provider key when a new plaintext is entered", () => {
|
||||
const req = providerFormValuesToUpdate(
|
||||
{ ...baseClaudePlatformFormValues, apiKey: "sk-ant-rotated" },
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
);
|
||||
expect(req.api_keys).toEqual([{ api_key: "sk-ant-rotated" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiProviderToFormValues", () => {
|
||||
it("maps backend-shaped Claude Platform settings without an auth mode", () => {
|
||||
const values = aiProviderToFormValues(MockAIProviderClaudePlatformAWS);
|
||||
expect(values.type).toBe("anthropic");
|
||||
expect(values.authMethod).toBe("claude_platform_aws");
|
||||
expect(values.claudePlatformRegion).toBe("us-east-1");
|
||||
expect(values.claudePlatformWorkspaceId).toBe("wrkspc_123");
|
||||
expect(values).not.toHaveProperty("claudePlatformAuthMode");
|
||||
});
|
||||
|
||||
it("does not infer mode or AWS identity fields from a provider key", () => {
|
||||
const values = aiProviderToFormValues(
|
||||
MockAIProviderClaudePlatformAWSAPIKey,
|
||||
);
|
||||
expect(values.apiKey).toBe("");
|
||||
expect(values).not.toHaveProperty("claudePlatformAuthMode");
|
||||
expect(values.roleArn).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,19 @@ import type {
|
||||
AIProvider,
|
||||
AIProviderBedrockProtocol,
|
||||
AIProviderBedrockSettings,
|
||||
AIProviderClaudePlatformAWSSettings,
|
||||
AIProviderKeyMutation,
|
||||
AIProviderSettings,
|
||||
AIProviderType,
|
||||
CreateAIProviderRequest,
|
||||
UpdateAIProviderRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
AIProviderClaudePlatformAWSSettingsVersion,
|
||||
AIProviderSettingsTypeClaudePlatformAWS,
|
||||
} from "#/api/typesGenerated";
|
||||
import { getProviderIcon } from "#/modules/aiModels/ProviderIcon";
|
||||
import { CLAUDE_PLATFORM_DISPLAY_TYPE } from "./claudePlatform";
|
||||
import {
|
||||
type ProviderFormValues,
|
||||
parseBedrockRegionFromBaseUrl,
|
||||
@@ -35,13 +41,21 @@ const sanitizeCredential = (
|
||||
const BEDROCK_SETTINGS_TYPE = "bedrock";
|
||||
const BEDROCK_SETTINGS_VERSION = 1;
|
||||
|
||||
type ProviderDisplayType = AIProviderType | typeof CLAUDE_PLATFORM_DISPLAY_TYPE;
|
||||
|
||||
type BedrockSettingsWire = AIProviderBedrockSettings & {
|
||||
_type: typeof BEDROCK_SETTINGS_TYPE;
|
||||
_version: typeof BEDROCK_SETTINGS_VERSION;
|
||||
};
|
||||
|
||||
type ClaudePlatformSettingsWire = AIProviderClaudePlatformAWSSettings & {
|
||||
_type: typeof AIProviderSettingsTypeClaudePlatformAWS;
|
||||
_version: typeof AIProviderClaudePlatformAWSSettingsVersion;
|
||||
};
|
||||
|
||||
type SettingsWire = AIProviderSettings &
|
||||
Partial<AIProviderBedrockSettings> & {
|
||||
Partial<AIProviderBedrockSettings> &
|
||||
Partial<AIProviderClaudePlatformAWSSettings> & {
|
||||
_type?: string;
|
||||
_version?: number;
|
||||
};
|
||||
@@ -57,7 +71,22 @@ export const isBedrockProvider = (provider: AIProvider): boolean => {
|
||||
return s !== null && s._type === BEDROCK_SETTINGS_TYPE;
|
||||
};
|
||||
|
||||
// Server-generated STS external ID; read-only.
|
||||
export const isClaudePlatformProvider = (
|
||||
provider: AIProvider,
|
||||
): provider is AIProvider & {
|
||||
type: "anthropic";
|
||||
settings: SettingsWire & {
|
||||
_type: typeof AIProviderSettingsTypeClaudePlatformAWS;
|
||||
};
|
||||
} => {
|
||||
if (provider.type !== "anthropic") {
|
||||
return false;
|
||||
}
|
||||
const s = provider.settings as SettingsWire | null;
|
||||
return s !== null && s._type === AIProviderSettingsTypeClaudePlatformAWS;
|
||||
};
|
||||
|
||||
// Server-generated STS external ID; read-only for Bedrock providers.
|
||||
export const bedrockExternalId = (provider: AIProvider): string | undefined => {
|
||||
if (!isBedrockProvider(provider)) {
|
||||
return undefined;
|
||||
@@ -96,16 +125,19 @@ const displayTypeHosts: ReadonlyArray<[string, AIProviderType]> = [
|
||||
const matchesHost = (host: string, suffix: string): boolean =>
|
||||
host === suffix || host.endsWith(`.${suffix}`);
|
||||
|
||||
// Determines which UI provider type to show for a saved provider. Bedrock is
|
||||
// detected via settings. Explicit stored types are authoritative. Generic
|
||||
// `openai` rows fall back to host inference from known preset endpoints;
|
||||
// unrecognized hosts stay as `openai`.
|
||||
// Determines which UI provider type to show for a saved provider. Bedrock and
|
||||
// Claude Platform are detected via settings. Explicit stored types are
|
||||
// authoritative. Generic `openai` rows fall back to host inference from known
|
||||
// preset endpoints; unrecognized hosts stay as `openai`.
|
||||
export const getProviderDisplayType = (
|
||||
provider: AIProvider,
|
||||
): AIProviderType => {
|
||||
): ProviderDisplayType => {
|
||||
if (isBedrockProvider(provider)) {
|
||||
return "bedrock";
|
||||
}
|
||||
if (isClaudePlatformProvider(provider)) {
|
||||
return CLAUDE_PLATFORM_DISPLAY_TYPE;
|
||||
}
|
||||
if (provider.type !== "openai") {
|
||||
return provider.type;
|
||||
}
|
||||
@@ -140,6 +172,15 @@ const buildBedrockSettings = (
|
||||
};
|
||||
};
|
||||
|
||||
const claudePlatformSettingsFromValues = (
|
||||
values: ProviderFormValues,
|
||||
): ClaudePlatformSettingsWire => ({
|
||||
_type: AIProviderSettingsTypeClaudePlatformAWS,
|
||||
_version: AIProviderClaudePlatformAWSSettingsVersion,
|
||||
region: values.claudePlatformRegion.trim(),
|
||||
workspace_id: values.claudePlatformWorkspaceId.trim(),
|
||||
});
|
||||
|
||||
// Bedrock credentials live in `settings`; openai/anthropic keys go in
|
||||
// `api_keys`. `display_name` is omitted when blank so the server stores
|
||||
// NULL and the UI falls back to `name`.
|
||||
@@ -184,10 +225,15 @@ export const providerFormValuesToCreate = (
|
||||
if (values.type === "") {
|
||||
throw new Error("provider type is required");
|
||||
}
|
||||
const settings =
|
||||
values.authMethod === "claude_platform_aws"
|
||||
? claudePlatformSettingsFromValues(values)
|
||||
: undefined;
|
||||
return {
|
||||
type: values.type,
|
||||
...base,
|
||||
...(apiKey ? { api_keys: [apiKey] } : {}),
|
||||
...(settings ? { settings } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -222,7 +268,15 @@ export const providerFormValuesToUpdate = (
|
||||
newApiKey === ""
|
||||
? existingProvider.api_keys.map((k) => ({ id: k.id }))
|
||||
: [{ api_key: newApiKey }];
|
||||
return { ...base, api_keys: apiKeys };
|
||||
const settings =
|
||||
values.authMethod === "claude_platform_aws"
|
||||
? claudePlatformSettingsFromValues(values)
|
||||
: undefined;
|
||||
return {
|
||||
...base,
|
||||
api_keys: apiKeys,
|
||||
...(settings ? { settings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const newAccessKey = sanitizeCredential(values.accessKey);
|
||||
@@ -289,9 +343,33 @@ export const aiProviderToFormValues = (
|
||||
};
|
||||
}
|
||||
|
||||
if (isClaudePlatformProvider(provider)) {
|
||||
const s = provider.settings;
|
||||
return {
|
||||
type: "anthropic",
|
||||
authMethod: "claude_platform_aws",
|
||||
name: provider.name,
|
||||
displayName,
|
||||
icon:
|
||||
provider.icon || (getProviderIcon(CLAUDE_PLATFORM_DISPLAY_TYPE) ?? ""),
|
||||
baseUrl: provider.base_url,
|
||||
claudePlatformRegion: s.region ?? "",
|
||||
claudePlatformWorkspaceId: s.workspace_id ?? "",
|
||||
accessKey: "",
|
||||
accessKeySecret: "",
|
||||
roleArn: "",
|
||||
apiKey: "",
|
||||
enabled: provider.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
const displayType = getProviderDisplayType(provider);
|
||||
// The synthetic Claude Platform display type maps back to the provider type
|
||||
// it is an authentication method on.
|
||||
const type: AIProviderType =
|
||||
displayType === CLAUDE_PLATFORM_DISPLAY_TYPE ? "anthropic" : displayType;
|
||||
return {
|
||||
type: displayType,
|
||||
type,
|
||||
name: provider.name,
|
||||
displayName,
|
||||
icon: provider.icon || (getProviderIcon(displayType) ?? ""),
|
||||
|
||||
@@ -5635,6 +5635,46 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = {
|
||||
updated_at: "2026-05-14T10:00:00Z",
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Platform for AWS is an authentication method on the `anthropic`
|
||||
* provider type, identified by the `claude_platform_aws` settings
|
||||
* discriminator. Authentication is inferred from the provider key pool,
|
||||
* falling back to ambient AWS credentials when the pool is empty.
|
||||
*/
|
||||
export const MockAIProviderClaudePlatformAWS: TypesGen.AIProvider = {
|
||||
id: "5b8c1d92-4e7a-4f38-9b21-6d3c0a7e5f42",
|
||||
type: "anthropic",
|
||||
name: "claude-platform",
|
||||
display_name: "Claude Platform",
|
||||
icon: "",
|
||||
base_url: "https://aws-external-anthropic.us-east-1.api.aws",
|
||||
enabled: true,
|
||||
api_keys: [],
|
||||
settings: {
|
||||
_type: "claude_platform_aws",
|
||||
_version: 1,
|
||||
region: "us-east-1",
|
||||
workspace_id: "wrkspc_123",
|
||||
},
|
||||
created_at: "2026-05-14T10:00:00Z",
|
||||
updated_at: "2026-05-14T10:00:00Z",
|
||||
};
|
||||
|
||||
/** The same provider with a workspace key in its provider key pool. */
|
||||
export const MockAIProviderClaudePlatformAWSAPIKey: TypesGen.AIProvider = {
|
||||
...MockAIProviderClaudePlatformAWS,
|
||||
id: "6c9d2e03-5f8b-4a49-8c32-7e4d1b8f6a53",
|
||||
name: "claude-platform-key",
|
||||
display_name: "Claude Platform (workspace key)",
|
||||
api_keys: [
|
||||
{
|
||||
id: "7d0e3f14-6a9c-4b5a-9d43-8f5e2c9a7b64",
|
||||
masked: "sk-ant-***\u2026***WXYZ",
|
||||
created_at: "2026-05-14T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MockAIProviderCopilot: TypesGen.AIProvider = {
|
||||
id: "b3f0d2c8-6a4e-4d11-8c2f-1e9a7c5b4d31",
|
||||
type: "copilot",
|
||||
@@ -5688,6 +5728,7 @@ export const MockAIProviders: TypesGen.AIProvider[] = [
|
||||
MockAIProviderOpenAI,
|
||||
MockAIProviderAnthropic,
|
||||
MockAIProviderBedrock,
|
||||
MockAIProviderClaudePlatformAWS,
|
||||
MockAIProviderCopilot,
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user