From 444fb8aa9be7e8a50141affc6c063ccabf475dd2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 18 Aug 2026 00:18:26 +0100 Subject: [PATCH] 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. --- .../AddProviderPageView.stories.tsx | 37 +++++++++++++- .../components/ProviderForm.stories.tsx | 49 +++++++++++++++++++ .../ProvidersPage/components/ProviderForm.tsx | 21 ++++---- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx index cf28ba7b14..6b361ee3e5 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPageView.stories.tsx @@ -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); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 899d81402f..fb75f891da 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -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()); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index a90d7a10a8..1014b7c8e3 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -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 = ({ {iconField} = ({