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,