mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user