From 7703e7a26e676540eadf18e40d1c9462adc0f560 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:24:08 +0200 Subject: [PATCH] fix: preserve AI provider preset types (#25925) > Mux created this PR on behalf of Mike. AI provider creation previously collapsed OpenAI-compatible presets like Google and generic OpenAI-compatible providers to `openai`, which lost the backend provider discriminator. Preserve selected provider types in the create payload, keep explicit stored types authoritative when reconstructing edit form values, and add frontend plus backend regressions for the supported preset types. --- coderd/ai_providers_test.go | 35 ++++++++++ .../components/providerFormApiMap.test.ts | 69 ++++++++++++++++++- .../components/providerFormApiMap.ts | 39 ++++------- 3 files changed, 117 insertions(+), 26 deletions(-) diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index e4f4f27a06..b9bfd283f1 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -44,6 +44,41 @@ func TestAIProvidersCRUD(t *testing.T) { require.Empty(t, got) }) + t.Run("CreatePreservesPresetProviderTypes", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + tests := []struct { + providerType codersdk.AIProviderType + baseURL string + }{ + {providerType: codersdk.AIProviderTypeAzure, baseURL: "https://example.openai.azure.com/openai/v1"}, + {providerType: codersdk.AIProviderTypeGoogle, baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"}, + {providerType: codersdk.AIProviderTypeOpenAICompat, baseURL: "https://compat.example.com/v1"}, + {providerType: codersdk.AIProviderTypeOpenrouter, baseURL: "https://openrouter.ai/api/v1"}, + {providerType: codersdk.AIProviderTypeVercel, baseURL: "https://ai-gateway.vercel.sh/v1"}, + } + for _, tt := range tests { + t.Run(string(tt.providerType), func(t *testing.T) { + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: tt.providerType, + Name: "type-preserve-" + string(tt.providerType), + Enabled: true, + BaseURL: tt.baseURL, + APIKeys: []string{"sk-test"}, + }) + require.NoError(t, err, tt.providerType) + require.Equal(t, tt.providerType, created.Type) + + got, err := client.AIProvider(ctx, created.ID.String()) + require.NoError(t, err, tt.providerType) + require.Equal(t, tt.providerType, got.Type) + }) + } + }) + t.Run("CreateGetUpdateDelete", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts index 6a955921a8..b02e1413dc 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -140,6 +140,14 @@ describe("isBedrockProvider", () => { expect(isBedrockProvider(MockAIProviderBedrock)).toBe(true); }); + it("recognises a provider with explicit bedrock type", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + type: "bedrock", + }; + expect(isBedrockProvider(provider)).toBe(true); + }); + it("rejects an OpenAI provider", () => { expect(isBedrockProvider(MockAIProviderOpenAI)).toBe(false); }); @@ -205,6 +213,15 @@ describe("getProviderDisplayType", () => { expect(getProviderDisplayType(provider)).toBe(expected); }); + it("preserves an explicit provider type over host detection", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + type: "openai-compat", + base_url: "https://openrouter.ai/api/v1", + }; + expect(getProviderDisplayType(provider)).toBe("openai-compat"); + }); + it("falls back to the wire type for an unrecognized base_url", () => { // Internal proxies and custom OpenAI-compatible endpoints keep the // OpenAI glyph rather than dropping to a question mark. @@ -274,19 +291,30 @@ describe("providerFormValuesToCreate", () => { expect(req.base_url).toBe("https://api.openai.com"); }); + it("preserves the Anthropic provider type", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + type: "anthropic", + baseUrl: "https://api.anthropic.com", + }); + expect(req.type).toBe("anthropic"); + expect(req.base_url).toBe("https://api.anthropic.com"); + expect(req.api_keys).toEqual(["sk-test"]); + }); + it.each([ ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], ["openai-compat", "https://compat.example.com/v1"], ["openrouter", "https://openrouter.ai/api/v1"], ["vercel", "https://ai-gateway.vercel.sh/v1"], - ] as const)("collapses the %s UI type to type=openai on the wire", (type, baseUrl) => { + ] as const)("preserves the %s provider type", (type, baseUrl) => { const req = providerFormValuesToCreate({ ...baseOpenAIFormValues, type, baseUrl, }); - expect(req.type).toBe("openai"); + expect(req.type).toBe(type); expect(req.base_url).toBe(baseUrl); expect(req.api_keys).toEqual(["sk-test"]); }); @@ -526,6 +554,32 @@ describe("aiProviderToFormValues", () => { expect(values.apiKey).toBe(""); }); + it.each([ + ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], + ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], + ["openai-compat", "https://compat.example.com/v1"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["vercel", "https://ai-gateway.vercel.sh/v1"], + ] as const)("seeds %s form values from the provider type", (type, baseUrl) => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + type, + base_url: baseUrl, + }; + const values = aiProviderToFormValues(provider); + expect(values.type).toBe(type); + expect(values.baseUrl).toBe(baseUrl); + }); + + it("uses the Google preset for a generic provider with the Google host", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: "https://generativelanguage.googleapis.com/v1beta/openai/", + }; + const values = aiProviderToFormValues(provider); + expect(values.type).toBe("google"); + }); + it("seeds Bedrock form values from settings", () => { const values = aiProviderToFormValues(MockAIProviderBedrock); expect(values.type).toBe("bedrock"); @@ -533,6 +587,17 @@ describe("aiProviderToFormValues", () => { expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); }); + it("seeds Bedrock form values from an explicit Bedrock provider type", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + type: "bedrock", + }; + const values = aiProviderToFormValues(provider); + expect(values.type).toBe("bedrock"); + expect(values.model).toBe("anthropic.claude-opus-4-7"); + expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); + }); + it("never round-trips Bedrock secrets back to the form", () => { // AccessKey and AccessKeySecret are write-only; the API strips // them from responses, so the form must seed them as empty. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index 2fdb8dd8d6..67eec7e4d9 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -44,12 +44,11 @@ type SettingsWire = AIProviderSettings & _version?: number; }; -// Bedrock providers carry an Anthropic wire type plus a -// `settings._type === "bedrock"` discriminator. `settings` is non-null in -// the generated type but Go serializes zero settings as JSON `null`, so we -// null-check before reading the discriminator. +// Bedrock providers are identified by the settings discriminator. The +// generated type marks settings as non-null, but Go serializes zero settings +// as JSON `null`. export const isBedrockProvider = (provider: AIProvider): boolean => { - if (provider.type !== "anthropic") { + if (provider.type !== "anthropic" && provider.type !== "bedrock") { return false; } const s = provider.settings as SettingsWire | null; @@ -73,9 +72,9 @@ const parseProviderHost = (url: string): string => { } }; -// UI types we recover from a saved provider's base_url because the wire -// `type` collapses them to `openai`. Matches the bare domain or any -// subdomain (Azure ships per-resource subdomains). +// Preset types can be recovered from a saved generic OpenAI provider's +// base_url. Matches the bare domain or any subdomain. Azure assigns +// per-resource subdomains such as my-resource.openai.azure.com. const displayTypeHosts: ReadonlyArray<[string, AIProviderType]> = [ ["openai.azure.com", "azure"], ["generativelanguage.googleapis.com", "google"], @@ -86,20 +85,18 @@ const displayTypeHosts: ReadonlyArray<[string, AIProviderType]> = [ const matchesHost = (host: string, suffix: string): boolean => host === suffix || host.endsWith(`.${suffix}`); -// Wire `type` collapses azure/google/openrouter/vercel to `openai`, so -// we recover the original choice from the saved host. Bedrock comes -// through the settings discriminator. Unknown hosts fall back to wire. +// 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`. export const getProviderDisplayType = ( provider: AIProvider, ): AIProviderType => { if (isBedrockProvider(provider)) { return "bedrock"; } - if (provider.type === "anthropic") { - return "anthropic"; - } - if (provider.type === "copilot") { - return "copilot"; + if (provider.type !== "openai") { + return provider.type; } const host = parseProviderHost(provider.base_url ?? ""); const match = displayTypeHosts.find(([h]) => matchesHost(host, h)); @@ -162,12 +159,8 @@ export const providerFormValuesToCreate = ( if (values.type === "") { throw new Error("provider type is required"); } - // Wire only accepts `openai` and `anthropic`; the other UI types are - // presets that collapse to `openai`. - const wireType: AIProvider["type"] = - values.type === "anthropic" ? "anthropic" : "openai"; return { - type: wireType, + type: values.type, ...base, ...(apiKey ? { api_keys: [apiKey] } : {}), }; @@ -259,10 +252,8 @@ export const aiProviderToFormValues = ( }; } - // Wire `type` is otherwise only `openai` or `anthropic`; the dropdown's - // richer labels apply only on create. return { - type: provider.type === "anthropic" ? "anthropic" : "openai", + type: getProviderDisplayType(provider), name: provider.name, displayName, baseUrl: provider.base_url,