mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): allow single-label AI provider endpoints (#28122)
Closes #27980. Reduces the `baseUrl` field validation in `ProviderForm.tsx` to only validate non-empty input. The previous validation was not in line with `validateAIProviderBaseURL` in `codersdk/aiproviders.go`. This was blocking users from adding providers with a short-form hostname (e.g. `http://localhost:8080/v1`). > Generated by Coder Agents, reviewed by a human.
This commit is contained in:
+36
-1
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { within } from "storybook/test";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { API } from "#/api/api";
|
||||
import { mockApiError } from "#/testHelpers/entities";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
import { addableProviders } from "../components/addableProviderTypes";
|
||||
import AddProviderPageView from "./AddProviderPageView";
|
||||
@@ -62,3 +64,36 @@ export const AddCopilot: Story = {
|
||||
await canvas.findByText("Add a GitHub Copilot provider");
|
||||
},
|
||||
};
|
||||
|
||||
// Server base_url errors must render inline on the Endpoint input, not only in the top-of-form ErrorAlert.
|
||||
export const WithBaseUrlValidationError: Story = {
|
||||
args: {
|
||||
provider: addableProviders.find((p) => p.value === "openai-compat")!,
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "createAIProvider").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Invalid AI provider request.",
|
||||
validations: [{ field: "base_url", detail: "server base_url error" }],
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(await canvas.findByLabelText(/^name/i), "localai");
|
||||
await userEvent.type(
|
||||
canvas.getByLabelText(/^endpoint\s*\*?$/i),
|
||||
"http://localai:8080/v1",
|
||||
);
|
||||
await userEvent.type(canvas.getByLabelText(/api key/i), "sk-local");
|
||||
const submitButton = canvas.getByRole("button", { name: /add provider/i });
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
await userEvent.click(submitButton);
|
||||
const endpointInput = canvas.getByLabelText(/^endpoint\s*\*?$/i);
|
||||
await waitFor(() =>
|
||||
expect(endpointInput).toHaveAttribute("aria-invalid", "true"),
|
||||
);
|
||||
// Guard the visible diagnostic too, not just the invalid state.
|
||||
expect(await canvas.findAllByText("server base_url error")).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -762,3 +762,52 @@ export const UnsavedChangesPrompt: Story = {
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// Regression coverage for #27980. The create form must accept endpoints with
|
||||
// single-label hosts such as http://localai:8080/v1.
|
||||
export const AddOpenAICompatSingleLabelHost: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
type: "openai-compat",
|
||||
name: "localai",
|
||||
baseUrl: "http://localai:8080/v1",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
await userEvent.type(apiKeyInput, "sk-local");
|
||||
|
||||
const submitButton = canvas.getByRole("button", { name: /add provider/i });
|
||||
|
||||
await waitFor(() => expect(submitButton).toBeEnabled());
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
await waitFor(() =>
|
||||
expect(args.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseUrl: "http://localai:8080/v1" }),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AddOpenAICompatEmptyEndpointBlocked: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
type: "openai-compat",
|
||||
name: "localai",
|
||||
baseUrl: "",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const apiKeyInput = await canvas.findByLabelText(/api key/i);
|
||||
await userEvent.type(apiKeyInput, "sk-local");
|
||||
|
||||
const submitButton = canvas.getByRole("button", { name: /add provider/i });
|
||||
|
||||
await waitFor(() => expect(submitButton).toBeDisabled());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,7 +45,6 @@ export type ProviderFormValues = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const HTTP_SCHEME_REGEX = /^https?:\/\//i;
|
||||
// AWS Bedrock InvokeModel URL, e.g. https://bedrock-runtime.{region}.amazonaws.com
|
||||
const BEDROCK_INVOKE_MODEL_URL_REGEX =
|
||||
/^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i;
|
||||
@@ -166,10 +165,8 @@ const makeOpenAiAnthropicSchema = (editing: boolean) =>
|
||||
name: makeNameSchema(editing),
|
||||
displayName: makeDisplayNameSchema(editing),
|
||||
icon: Yup.string(),
|
||||
baseUrl: Yup.string()
|
||||
.url("Endpoint must be a valid URL")
|
||||
.matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.")
|
||||
.required("Endpoint is required"),
|
||||
// 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"),
|
||||
@@ -201,7 +198,6 @@ const makeBedrockSchema = (editing: boolean) =>
|
||||
.oneOf(["invoke-model", "mantle"] as const)
|
||||
.required(),
|
||||
baseUrl: Yup.string()
|
||||
.url("Endpoint must be a valid URL")
|
||||
.when("protocol", {
|
||||
is: "mantle",
|
||||
then: (schema) =>
|
||||
@@ -257,10 +253,7 @@ const makeCopilotSchema = (editing: boolean) =>
|
||||
name: makeNameSchema(editing),
|
||||
displayName: makeDisplayNameSchema(editing),
|
||||
icon: Yup.string(),
|
||||
baseUrl: Yup.string()
|
||||
.url("Endpoint must be a valid URL")
|
||||
.matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.")
|
||||
.required("Endpoint is required"),
|
||||
baseUrl: Yup.string().required("Endpoint is required"),
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
@@ -503,7 +496,9 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
{iconField}
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("baseUrl")}
|
||||
field={getFieldHelpers("baseUrl", {
|
||||
backendFieldName: "base_url",
|
||||
})}
|
||||
label="Endpoint"
|
||||
description={
|
||||
typeSelectValue === "copilot" ? (
|
||||
@@ -587,7 +582,9 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
</div>
|
||||
<FormField
|
||||
required
|
||||
field={getFieldHelpers("baseUrl")}
|
||||
field={getFieldHelpers("baseUrl", {
|
||||
backendFieldName: "base_url",
|
||||
})}
|
||||
label="Endpoint"
|
||||
description={
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user