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.
This commit is contained in:
Michael Suchacz
2026-06-03 09:24:08 +02:00
committed by GitHub
parent 8a9580a294
commit 7703e7a26e
3 changed files with 117 additions and 26 deletions
+35
View File
@@ -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)
@@ -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.
@@ -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,