mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: move model settings page to ai settings
This commit is contained in:
@@ -23,6 +23,8 @@ export interface FieldSchema {
|
||||
enum?: string[];
|
||||
/** If true, this field should not be rendered in admin UI forms. */
|
||||
hidden?: boolean;
|
||||
visible_when?: string;
|
||||
conflicts_with?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,7 +145,9 @@
|
||||
"description": "Restrict web search to these domains (cannot be used with blocked_domains)",
|
||||
"label": "Web Search: Allowed Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
"input_type": "json",
|
||||
"visible_when": "web_search_enabled",
|
||||
"conflicts_with": ["blocked_domains"]
|
||||
},
|
||||
{
|
||||
"json_name": "blocked_domains",
|
||||
@@ -154,7 +156,9 @@
|
||||
"description": "Block web search on these domains (cannot be used with allowed_domains)",
|
||||
"label": "Web Search: Blocked Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
"input_type": "json",
|
||||
"visible_when": "web_search_enabled",
|
||||
"conflicts_with": ["allowed_domains"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -404,7 +408,8 @@
|
||||
"description": "Amount of search context to use",
|
||||
"required": false,
|
||||
"enum": ["low", "medium", "high"],
|
||||
"input_type": "select"
|
||||
"input_type": "select",
|
||||
"visible_when": "web_search_enabled"
|
||||
},
|
||||
{
|
||||
"json_name": "allowed_domains",
|
||||
@@ -413,7 +418,8 @@
|
||||
"description": "Restrict web search to these domains",
|
||||
"label": "Web Search: Allowed Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
"input_type": "json",
|
||||
"visible_when": "web_search_enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
type CreateChatMessageRequestWithClearablePlanMode,
|
||||
} from "#/api/api";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { type AIProviderType, AIProviderTypes } from "#/api/typesGenerated";
|
||||
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
|
||||
import { formatProviderLabel } from "#/utils/aiProviders";
|
||||
import {
|
||||
projectEditedConversationIntoCache,
|
||||
reconcileEditedMessageInCache,
|
||||
@@ -1797,82 +1795,6 @@ const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const generatedAIProviderName = (provider: string): string => {
|
||||
const suffix =
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `${provider}-${suffix}`;
|
||||
};
|
||||
|
||||
const normalizeAIProviderType = (provider: string): AIProviderType => {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
const aliased =
|
||||
normalized === "openai-compatible" || normalized === "openai_compatible"
|
||||
? "openai-compat"
|
||||
: normalized;
|
||||
const providerType = AIProviderTypes.find(
|
||||
(candidate) => candidate === aliased,
|
||||
);
|
||||
if (!providerType) {
|
||||
throw new Error(`Unsupported AI provider type "${provider}".`);
|
||||
}
|
||||
return providerType;
|
||||
};
|
||||
|
||||
export const createChatProviderConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: (req: TypesGen.CreateChatProviderConfigRequest) => {
|
||||
const providerType = normalizeAIProviderType(req.provider);
|
||||
const apiKey = req.api_key;
|
||||
return API.experimental.createAIProvider({
|
||||
type: providerType,
|
||||
name: generatedAIProviderName(providerType),
|
||||
display_name: req.display_name || formatProviderLabel(providerType),
|
||||
base_url: req.base_url ?? "",
|
||||
enabled: req.enabled ?? true,
|
||||
api_keys: apiKey ? [apiKey] : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
type UpdateChatProviderConfigMutationArgs = {
|
||||
providerConfigId: string;
|
||||
req: TypesGen.UpdateChatProviderConfigRequest;
|
||||
};
|
||||
|
||||
export const updateChatProviderConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: async ({
|
||||
providerConfigId,
|
||||
req,
|
||||
}: UpdateChatProviderConfigMutationArgs) => {
|
||||
const apiKey = req.api_key;
|
||||
return API.experimental.updateAIProvider(providerConfigId, {
|
||||
display_name: req.display_name,
|
||||
base_url: req.base_url,
|
||||
enabled: req.enabled,
|
||||
api_keys:
|
||||
req.api_key === undefined
|
||||
? undefined
|
||||
: apiKey
|
||||
? [{ api_key: apiKey }]
|
||||
: [],
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteChatProviderConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: (providerConfigId: string) =>
|
||||
API.experimental.deleteAIProvider(providerConfigId),
|
||||
onSuccess: async () => {
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
export const createChatModelConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: (req: TypesGen.CreateChatModelConfigRequest) =>
|
||||
API.experimental.createChatModelConfig(req),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function normalizeProvider(provider: string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const canonicalProviderBaseURLs: Record<string, string> = {
|
||||
anthropic: "https://api.anthropic.com",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta",
|
||||
openai: "https://api.openai.com/v1",
|
||||
openrouter: "https://openrouter.ai/api/v1",
|
||||
vercel: "https://ai-gateway.vercel.sh/v1",
|
||||
};
|
||||
|
||||
export function getDefaultProviderBaseURL(provider: string): string {
|
||||
return canonicalProviderBaseURLs[normalizeProvider(provider)] ?? "";
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import {
|
||||
MockChatModelConfig,
|
||||
MockChatModelProvider,
|
||||
MockChatProviderConfig,
|
||||
} from "#/testHelpers/chatModels";
|
||||
import {
|
||||
canManageProviderModels,
|
||||
deriveProviderStates,
|
||||
type ProviderState,
|
||||
resolveModelProviderKey,
|
||||
} from "./providerStates";
|
||||
|
||||
const baseProviderState: ProviderState = {
|
||||
key: "prov-openai",
|
||||
provider: "openai",
|
||||
label: "OpenAI",
|
||||
providerConfig: MockChatProviderConfig,
|
||||
modelConfigs: [],
|
||||
catalogModelCount: 0,
|
||||
hasManagedAPIKey: true,
|
||||
hasCatalogAPIKey: false,
|
||||
hasEffectiveAPIKey: true,
|
||||
allowUserAPIKey: false,
|
||||
isEnvPreset: false,
|
||||
baseURL: "",
|
||||
};
|
||||
|
||||
describe("deriveProviderStates", () => {
|
||||
it("orders provider configs first, then catalog-only, then model-only providers", () => {
|
||||
const providerConfigs = [
|
||||
{
|
||||
...MockChatProviderConfig,
|
||||
id: "prov-anthropic",
|
||||
provider: "anthropic",
|
||||
display_name: "Anthropic",
|
||||
},
|
||||
];
|
||||
const catalog: TypesGen.ChatModelsResponse = {
|
||||
providers: [
|
||||
{ ...MockChatModelProvider, provider: "anthropic" },
|
||||
{ ...MockChatModelProvider, provider: "google" },
|
||||
],
|
||||
};
|
||||
const modelConfigs = [
|
||||
{ ...MockChatModelConfig, id: "m-vercel", provider: "vercel" },
|
||||
];
|
||||
|
||||
const states = deriveProviderStates(modelConfigs, providerConfigs, catalog);
|
||||
|
||||
expect(states.map((s) => s.provider)).toEqual([
|
||||
"anthropic",
|
||||
"google",
|
||||
"vercel",
|
||||
]);
|
||||
expect(states[0].key).toBe("prov-anthropic");
|
||||
expect(states[1].key).toBe("google");
|
||||
expect(states[2].key).toBe("vercel");
|
||||
expect(states[0].hasEffectiveAPIKey).toBe(true);
|
||||
expect(states[1].hasEffectiveAPIKey).toBe(true);
|
||||
expect(states[2].hasEffectiveAPIKey).toBe(false);
|
||||
});
|
||||
|
||||
it("matches model configs to provider configs by ai_provider_id", () => {
|
||||
const providerConfigs = [
|
||||
{ ...MockChatProviderConfig, id: "prov-openai", provider: "openai" },
|
||||
];
|
||||
const modelConfigs = [
|
||||
{
|
||||
...MockChatModelConfig,
|
||||
id: "m1",
|
||||
provider: "openai",
|
||||
ai_provider_id: "prov-openai",
|
||||
},
|
||||
{ ...MockChatModelConfig, id: "m2", provider: "openai" },
|
||||
];
|
||||
|
||||
const states = deriveProviderStates(modelConfigs, providerConfigs, null);
|
||||
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0].key).toBe("prov-openai");
|
||||
expect(states[0].modelConfigs.map((m) => m.id)).toEqual(["m1", "m2"]);
|
||||
});
|
||||
|
||||
it("treats bedrock with central_api_key_enabled as having an effective key", () => {
|
||||
const providerConfigs = [
|
||||
{
|
||||
...MockChatProviderConfig,
|
||||
id: "prov-bedrock",
|
||||
provider: "bedrock",
|
||||
has_api_key: false,
|
||||
central_api_key_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const states = deriveProviderStates([], providerConfigs, null);
|
||||
|
||||
expect(states[0].hasEffectiveAPIKey).toBe(true);
|
||||
});
|
||||
|
||||
it("drops models without ai_provider_id when multiple configs exist for the same provider", () => {
|
||||
const providerConfigs = [
|
||||
{ ...MockChatProviderConfig, id: "prov-a", provider: "openai" },
|
||||
{ ...MockChatProviderConfig, id: "prov-b", provider: "openai" },
|
||||
];
|
||||
const modelConfigs = [
|
||||
{ ...MockChatModelConfig, id: "m1", provider: "openai" },
|
||||
];
|
||||
|
||||
const states = deriveProviderStates(modelConfigs, providerConfigs, null);
|
||||
|
||||
expect(states.flatMap((s) => s.modelConfigs)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects env-preset providers from the catalog when no config exists", () => {
|
||||
const catalog: TypesGen.ChatModelsResponse = {
|
||||
providers: [
|
||||
{ ...MockChatModelProvider, provider: "openai", available: true },
|
||||
],
|
||||
};
|
||||
|
||||
const states = deriveProviderStates([], null, catalog);
|
||||
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0].provider).toBe("openai");
|
||||
expect(states[0].isEnvPreset).toBe(true);
|
||||
expect(states[0].hasCatalogAPIKey).toBe(true);
|
||||
});
|
||||
|
||||
it("flags env-preset providers via the provider config source", () => {
|
||||
const providerConfigs = [
|
||||
{
|
||||
...MockChatProviderConfig,
|
||||
id: "prov-openai",
|
||||
provider: "openai",
|
||||
source: "env_preset" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const states = deriveProviderStates([], providerConfigs, null);
|
||||
|
||||
expect(states[0].isEnvPreset).toBe(true);
|
||||
expect(states[0].providerConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats an unavailable catalog provider as having a key unless the api key is missing", () => {
|
||||
const catalog: TypesGen.ChatModelsResponse = {
|
||||
providers: [
|
||||
{
|
||||
...MockChatModelProvider,
|
||||
provider: "openai",
|
||||
available: false,
|
||||
unavailable_reason: "fetch_failed",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const states = deriveProviderStates([], null, catalog);
|
||||
|
||||
expect(states[0].hasCatalogAPIKey).toBe(true);
|
||||
});
|
||||
|
||||
it("derives label, catalogModelCount, and baseURL from the inputs", () => {
|
||||
const providerConfigs = [
|
||||
{
|
||||
...MockChatProviderConfig,
|
||||
id: "prov-openai",
|
||||
provider: "openai",
|
||||
display_name: "Custom OpenAI",
|
||||
base_url: "https://custom.example.com/v1",
|
||||
},
|
||||
];
|
||||
const catalog: TypesGen.ChatModelsResponse = {
|
||||
providers: [
|
||||
{
|
||||
...MockChatModelProvider,
|
||||
provider: "openai",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-x",
|
||||
provider: "openai",
|
||||
model: "gpt-x",
|
||||
display_name: "GPT-X",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const states = deriveProviderStates([], providerConfigs, catalog);
|
||||
|
||||
expect(states[0].label).toBe("Custom OpenAI");
|
||||
expect(states[0].catalogModelCount).toBe(1);
|
||||
expect(states[0].baseURL).toBe("https://custom.example.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canManageProviderModels", () => {
|
||||
const baseState = baseProviderState;
|
||||
|
||||
it("returns false without a managed provider config", () => {
|
||||
expect(
|
||||
canManageProviderModels({ ...baseState, providerConfig: undefined }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the provider has an effective API key", () => {
|
||||
expect(canManageProviderModels(baseState)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when user-supplied API keys are allowed", () => {
|
||||
expect(
|
||||
canManageProviderModels({
|
||||
...baseState,
|
||||
hasEffectiveAPIKey: false,
|
||||
providerConfig: { ...MockChatProviderConfig, allow_user_api_key: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false with no key and user keys disallowed", () => {
|
||||
expect(
|
||||
canManageProviderModels({
|
||||
...baseState,
|
||||
hasEffectiveAPIKey: false,
|
||||
providerConfig: {
|
||||
...MockChatProviderConfig,
|
||||
allow_user_api_key: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined provider state", () => {
|
||||
expect(canManageProviderModels(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelProviderKey", () => {
|
||||
const states: ProviderState[] = [
|
||||
{ ...baseProviderState, key: "prov-a", provider: "openai" },
|
||||
{ ...baseProviderState, key: "prov-b", provider: "openai" },
|
||||
{ ...baseProviderState, key: "prov-anthropic", provider: "anthropic" },
|
||||
];
|
||||
|
||||
it("prefers ai_provider_id when present", () => {
|
||||
expect(
|
||||
resolveModelProviderKey(
|
||||
{ ...MockChatModelConfig, ai_provider_id: "prov-explicit" },
|
||||
states,
|
||||
),
|
||||
).toBe("prov-explicit");
|
||||
});
|
||||
|
||||
it("falls back to the single matching provider state key", () => {
|
||||
expect(
|
||||
resolveModelProviderKey(
|
||||
{ ...MockChatModelConfig, provider: "anthropic" },
|
||||
states,
|
||||
),
|
||||
).toBe("prov-anthropic");
|
||||
});
|
||||
|
||||
it("returns an empty key when multiple states match the provider", () => {
|
||||
expect(
|
||||
resolveModelProviderKey(
|
||||
{ ...MockChatModelConfig, provider: "openai" },
|
||||
states,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the provider name when no states match", () => {
|
||||
expect(
|
||||
resolveModelProviderKey(
|
||||
{ ...MockChatModelConfig, provider: "google" },
|
||||
states,
|
||||
),
|
||||
).toBe("google");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import {
|
||||
getDefaultProviderBaseURL,
|
||||
normalizeProvider,
|
||||
} from "#/modules/aiModels/helpers";
|
||||
import { formatProviderLabel } from "#/utils/aiProviders";
|
||||
|
||||
export type ProviderState = {
|
||||
key: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
catalogModelCount: number;
|
||||
hasManagedAPIKey: boolean;
|
||||
hasCatalogAPIKey: boolean;
|
||||
hasEffectiveAPIKey: boolean;
|
||||
allowUserAPIKey: boolean;
|
||||
isEnvPreset: boolean;
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
|
||||
|
||||
const envPresetProviders = new Set(["openai", "anthropic"]);
|
||||
|
||||
const readOptionalString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const isDatabaseProviderConfig = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
source: TypesGen.ChatProviderConfigSource | undefined,
|
||||
): providerConfig is TypesGen.ChatProviderConfig => {
|
||||
if (!providerConfig) return false;
|
||||
if (providerConfig.id === "00000000-0000-0000-0000-000000000000") {
|
||||
return false;
|
||||
}
|
||||
return source === undefined || source === "database";
|
||||
};
|
||||
|
||||
const getCatalogProviders = (
|
||||
catalog: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): readonly CatalogProvider[] => {
|
||||
const providers = catalog?.providers;
|
||||
return Array.isArray(providers) ? providers : [];
|
||||
};
|
||||
|
||||
const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean =>
|
||||
provider.available ||
|
||||
(Boolean(provider.unavailable_reason) &&
|
||||
provider.unavailable_reason !== "missing_api_key");
|
||||
|
||||
const getProviderModels = (
|
||||
provider: CatalogProvider | undefined,
|
||||
): readonly CatalogProvider["models"][number][] => {
|
||||
const models = provider?.models;
|
||||
return Array.isArray(models) ? models : [];
|
||||
};
|
||||
|
||||
const getProviderBaseURL = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
): string => {
|
||||
return (
|
||||
readOptionalString(providerConfig?.base_url) ??
|
||||
getDefaultProviderBaseURL(providerConfig?.provider ?? "")
|
||||
);
|
||||
};
|
||||
|
||||
const providerConfigStateKey = (
|
||||
providerConfig: TypesGen.ChatProviderConfig,
|
||||
): string => {
|
||||
const providerID = readOptionalString(providerConfig.id);
|
||||
if (providerID && providerID !== "00000000-0000-0000-0000-000000000000") {
|
||||
return providerID;
|
||||
}
|
||||
return normalizeProvider(providerConfig.provider);
|
||||
};
|
||||
|
||||
type ProviderEntry = {
|
||||
key: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export const deriveProviderStates = (
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[],
|
||||
providerConfigs: TypesGen.ChatProviderConfig[] | null | undefined,
|
||||
catalog: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): readonly ProviderState[] => {
|
||||
const orderedEntries: ProviderEntry[] = [];
|
||||
const seenEntries = new Set<string>();
|
||||
const includeEntry = (keyValue: string, providerValue: string) => {
|
||||
const key = readOptionalString(keyValue);
|
||||
const provider = normalizeProvider(providerValue);
|
||||
if (!key || !provider || seenEntries.has(key)) return;
|
||||
seenEntries.add(key);
|
||||
orderedEntries.push({ key, provider });
|
||||
};
|
||||
|
||||
const catalogProviders = getCatalogProviders(catalog);
|
||||
const catalogProvidersByProvider = new Map<string, CatalogProvider>();
|
||||
for (const cp of catalogProviders) {
|
||||
const provider = normalizeProvider(cp.provider);
|
||||
if (!provider) continue;
|
||||
catalogProvidersByProvider.set(provider, cp);
|
||||
}
|
||||
|
||||
const providerConfigKeysByProvider = new Map<string, string[]>();
|
||||
const providerTypesWithConfigs = new Set<string>();
|
||||
for (const pc of providerConfigs ?? []) {
|
||||
const provider = normalizeProvider(pc.provider);
|
||||
if (!provider) continue;
|
||||
const key = providerConfigStateKey(pc);
|
||||
providerTypesWithConfigs.add(provider);
|
||||
providerConfigKeysByProvider.set(provider, [
|
||||
...(providerConfigKeysByProvider.get(provider) ?? []),
|
||||
key,
|
||||
]);
|
||||
includeEntry(key, provider);
|
||||
}
|
||||
const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => {
|
||||
const aiProviderID = readOptionalString(modelConfig.ai_provider_id);
|
||||
if (aiProviderID) {
|
||||
return aiProviderID;
|
||||
}
|
||||
const provider = normalizeProvider(modelConfig.provider);
|
||||
const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? [];
|
||||
if (providerConfigKeys.length === 1) {
|
||||
return providerConfigKeys[0];
|
||||
}
|
||||
return providerConfigKeys.length === 0 ? provider : "";
|
||||
};
|
||||
|
||||
for (const cp of catalogProviders) {
|
||||
const provider = normalizeProvider(cp.provider);
|
||||
if (!provider || providerTypesWithConfigs.has(provider)) continue;
|
||||
includeEntry(provider, provider);
|
||||
}
|
||||
for (const mc of modelConfigs) {
|
||||
includeEntry(modelStateKey(mc), mc.provider);
|
||||
}
|
||||
|
||||
const providerConfigsByKey = new Map<string, TypesGen.ChatProviderConfig>();
|
||||
for (const pc of providerConfigs ?? []) {
|
||||
const key = providerConfigStateKey(pc);
|
||||
if (!key) continue;
|
||||
providerConfigsByKey.set(key, pc);
|
||||
}
|
||||
|
||||
const modelConfigsByKey = new Map<string, TypesGen.ChatModelConfig[]>();
|
||||
for (const mc of modelConfigs) {
|
||||
const key = modelStateKey(mc);
|
||||
if (!key) continue;
|
||||
const existing = modelConfigsByKey.get(key);
|
||||
if (existing) {
|
||||
existing.push(mc);
|
||||
} else {
|
||||
modelConfigsByKey.set(key, [mc]);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedEntries.map(({ key, provider }) => {
|
||||
const providerConfigEntry = providerConfigsByKey.get(key);
|
||||
const providerConfigSource = providerConfigEntry?.source;
|
||||
const providerConfig = isDatabaseProviderConfig(
|
||||
providerConfigEntry,
|
||||
providerConfigSource,
|
||||
)
|
||||
? providerConfigEntry
|
||||
: undefined;
|
||||
const catalogProvider = catalogProvidersByProvider.get(provider);
|
||||
const hasManagedAPIKey = providerConfig?.has_api_key ?? false;
|
||||
const hasProviderEntryAPIKey = providerConfigEntry?.has_api_key ?? false;
|
||||
const hasCatalogAPIKey = catalogProvider
|
||||
? providerHasCatalogAPIKey(catalogProvider)
|
||||
: false;
|
||||
const label =
|
||||
readOptionalString(providerConfigEntry?.display_name) ??
|
||||
formatProviderLabel(provider);
|
||||
const hasBedrockAmbientCredentials =
|
||||
provider === "bedrock" &&
|
||||
providerConfig?.central_api_key_enabled === true;
|
||||
const modelConfigsForProvider = modelConfigsByKey.get(key) ?? [];
|
||||
const isCatalogEnvPreset =
|
||||
!providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey;
|
||||
const isEnvPreset =
|
||||
providerConfigSource === "env_preset" || isCatalogEnvPreset;
|
||||
|
||||
return {
|
||||
key,
|
||||
provider,
|
||||
label,
|
||||
providerConfig,
|
||||
modelConfigs: modelConfigsForProvider,
|
||||
catalogModelCount: getProviderModels(catalogProvider).length,
|
||||
hasManagedAPIKey,
|
||||
hasCatalogAPIKey,
|
||||
hasEffectiveAPIKey: providerConfigEntry
|
||||
? hasProviderEntryAPIKey || hasBedrockAmbientCredentials
|
||||
: hasManagedAPIKey || hasCatalogAPIKey,
|
||||
allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true,
|
||||
isEnvPreset,
|
||||
baseURL: getProviderBaseURL(providerConfigEntry),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const canManageProviderModels = (
|
||||
providerState: ProviderState | undefined,
|
||||
): boolean => {
|
||||
return Boolean(
|
||||
providerState?.providerConfig &&
|
||||
(providerState.hasEffectiveAPIKey ||
|
||||
providerState.providerConfig.allow_user_api_key),
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveModelProviderKey = (
|
||||
modelConfig: TypesGen.ChatModelConfig,
|
||||
providerStates: readonly ProviderState[],
|
||||
): string => {
|
||||
const providerID = readOptionalString(modelConfig.ai_provider_id);
|
||||
if (providerID) {
|
||||
return providerID;
|
||||
}
|
||||
const provider = normalizeProvider(modelConfig.provider);
|
||||
const matches = providerStates.filter((s) => s.provider === provider);
|
||||
if (matches.length === 1) {
|
||||
return matches[0].key;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return "";
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
@@ -32,6 +32,9 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
|
||||
AI Gateway Keys
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/agents/settings/agents">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type FC, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
createChatModelConfig,
|
||||
} from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import {
|
||||
canManageProviderModels,
|
||||
deriveProviderStates,
|
||||
} from "#/modules/aiModels/providerStates";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import AddModelPageView from "./AddModelPageView";
|
||||
|
||||
const AddModelPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const providerKey = searchParams.get("provider") ?? "";
|
||||
const duplicateId = searchParams.get("duplicate");
|
||||
|
||||
const providerConfigsQuery = useQuery(chatProviderConfigs());
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
const createMutation = useMutation(createChatModelConfig(queryClient));
|
||||
|
||||
const providerStates = useMemo(
|
||||
() =>
|
||||
deriveProviderStates(
|
||||
modelConfigsQuery.data ?? [],
|
||||
providerConfigsQuery.data,
|
||||
modelCatalogQuery.data,
|
||||
),
|
||||
[modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
providerConfigsQuery.isLoading ||
|
||||
modelConfigsQuery.isLoading ||
|
||||
modelCatalogQuery.isLoading;
|
||||
|
||||
const selectedProviderState = providerKey
|
||||
? (providerStates.find((ps) => ps.key === providerKey) ?? null)
|
||||
: (providerStates.find(canManageProviderModels) ?? null);
|
||||
const duplicateSourceModel = duplicateId
|
||||
? modelConfigsQuery.data?.find((m) => m.id === duplicateId)
|
||||
: undefined;
|
||||
const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<title>{pageTitle("Add model", "AI Settings")}</title>
|
||||
|
||||
<AddModelPageView
|
||||
isLoading={isLoading}
|
||||
providerStates={providerStates}
|
||||
selectedProviderState={selectedProviderState}
|
||||
duplicateSourceModel={duplicateSourceModel}
|
||||
currentDefaultModel={currentDefaultModel}
|
||||
isSaving={createMutation.isPending}
|
||||
onProviderChange={(key) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("provider", key);
|
||||
void navigate(`/ai/settings/models/add?${next.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
onCreateModel={async (req) => {
|
||||
try {
|
||||
const created = await createMutation.mutateAsync(req);
|
||||
toast.success(
|
||||
`Model "${created.display_name || created.model}" added.`,
|
||||
);
|
||||
await navigate(`/ai/settings/models/${created.id}`);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Failed to add model."));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModelPage;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
import {
|
||||
MockAnthropicProviderState,
|
||||
MockOpenAIProviderState,
|
||||
} from "../testFixtures";
|
||||
import AddModelPageView from "./AddModelPageView";
|
||||
|
||||
const meta: Meta<typeof AddModelPageView> = {
|
||||
title: "pages/AISettingsPage/ModelsPage/AddModelPageView",
|
||||
component: AddModelPageView,
|
||||
decorators: [withToaster],
|
||||
args: {
|
||||
isLoading: false,
|
||||
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
|
||||
selectedProviderState: MockOpenAIProviderState,
|
||||
isSaving: false,
|
||||
onProviderChange: fn(),
|
||||
onCreateModel: fn(async () => undefined),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AddModelPageView>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: /add an? OpenAI model/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WebSearchDependentFields: Story = {
|
||||
args: { selectedProviderState: MockAnthropicProviderState },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: /provider configuration/i }),
|
||||
);
|
||||
expect(canvas.queryByLabelText(/allowed domains/i)).not.toBeInTheDocument();
|
||||
const webSearchField = canvas.getByRole("radiogroup", {
|
||||
name: /web search enabled/i,
|
||||
});
|
||||
await userEvent.click(
|
||||
within(webSearchField).getByRole("radio", { name: /on/i }),
|
||||
);
|
||||
const allowed = await canvas.findByLabelText(/allowed domains/i);
|
||||
const blocked = await canvas.findByLabelText(/blocked domains/i);
|
||||
await userEvent.type(allowed, "example.com");
|
||||
expect(blocked).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const ProviderNotFound: Story = {
|
||||
args: { selectedProviderState: null },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Provider not found")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { isLoading: true },
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import { ModelForm } from "../components/ModelForm";
|
||||
|
||||
interface AddModelPageViewProps {
|
||||
isLoading: boolean;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProviderState: ProviderState | null;
|
||||
duplicateSourceModel?: TypesGen.ChatModelConfig;
|
||||
currentDefaultModel?: TypesGen.ChatModelConfig;
|
||||
isSaving: boolean;
|
||||
onProviderChange: (providerKey: string) => void;
|
||||
onCreateModel: (
|
||||
req: TypesGen.CreateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const AddModelPageView: FC<AddModelPageViewProps> = ({
|
||||
isLoading,
|
||||
providerStates,
|
||||
selectedProviderState,
|
||||
duplicateSourceModel,
|
||||
currentDefaultModel,
|
||||
isSaving,
|
||||
onProviderChange,
|
||||
onCreateModel,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <Loader fullscreen />;
|
||||
}
|
||||
|
||||
if (!selectedProviderState) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Link to="/ai/settings/models" className="-ml-3">
|
||||
<Button variant="subtle">
|
||||
<ArrowLeftIcon />
|
||||
<span>Back to models</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Provider not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The provider you are trying to add a model for is not available.
|
||||
Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelForm
|
||||
duplicateSourceModel={duplicateSourceModel}
|
||||
currentDefaultModel={currentDefaultModel}
|
||||
providerStates={providerStates}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onProviderChange={onProviderChange}
|
||||
isSaving={isSaving}
|
||||
isDeleting={false}
|
||||
onCreateModel={onCreateModel}
|
||||
onUpdateModel={async () => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModelPageView;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
} from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { deriveProviderStates } from "#/modules/aiModels/providerStates";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import ModelsPageView from "./ModelsPageView";
|
||||
|
||||
const ModelsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const providerConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
const models = (modelConfigsQuery.data ?? []).slice().sort((a, b) => {
|
||||
const cmp = a.provider.localeCompare(b.provider);
|
||||
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
|
||||
});
|
||||
const providerStates = deriveProviderStates(
|
||||
models,
|
||||
providerConfigsQuery.data,
|
||||
modelCatalogQuery.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<title>{pageTitle("Models", "AI Settings")}</title>
|
||||
|
||||
<ModelsPageView
|
||||
isLoading={
|
||||
providerConfigsQuery.isLoading ||
|
||||
modelConfigsQuery.isLoading ||
|
||||
modelCatalogQuery.isLoading
|
||||
}
|
||||
error={
|
||||
providerConfigsQuery.error ??
|
||||
modelConfigsQuery.error ??
|
||||
modelCatalogQuery.error
|
||||
}
|
||||
models={models}
|
||||
providerStates={providerStates}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPage;
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import type { ChatModelConfig } from "#/api/typesGenerated";
|
||||
import ModelsPageView from "./ModelsPageView";
|
||||
import {
|
||||
MockAnthropicProviderState,
|
||||
MockOpenAIProviderState,
|
||||
mockClaude,
|
||||
mockDisabledModel,
|
||||
mockGPT5,
|
||||
} from "./testFixtures";
|
||||
|
||||
const meta: Meta<typeof ModelsPageView> = {
|
||||
title: "pages/AISettingsPage/ModelsPage/ModelsPageView",
|
||||
component: ModelsPageView,
|
||||
args: {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
models: [mockGPT5, mockClaude, mockDisabledModel],
|
||||
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/ai/settings/models" },
|
||||
routing: [
|
||||
{ path: "/ai/settings/models", useStoryElement: true },
|
||||
{ path: "/ai/settings/models/add", useStoryElement: true },
|
||||
{ path: "/ai/settings/models/:modelId", useStoryElement: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ModelsPageView>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /add model/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText("GPT-5")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
await expect(canvas.getAllByText("OpenAI").length).toBeGreaterThan(0);
|
||||
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
|
||||
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
|
||||
await expect(canvas.getByText("Default")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
models: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
models: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("No models configured")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadError: Story = {
|
||||
args: {
|
||||
error: new Error("Failed to load models"),
|
||||
models: [],
|
||||
},
|
||||
};
|
||||
|
||||
const manyModels: ChatModelConfig[] = Array.from({ length: 23 }, (_, i) => ({
|
||||
...mockClaude,
|
||||
id: `model-${i}`,
|
||||
model: `model-${i}`,
|
||||
display_name: `Model ${i}`,
|
||||
is_default: false,
|
||||
}));
|
||||
|
||||
export const Paginated: Story = {
|
||||
args: {
|
||||
models: manyModels,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Model 0")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Model 10")).not.toBeInTheDocument();
|
||||
await expect(canvas.getByText(/Showing/)).toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button", { name: /next page/i }));
|
||||
await expect(canvas.getByText("Model 10")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Model 0")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ChevronDownIcon, PlusIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { ChatModelConfig } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import {
|
||||
canManageProviderModels,
|
||||
type ProviderState,
|
||||
} from "#/modules/aiModels/providerStates";
|
||||
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
|
||||
import { paginateItems } from "#/utils/paginateItems";
|
||||
import { ModelRow } from "./components/ModelRow";
|
||||
|
||||
const MODELS_PAGE_SIZE = 10;
|
||||
|
||||
const AddModelDropdown: FC<{ providerStates: readonly ProviderState[] }> = ({
|
||||
providerStates,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const manageableProviderStates = providerStates.filter(
|
||||
canManageProviderModels,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<PlusIcon />
|
||||
<span>Add model</span>
|
||||
<ChevronDownIcon className="ml-1 size-icon-xs" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-content-secondary">
|
||||
Select a provider
|
||||
</div>
|
||||
{manageableProviderStates.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No providers available</DropdownMenuItem>
|
||||
) : (
|
||||
manageableProviderStates.map((providerState) => (
|
||||
<DropdownMenuItem
|
||||
key={providerState.key}
|
||||
onSelect={() =>
|
||||
void navigate(
|
||||
`/ai/settings/models/add?provider=${encodeURIComponent(
|
||||
providerState.key,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProviderIcon provider={providerState.provider} />
|
||||
<span>{providerState.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModelsPageViewProps {
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
models: readonly ChatModelConfig[];
|
||||
providerStates: readonly ProviderState[];
|
||||
}
|
||||
|
||||
const ModelsPageView: FC<ModelsPageViewProps> = ({
|
||||
isLoading,
|
||||
error,
|
||||
models,
|
||||
providerStates,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } =
|
||||
paginateItems(models, MODELS_PAGE_SIZE, page);
|
||||
|
||||
const providerLabelByModelId = new Map<string, string>();
|
||||
for (const providerState of providerStates) {
|
||||
for (const providerModel of providerState.modelConfigs) {
|
||||
providerLabelByModelId.set(providerModel.id, providerState.label);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
actions={<AddModelDropdown providerStates={providerStates} />}
|
||||
>
|
||||
<SettingsHeaderTitle>Models</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Choose which models from your configured providers are available for
|
||||
users to select. You can set a default and adjust context limits.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
{Boolean(error) && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
<Table className="table-fixed" aria-label="Models">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-1/3">Name</TableHead>
|
||||
<TableHead className="w-1/4">Provider</TableHead>
|
||||
<TableHead className="w-1/4">Context limit</TableHead>
|
||||
<TableHead className="w-40">Status</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Open model</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : models.length === 0 ? (
|
||||
<TableEmpty
|
||||
message="No models configured"
|
||||
description="Configured models will appear here."
|
||||
/>
|
||||
) : (
|
||||
pagedItems.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
providerLabel={providerLabelByModelId.get(model.id) ?? ""}
|
||||
onClick={() => void navigate(`/ai/settings/models/${model.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{models.length > 0 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="flex-1">
|
||||
<PaginationWidgetBase
|
||||
currentPage={clampedPage}
|
||||
pageSize={MODELS_PAGE_SIZE}
|
||||
totalRecords={models.length}
|
||||
onPageChange={setPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-content-secondary">
|
||||
Showing <strong className="font-medium">{pagedItems.length}</strong>{" "}
|
||||
of <strong className="font-medium">{models.length}</strong> models
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPageView;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Navigate, useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
deleteChatModelConfig,
|
||||
updateChatModelConfig,
|
||||
} from "#/api/queries/chats";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { deriveProviderStates } from "#/modules/aiModels/providerStates";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import UpdateModelPageView from "./UpdateModelPageView";
|
||||
|
||||
const UpdateModelPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { modelId } = useParams<{ modelId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const providerConfigsQuery = useQuery(chatProviderConfigs());
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
const updateMutation = useMutation(updateChatModelConfig(queryClient));
|
||||
const deleteMutation = useMutation(deleteChatModelConfig(queryClient));
|
||||
|
||||
const providerStates = useMemo(
|
||||
() =>
|
||||
deriveProviderStates(
|
||||
modelConfigsQuery.data ?? [],
|
||||
providerConfigsQuery.data,
|
||||
modelCatalogQuery.data,
|
||||
),
|
||||
[modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
providerConfigsQuery.isLoading ||
|
||||
modelConfigsQuery.isLoading ||
|
||||
modelCatalogQuery.isLoading;
|
||||
|
||||
const model = modelConfigsQuery.data?.find((m) => m.id === modelId);
|
||||
const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default);
|
||||
const [providerKeyOverride, setProviderKeyOverride] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const selectedProviderState =
|
||||
(providerKeyOverride
|
||||
? providerStates.find((ps) => ps.key === providerKeyOverride)
|
||||
: undefined) ??
|
||||
providerStates.find((ps) =>
|
||||
ps.modelConfigs.some((m) => m.id === modelId),
|
||||
) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
{!modelId ? (
|
||||
<Navigate to="/ai/settings/models" replace />
|
||||
) : isLoading ? (
|
||||
<>
|
||||
<title>{pageTitle("Loading...", "AI Settings")}</title>
|
||||
<Loader fullscreen />
|
||||
</>
|
||||
) : !model ? (
|
||||
<Navigate to="/ai/settings/models" replace />
|
||||
) : (
|
||||
<UpdateModelPageView
|
||||
model={model}
|
||||
currentDefaultModel={currentDefaultModel}
|
||||
providerStates={providerStates}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onProviderChange={setProviderKeyOverride}
|
||||
isSaving={updateMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onUpdateModel={async (id, req) => {
|
||||
try {
|
||||
const updated = await updateMutation.mutateAsync({
|
||||
modelConfigId: id,
|
||||
req,
|
||||
});
|
||||
toast.success(
|
||||
`Model "${updated.display_name || updated.model}" updated.`,
|
||||
);
|
||||
await navigate("/ai/settings/models");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Failed to update model."));
|
||||
}
|
||||
}}
|
||||
onDeleteModel={async (id) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
toast.success(
|
||||
`Model "${model.display_name || model.model}" deleted.`,
|
||||
);
|
||||
await navigate("/ai/settings/models", { replace: true });
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Failed to delete model."));
|
||||
}
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
if (!selectedProviderState) return;
|
||||
void navigate(
|
||||
`/ai/settings/models/add?provider=${encodeURIComponent(
|
||||
selectedProviderState.key,
|
||||
)}&duplicate=${encodeURIComponent(model.id)}`,
|
||||
);
|
||||
}}
|
||||
onToggleEnabled={(enabled) => {
|
||||
updateMutation.mutate(
|
||||
{ modelConfigId: model.id, req: { enabled } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Model "${model.display_name || model.model}" ${
|
||||
enabled ? "enabled" : "disabled"
|
||||
}.`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to update model."),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateModelPage;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
import {
|
||||
MockAnthropicProviderState,
|
||||
MockOpenAIProviderState,
|
||||
mockGPT5,
|
||||
} from "../testFixtures";
|
||||
import UpdateModelPageView from "./UpdateModelPageView";
|
||||
|
||||
const meta: Meta<typeof UpdateModelPageView> = {
|
||||
title: "pages/AISettingsPage/ModelsPage/UpdateModelPageView",
|
||||
component: UpdateModelPageView,
|
||||
decorators: [withToaster],
|
||||
args: {
|
||||
model: mockGPT5,
|
||||
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
|
||||
selectedProviderState: MockOpenAIProviderState,
|
||||
onProviderChange: fn(),
|
||||
isSaving: false,
|
||||
isDeleting: false,
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
onDuplicate: fn(),
|
||||
onToggleEnabled: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UpdateModelPageView>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /^update model$/i }),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { ModelForm } from "../components/ModelForm";
|
||||
|
||||
interface UpdateModelPageViewProps {
|
||||
model: TypesGen.ChatModelConfig;
|
||||
currentDefaultModel?: TypesGen.ChatModelConfig;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProviderState: ProviderState | null;
|
||||
onProviderChange: (providerKey: string) => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
onUpdateModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteModel: (modelConfigId: string) => Promise<void>;
|
||||
onDuplicate: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const UpdateModelPageView: FC<UpdateModelPageViewProps> = ({
|
||||
model,
|
||||
currentDefaultModel,
|
||||
providerStates,
|
||||
selectedProviderState,
|
||||
onProviderChange,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
onUpdateModel,
|
||||
onDeleteModel,
|
||||
onDuplicate,
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<title>
|
||||
{pageTitle(model.display_name || model.model, "AI Settings")}
|
||||
</title>
|
||||
<ModelForm
|
||||
key={model.id}
|
||||
editingModel={model}
|
||||
currentDefaultModel={currentDefaultModel}
|
||||
providerStates={providerStates}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onProviderChange={onProviderChange}
|
||||
isSaving={isSaving}
|
||||
isDeleting={isDeleting}
|
||||
onCreateModel={async () => {}}
|
||||
onUpdateModel={onUpdateModel}
|
||||
onDeleteModel={onDeleteModel}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateModelPageView;
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, screen, userEvent, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
import {
|
||||
MockAnthropicProviderState,
|
||||
MockOpenAIProviderState,
|
||||
mockGPT5,
|
||||
} from "../testFixtures";
|
||||
import { ModelForm } from "./ModelForm";
|
||||
|
||||
const meta: Meta<typeof ModelForm> = {
|
||||
title: "pages/AISettingsPage/ModelsPage/ModelForm",
|
||||
component: ModelForm,
|
||||
decorators: [withToaster],
|
||||
args: {
|
||||
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
|
||||
selectedProviderState: MockOpenAIProviderState,
|
||||
onProviderChange: fn(),
|
||||
isSaving: false,
|
||||
isDeleting: false,
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/ai/settings/models/add" },
|
||||
routing: [
|
||||
{ path: "/ai/settings/models/add", useStoryElement: true },
|
||||
{ path: "/ai/settings/models", element: <div>Models</div> },
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ModelForm>;
|
||||
|
||||
export const Add: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: /add an? OpenAI model/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("checkbox", { name: /set as default model/i }),
|
||||
).toBeInTheDocument();
|
||||
const submit = canvas.getByRole("button", { name: /add model/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const AddValidSubmit: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const modelInput = canvas.getByLabelText(/model identifier/i);
|
||||
await userEvent.type(modelInput, "gpt-5");
|
||||
const contextLimit = canvas.getByLabelText(/context limit/i);
|
||||
await userEvent.type(contextLimit, "200000");
|
||||
const submit = canvas.getByRole("button", { name: /add model/i });
|
||||
await expect(submit).toBeEnabled();
|
||||
await userEvent.click(submit);
|
||||
await expect(args.onCreateModel).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const AddSetAsDefault: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
|
||||
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: /set as default model/i }),
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
|
||||
await expect(args.onCreateModel).toHaveBeenCalledTimes(1);
|
||||
await expect(args.onCreateModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_default: true }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LeaveWithUnsavedChanges: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("link", { name: /back to models/i }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: /unsaved changes/i,
|
||||
});
|
||||
await expect(dialog).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ReplaceDefaultWarning: Story = {
|
||||
args: {
|
||||
currentDefaultModel: { ...mockGPT5, is_default: true },
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.type(
|
||||
canvas.getByLabelText(/model identifier/i),
|
||||
"gpt-5-mini",
|
||||
);
|
||||
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: /set as default model/i }),
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
|
||||
await expect(
|
||||
await screen.findByText(/replace default model/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: /^confirm$/i }));
|
||||
await expect(args.onCreateModel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_default: true }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Edit: Story = {
|
||||
args: {
|
||||
editingModel: mockGPT5,
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
onDuplicate: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /^update model$/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: /^cancel$/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.getByRole("checkbox", { name: /set as default model/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
|
||||
await expect(
|
||||
canvas.getByRole("combobox", { name: /provider/i }),
|
||||
).toBeEnabled();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /model actions/i }),
|
||||
);
|
||||
await expect(
|
||||
screen.getByRole("menuitem", { name: /duplicate model/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.getByRole("menuitem", { name: /delete/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EditDefaultBadge: Story = {
|
||||
args: {
|
||||
editingModel: { ...mockGPT5, is_default: true, enabled: true },
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/^default$/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EditDisabledBadge: Story = {
|
||||
args: {
|
||||
editingModel: { ...mockGPT5, is_default: false, enabled: false },
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/^disabled$/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EditSaveSubmits: Story = {
|
||||
args: {
|
||||
editingModel: mockGPT5,
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const save = canvas.getByRole("button", { name: /^update model$/i });
|
||||
await userEvent.click(save);
|
||||
await expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const CostTrackingExpanded: Story = {
|
||||
args: {
|
||||
editingModel: mockGPT5,
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = canvas.getByRole("button", { name: /cost tracking/i });
|
||||
await userEvent.click(toggle);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
|
||||
import {
|
||||
canManageProviderModels,
|
||||
type ProviderState,
|
||||
} from "#/modules/aiModels/providerStates";
|
||||
import {
|
||||
buildInitialModelFormValues,
|
||||
buildModelConfigFromForm,
|
||||
type ModelFormValues,
|
||||
parsePositiveInteger,
|
||||
parseThresholdInteger,
|
||||
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import { ModelFormDialogs } from "./ModelFormDialogs";
|
||||
import { ModelFormFields } from "./ModelFormFields";
|
||||
import { ModelFormBackLink, ModelFormHeader } from "./ModelFormHeader";
|
||||
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
|
||||
|
||||
const indefiniteArticle = (word: string): string =>
|
||||
/^[aeiou]/i.test(word) ? "an" : "a";
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
model: Yup.string().trim().required("Model ID is required."),
|
||||
displayName: Yup.string(),
|
||||
enabled: Yup.boolean(),
|
||||
contextLimit: Yup.string()
|
||||
.required("Context limit is required.")
|
||||
.test(
|
||||
"positive-integer",
|
||||
"Context limit must be a positive integer.",
|
||||
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
|
||||
),
|
||||
compressionThreshold: Yup.string().test(
|
||||
"threshold-range",
|
||||
"Compression threshold must be a number between 0 and 100.",
|
||||
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
|
||||
),
|
||||
isDefault: Yup.boolean(),
|
||||
});
|
||||
|
||||
interface ModelFormProps {
|
||||
editingModel?: TypesGen.ChatModelConfig;
|
||||
duplicateSourceModel?: TypesGen.ChatModelConfig;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProviderState: ProviderState | null;
|
||||
onProviderChange: (providerKey: string) => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
onCreateModel: (
|
||||
req: TypesGen.CreateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onUpdateModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteModel?: (modelConfigId: string) => Promise<void>;
|
||||
currentDefaultModel?: TypesGen.ChatModelConfig;
|
||||
onSetDefault?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onToggleEnabled?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const ModelForm: FC<ModelFormProps> = ({
|
||||
editingModel,
|
||||
duplicateSourceModel,
|
||||
providerStates,
|
||||
selectedProviderState,
|
||||
onProviderChange,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
onCreateModel,
|
||||
onUpdateModel,
|
||||
onDeleteModel,
|
||||
onDuplicate,
|
||||
currentDefaultModel,
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const initialModel = editingModel ?? duplicateSourceModel;
|
||||
const isEditing = Boolean(editingModel);
|
||||
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
|
||||
const initialValues = {
|
||||
...buildInitialModelFormValues(initialModel),
|
||||
...(isDuplicating && { isDefault: false }),
|
||||
};
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showPricing, setShowPricing] = useState(false);
|
||||
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [confirmingReplaceDefault, setConfirmingReplaceDefault] =
|
||||
useState(false);
|
||||
const replaceDefaultConfirmedRef = useRef(false);
|
||||
|
||||
const canAddModelForSelectedProvider = canManageProviderModels(
|
||||
selectedProviderState ?? undefined,
|
||||
);
|
||||
const mode: "add" | "edit" | "duplicate" = isEditing
|
||||
? "edit"
|
||||
: isDuplicating
|
||||
? "duplicate"
|
||||
: "add";
|
||||
|
||||
const selectedProviderType =
|
||||
selectedProviderState?.provider ?? selectedProviderState?.key ?? "";
|
||||
const selectedProviderKey = selectedProviderState?.key ?? "";
|
||||
|
||||
const form = useFormik<ModelFormValues>({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
validateOnMount: true,
|
||||
validateOnBlur: false,
|
||||
onSubmit: async (values) => {
|
||||
if (isSaving) return;
|
||||
|
||||
const replacingDefault =
|
||||
values.isDefault &&
|
||||
currentDefaultModel != null &&
|
||||
currentDefaultModel.id !== editingModel?.id;
|
||||
if (replacingDefault && !replaceDefaultConfirmedRef.current) {
|
||||
setConfirmingReplaceDefault(true);
|
||||
return;
|
||||
}
|
||||
replaceDefaultConfirmedRef.current = false;
|
||||
|
||||
const trimmedModel = values.model.trim();
|
||||
if (!trimmedModel) return;
|
||||
|
||||
const parsedContextLimit = parsePositiveInteger(values.contextLimit);
|
||||
const parsedCompressionThreshold = parseThresholdInteger(
|
||||
values.compressionThreshold,
|
||||
);
|
||||
|
||||
const buildResult = buildModelConfigFromForm(
|
||||
selectedProviderType,
|
||||
values.config,
|
||||
);
|
||||
if (Object.keys(buildResult.fieldErrors).length > 0) return;
|
||||
|
||||
const trimmedDisplayName = values.displayName.trim();
|
||||
const builtModelConfig = buildResult.modelConfig;
|
||||
|
||||
const selectedProviderConfigID =
|
||||
selectedProviderState?.providerConfig?.id;
|
||||
const editingProviderConfigID =
|
||||
editingModel?.ai_provider_id?.trim() ?? "";
|
||||
|
||||
if (isEditing && editingModel) {
|
||||
const req: TypesGen.UpdateChatModelConfigRequest = {
|
||||
...(selectedProviderConfigID &&
|
||||
selectedProviderConfigID !== editingProviderConfigID && {
|
||||
provider: selectedProviderState.provider,
|
||||
ai_provider_id: selectedProviderConfigID,
|
||||
}),
|
||||
...(trimmedModel !== editingModel.model && {
|
||||
model: trimmedModel,
|
||||
}),
|
||||
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(parsedContextLimit !== null &&
|
||||
parsedContextLimit !== editingModel.context_limit && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null &&
|
||||
parsedCompressionThreshold !==
|
||||
editingModel.compression_threshold && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(values.isDefault !== editingModel.is_default && {
|
||||
is_default: values.isDefault,
|
||||
}),
|
||||
model_config: builtModelConfig,
|
||||
};
|
||||
|
||||
await onUpdateModel(editingModel.id, req);
|
||||
} else {
|
||||
if (!selectedProviderState?.providerConfig) return;
|
||||
|
||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: selectedProviderState.provider,
|
||||
ai_provider_id: selectedProviderState.providerConfig.id,
|
||||
model: trimmedModel,
|
||||
enabled: values.enabled,
|
||||
is_default: values.isDefault,
|
||||
...(parsedContextLimit !== null && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(builtModelConfig && {
|
||||
model_config: builtModelConfig,
|
||||
}),
|
||||
};
|
||||
|
||||
await onCreateModel(req);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
|
||||
const unsavedChanges = useUnsavedChangesPrompt(
|
||||
form.dirty && !form.isSubmitting,
|
||||
);
|
||||
|
||||
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
||||
selectedProviderType,
|
||||
form.values.config,
|
||||
);
|
||||
const hasFieldErrors =
|
||||
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
|
||||
const enabledToggleDisabled =
|
||||
isSaving ||
|
||||
(editingModel?.is_default === true && editingModel.enabled === true);
|
||||
const setDefaultDisabled =
|
||||
isSaving ||
|
||||
(isEditing &&
|
||||
(editingModel?.is_default === true || editingModel?.enabled === false));
|
||||
|
||||
const contextLimitValid =
|
||||
parsePositiveInteger(form.values.contextLimit) !== null;
|
||||
const compressionThresholdValid =
|
||||
!form.values.compressionThreshold.trim() ||
|
||||
parseThresholdInteger(form.values.compressionThreshold) !== null;
|
||||
const canSubmit =
|
||||
!isSaving &&
|
||||
!hasFieldErrors &&
|
||||
form.values.model.trim().length > 0 &&
|
||||
contextLimitValid &&
|
||||
compressionThresholdValid;
|
||||
|
||||
const handleConfirmReplaceDefault = () => {
|
||||
replaceDefaultConfirmedRef.current = true;
|
||||
setConfirmingReplaceDefault(false);
|
||||
void form.submitForm();
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedProviderState ||
|
||||
(!canAddModelForSelectedProvider && !isEditing)
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<ModelFormBackLink />
|
||||
<div className="flex flex-col gap-6 pt-6">
|
||||
<SettingsHeaderTitle>Add model</SettingsHeaderTitle>
|
||||
<div className="border border-solid p-6 rounded-lg">
|
||||
<div className="space-y-3">
|
||||
<ModelFormProviderSelect
|
||||
providerStates={providerStates}
|
||||
selectedProviderKey={selectedProviderKey}
|
||||
onProviderChange={onProviderChange}
|
||||
disabled={isDuplicating || providerStates.length === 0}
|
||||
/>
|
||||
{selectedProviderState && (
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
{!selectedProviderState.providerConfig
|
||||
? "Create a managed provider before adding models."
|
||||
: "Set an API key for this provider before adding models."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const modelField = getFieldHelpers("model");
|
||||
const contextLimitField = getFieldHelpers("contextLimit");
|
||||
const compressionThresholdField = getFieldHelpers("compressionThreshold");
|
||||
const displayNameField = getFieldHelpers("displayName");
|
||||
|
||||
const providerLabel = selectedProviderState.label;
|
||||
const title = isEditing
|
||||
? editingModel
|
||||
? editingModel.display_name || editingModel.model
|
||||
: "Edit model"
|
||||
: isDuplicating
|
||||
? `Duplicate ${providerLabel} model`
|
||||
: `Add ${indefiniteArticle(providerLabel)} ${providerLabel} model`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelFormHeader
|
||||
title={title}
|
||||
selectedProviderState={selectedProviderState}
|
||||
isEditing={isEditing}
|
||||
editingModel={editingModel}
|
||||
onDeleteModel={onDeleteModel}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
isSaving={isSaving}
|
||||
enabledToggleDisabled={enabledToggleDisabled}
|
||||
onRequestDelete={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
<div className="flex flex-col gap-6 pt-6">
|
||||
<ModelFormFields
|
||||
form={form}
|
||||
mode={mode}
|
||||
providerStates={providerStates}
|
||||
selectedProviderState={selectedProviderState}
|
||||
selectedProviderKey={selectedProviderKey}
|
||||
selectedProviderType={selectedProviderType}
|
||||
onProviderChange={onProviderChange}
|
||||
isDuplicating={isDuplicating}
|
||||
isEditing={isEditing}
|
||||
isSaving={isSaving}
|
||||
canSubmit={canSubmit}
|
||||
initialModel={initialModel}
|
||||
modelField={modelField}
|
||||
contextLimitField={contextLimitField}
|
||||
compressionThresholdField={compressionThresholdField}
|
||||
displayNameField={displayNameField}
|
||||
setDefaultDisabled={setDefaultDisabled}
|
||||
modelConfigFormBuildResult={modelConfigFormBuildResult}
|
||||
showPricing={showPricing}
|
||||
setShowPricing={setShowPricing}
|
||||
showProviderConfig={showProviderConfig}
|
||||
setShowProviderConfig={setShowProviderConfig}
|
||||
showAdvanced={showAdvanced}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
/>
|
||||
</div>
|
||||
<ModelFormDialogs
|
||||
editingModel={editingModel}
|
||||
onDeleteModel={onDeleteModel}
|
||||
isDeleting={isDeleting}
|
||||
confirmingDelete={confirmingDelete}
|
||||
setConfirmingDelete={setConfirmingDelete}
|
||||
resetForm={(values) => form.resetForm({ values })}
|
||||
formValues={form.values}
|
||||
unsavedChanges={unsavedChanges}
|
||||
confirmingReplaceDefault={confirmingReplaceDefault}
|
||||
setConfirmingReplaceDefault={setConfirmingReplaceDefault}
|
||||
currentDefaultModel={currentDefaultModel}
|
||||
onConfirmReplaceDefault={handleConfirmReplaceDefault}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import type { ModelFormValues } from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
|
||||
import { ConfirmDeleteDialog } from "#/pages/AgentsPage/components/ConfirmDeleteDialog";
|
||||
|
||||
export const ModelFormDialogs: FC<{
|
||||
editingModel?: TypesGen.ChatModelConfig;
|
||||
onDeleteModel?: (modelConfigId: string) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
setConfirmingDelete: (open: boolean) => void;
|
||||
resetForm: (values: ModelFormValues) => void;
|
||||
formValues: ModelFormValues;
|
||||
unsavedChanges: {
|
||||
isOpen: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
confirmingReplaceDefault: boolean;
|
||||
setConfirmingReplaceDefault: (open: boolean) => void;
|
||||
currentDefaultModel?: TypesGen.ChatModelConfig;
|
||||
onConfirmReplaceDefault: () => void;
|
||||
}> = ({
|
||||
editingModel,
|
||||
onDeleteModel,
|
||||
isDeleting,
|
||||
confirmingDelete,
|
||||
setConfirmingDelete,
|
||||
resetForm,
|
||||
formValues,
|
||||
unsavedChanges,
|
||||
confirmingReplaceDefault,
|
||||
setConfirmingReplaceDefault,
|
||||
currentDefaultModel,
|
||||
onConfirmReplaceDefault,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{editingModel && onDeleteModel && (
|
||||
<ConfirmDeleteDialog
|
||||
entity="model"
|
||||
isPending={isDeleting}
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
onConfirm={() => {
|
||||
resetForm(formValues);
|
||||
void onDeleteModel(editingModel.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Dialog
|
||||
open={unsavedChanges.isOpen}
|
||||
onOpenChange={(open) => !open && unsavedChanges.onCancel()}
|
||||
>
|
||||
<DialogContent className="border-border-warning">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved changes</DialogTitle>
|
||||
<DialogDescription className="flex items-start gap-3">
|
||||
<TriangleAlertIcon className="size-icon-sm mt-1 shrink-0 text-content-primary" />
|
||||
<span>Your updates haven't been saved. Leave anyway?</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={unsavedChanges.onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={unsavedChanges.onConfirm}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={confirmingReplaceDefault}
|
||||
onOpenChange={(open) => !open && setConfirmingReplaceDefault(false)}
|
||||
>
|
||||
<DialogContent className="border-border-warning">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Replace default model</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<TriangleAlertIcon className="size-icon-sm shrink-0 text-content-primary" />
|
||||
<span>
|
||||
<strong className="text-content-primary">
|
||||
{currentDefaultModel?.display_name ||
|
||||
currentDefaultModel?.model}
|
||||
</strong>{" "}
|
||||
is currently the default. Replace it?
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => setConfirmingReplaceDefault(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirmReplaceDefault}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { FormikContextType } from "formik";
|
||||
import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Checkbox } from "#/components/Checkbox/Checkbox";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "#/components/Collapsible/Collapsible";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import {
|
||||
GeneralModelConfigFields,
|
||||
ModelConfigFields,
|
||||
PricingModelConfigFields,
|
||||
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields";
|
||||
import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField";
|
||||
import type {
|
||||
ModelConfigFormBuildResult,
|
||||
ModelFormValues,
|
||||
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
|
||||
import { cn } from "#/utils/cn";
|
||||
import type { FormHelpers } from "#/utils/formUtils";
|
||||
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
|
||||
|
||||
const CollapsibleSection: FC<{
|
||||
title: string;
|
||||
description: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
children: ReactNode;
|
||||
}> = ({
|
||||
title,
|
||||
description,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
contentClassName,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={cn("p-4", className)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full cursor-pointer items-start gap-2 border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary">
|
||||
{open ? (
|
||||
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelFormFields: FC<{
|
||||
form: FormikContextType<ModelFormValues>;
|
||||
mode: "add" | "edit" | "duplicate";
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProviderState: ProviderState;
|
||||
selectedProviderKey: string;
|
||||
selectedProviderType: string;
|
||||
onProviderChange: (providerKey: string) => void;
|
||||
isDuplicating: boolean;
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
canSubmit: boolean;
|
||||
initialModel?: TypesGen.ChatModelConfig;
|
||||
modelField: FormHelpers;
|
||||
contextLimitField: FormHelpers;
|
||||
compressionThresholdField: FormHelpers;
|
||||
displayNameField: FormHelpers;
|
||||
setDefaultDisabled: boolean;
|
||||
modelConfigFormBuildResult: ModelConfigFormBuildResult;
|
||||
showPricing: boolean;
|
||||
setShowPricing: (open: boolean) => void;
|
||||
showProviderConfig: boolean;
|
||||
setShowProviderConfig: (open: boolean) => void;
|
||||
showAdvanced: boolean;
|
||||
setShowAdvanced: (open: boolean) => void;
|
||||
}> = ({
|
||||
form,
|
||||
mode,
|
||||
providerStates,
|
||||
selectedProviderState,
|
||||
selectedProviderKey,
|
||||
selectedProviderType,
|
||||
onProviderChange,
|
||||
isDuplicating,
|
||||
isEditing,
|
||||
isSaving,
|
||||
canSubmit,
|
||||
initialModel,
|
||||
modelField,
|
||||
contextLimitField,
|
||||
compressionThresholdField,
|
||||
displayNameField,
|
||||
setDefaultDisabled,
|
||||
modelConfigFormBuildResult,
|
||||
showPricing,
|
||||
setShowPricing,
|
||||
showProviderConfig,
|
||||
setShowProviderConfig,
|
||||
showAdvanced,
|
||||
setShowAdvanced,
|
||||
}) => {
|
||||
return (
|
||||
<div className="border border-solid p-6 rounded-lg">
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<ModelFormProviderSelect
|
||||
providerStates={providerStates}
|
||||
selectedProviderKey={selectedProviderKey}
|
||||
onProviderChange={onProviderChange}
|
||||
disabled={isDuplicating || providerStates.length === 0}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ModelIdentifierField
|
||||
form={form}
|
||||
modelField={modelField}
|
||||
mode={mode}
|
||||
selectedProvider={selectedProviderType}
|
||||
disabled={isSaving}
|
||||
controlClassName="shadow-none"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isDefault"
|
||||
className="flex w-fit cursor-pointer items-center gap-2 font-normal text-sm leading-6 text-content-secondary"
|
||||
>
|
||||
<Checkbox
|
||||
id="isDefault"
|
||||
checked={form.values.isDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setFieldValue("isDefault", checked === true)
|
||||
}
|
||||
disabled={setDefaultDisabled}
|
||||
/>
|
||||
Set as default model
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={displayNameField.id}
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
Display name{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Friendly name. Defaults to identifier if blank.
|
||||
</p>
|
||||
<Input
|
||||
id={displayNameField.id}
|
||||
name={displayNameField.name}
|
||||
className="placeholder:text-content-disabled shadow-none"
|
||||
placeholder={initialModel?.model ?? "Model name"}
|
||||
value={displayNameField.value}
|
||||
onChange={displayNameField.onChange}
|
||||
onBlur={displayNameField.onBlur}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
Context limit{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
{contextLimitField.error ? (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{contextLimitField.helperText}
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Max tokens in the context window.
|
||||
</p>
|
||||
)}
|
||||
<InputGroup
|
||||
className={cn(
|
||||
contextLimitField.error && "border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={contextLimitField.id}
|
||||
name={contextLimitField.name}
|
||||
className="min-w-0 placeholder:text-content-disabled"
|
||||
placeholder="200000"
|
||||
value={contextLimitField.value}
|
||||
onChange={contextLimitField.onChange}
|
||||
onBlur={contextLimitField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={contextLimitField.error}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">Tokens</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-solid border-border">
|
||||
<CollapsibleSection
|
||||
title="Cost tracking"
|
||||
description="Set per-token pricing so Coder can track costs and enforce spending limits."
|
||||
open={showPricing}
|
||||
onOpenChange={setShowPricing}
|
||||
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-4"
|
||||
>
|
||||
<PricingModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Provider configuration"
|
||||
description="Tune provider-specific behavior like reasoning, tool calling, and web search."
|
||||
open={showProviderConfig}
|
||||
onOpenChange={setShowProviderConfig}
|
||||
className="border-0 border-t border-solid border-border"
|
||||
contentClassName="pt-3 pl-6"
|
||||
>
|
||||
<ModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Advanced"
|
||||
description="Low-level parameters like temperature and penalties. Rarely need changing."
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
className="border-0 border-t border-solid border-border"
|
||||
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-3"
|
||||
>
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
Compression threshold
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
Percentage at which context is compressed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
compressionThresholdField.error &&
|
||||
"border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className="placeholder:text-content-disabled"
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">%</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link to="/ai/settings/models">
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{isSaving && <Spinner loading />}
|
||||
{isEditing
|
||||
? "Update model"
|
||||
: isDuplicating
|
||||
? "Create duplicate"
|
||||
: "Add Model"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CopyIcon,
|
||||
EllipsisVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import { getProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const ModelFormBackLink: FC = () => {
|
||||
return (
|
||||
<Link to="/ai/settings/models" className="-ml-3">
|
||||
<Button variant="subtle" type="button">
|
||||
<ArrowLeftIcon />
|
||||
<span>Back to models</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelFormHeader: FC<{
|
||||
title: string;
|
||||
selectedProviderState: ProviderState;
|
||||
isEditing: boolean;
|
||||
editingModel?: TypesGen.ChatModelConfig;
|
||||
onDeleteModel?: (modelConfigId: string) => Promise<void>;
|
||||
onDuplicate?: () => void;
|
||||
onToggleEnabled?: (enabled: boolean) => void;
|
||||
isSaving: boolean;
|
||||
enabledToggleDisabled: boolean;
|
||||
onRequestDelete: () => void;
|
||||
}> = ({
|
||||
title,
|
||||
selectedProviderState,
|
||||
isEditing,
|
||||
editingModel,
|
||||
onDeleteModel,
|
||||
onDuplicate,
|
||||
onToggleEnabled,
|
||||
isSaving,
|
||||
enabledToggleDisabled,
|
||||
onRequestDelete,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<ModelFormBackLink />
|
||||
{isEditing && editingModel && onDeleteModel && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
aria-label="Model actions"
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onDuplicate && (
|
||||
<DropdownMenuItem onClick={onDuplicate}>
|
||||
<CopyIcon className="size-icon-sm" />
|
||||
Duplicate model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
variant="icon"
|
||||
size="lg"
|
||||
src={getProviderIcon(selectedProviderState.provider)}
|
||||
/>
|
||||
<SettingsHeaderTitle>
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-0 truncate",
|
||||
editingModel?.enabled === false && "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</SettingsHeaderTitle>
|
||||
{isEditing && editingModel?.is_default && (
|
||||
<Badge variant="default">Default</Badge>
|
||||
)}
|
||||
{isEditing &&
|
||||
editingModel &&
|
||||
!editingModel.is_default &&
|
||||
!editingModel.enabled && <Badge variant="default">Disabled</Badge>}
|
||||
</div>
|
||||
{isEditing && editingModel && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Switch
|
||||
checked={editingModel.enabled}
|
||||
onCheckedChange={(checked) => onToggleEnabled?.(checked)}
|
||||
disabled={enabledToggleDisabled}
|
||||
aria-label="Model enabled"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{editingModel.is_default && editingModel.enabled
|
||||
? "Default model cannot be disabled. Set another model as default first."
|
||||
: editingModel.enabled
|
||||
? "Disable this model. It will be hidden from users."
|
||||
: "Enable this model. It will be visible to users."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm">Enable</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FC } from "react";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
|
||||
|
||||
export const ModelFormProviderSelect: FC<{
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProviderKey: string;
|
||||
onProviderChange: (providerKey: string) => void;
|
||||
disabled: boolean;
|
||||
}> = ({ providerStates, selectedProviderKey, onProviderChange, disabled }) => {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor="providerSelect"
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
Provider{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">*</span>
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
The provider this model belongs to.
|
||||
</p>
|
||||
<Select
|
||||
value={selectedProviderKey}
|
||||
onValueChange={onProviderChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="providerSelect"
|
||||
className="text-content-primary shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerStates.map((ps) => (
|
||||
<SelectItem key={ps.key} value={ps.key}>
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon provider={ps.provider} />
|
||||
{ps.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ChatModelConfig } from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { TableCell, TableRow } from "#/components/Table/Table";
|
||||
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
|
||||
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type ModelRowProps = {
|
||||
model: ChatModelConfig;
|
||||
providerLabel: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const formatContextLimit = (contextLimit: number): string => {
|
||||
if (!Number.isFinite(contextLimit)) {
|
||||
return "N/A";
|
||||
}
|
||||
return `${contextLimit.toLocaleString("en-US")} tokens`;
|
||||
};
|
||||
|
||||
export const ModelRow: FC<ModelRowProps> = ({
|
||||
model,
|
||||
providerLabel,
|
||||
onClick,
|
||||
}) => {
|
||||
const clickableProps = useClickableTableRow({ onClick });
|
||||
const displayName = model.display_name || model.model;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
{...clickableProps}
|
||||
className={cn(clickableProps.className, "h-[72px]")}
|
||||
>
|
||||
<TableCell className="min-w-0 p-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
>
|
||||
<ProviderIcon provider={model.provider} />
|
||||
</Avatar>
|
||||
<span
|
||||
className="truncate text-sm font-medium leading-6 text-content-primary"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0">
|
||||
<span
|
||||
className="block truncate text-sm font-medium leading-6 text-content-secondary"
|
||||
title={providerLabel}
|
||||
>
|
||||
{providerLabel || "N/A"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium leading-6 text-content-secondary">
|
||||
{formatContextLimit(model.context_limit)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{model.is_default && <Badge variant="default">Default</Badge>}
|
||||
<Badge variant="default">
|
||||
{model.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-10 text-center">
|
||||
<div className="flex justify-end items-center gap-8 pr-4">
|
||||
<ChevronRightIcon
|
||||
aria-hidden
|
||||
className="size-icon-md text-content-primary flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ChatModelConfig, ChatProviderConfig } from "#/api/typesGenerated";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
|
||||
const now = "2026-02-18T12:00:00.000Z";
|
||||
|
||||
const MockOpenAIProviderConfig: ChatProviderConfig = {
|
||||
id: "prov-openai",
|
||||
provider: "openai",
|
||||
display_name: "OpenAI",
|
||||
enabled: true,
|
||||
has_api_key: true,
|
||||
central_api_key_enabled: true,
|
||||
allow_user_api_key: false,
|
||||
allow_central_api_key_fallback: true,
|
||||
base_url: "",
|
||||
source: "database",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const MockAnthropicProviderConfig: ChatProviderConfig = {
|
||||
...MockOpenAIProviderConfig,
|
||||
id: "prov-anthropic",
|
||||
provider: "anthropic",
|
||||
display_name: "Anthropic",
|
||||
};
|
||||
|
||||
export const mockGPT5: ChatModelConfig = {
|
||||
id: "model-gpt5",
|
||||
provider: "openai",
|
||||
ai_provider_id: "prov-openai",
|
||||
model: "gpt-5",
|
||||
display_name: "GPT-5",
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
context_limit: 200000,
|
||||
compression_threshold: 70,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
export const mockClaude: ChatModelConfig = {
|
||||
...mockGPT5,
|
||||
id: "model-claude",
|
||||
provider: "anthropic",
|
||||
ai_provider_id: "prov-anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
display_name: "Claude Sonnet 4.5",
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export const mockDisabledModel: ChatModelConfig = {
|
||||
...mockGPT5,
|
||||
id: "model-disabled",
|
||||
model: "gpt-4o-mini",
|
||||
display_name: "GPT-4o mini",
|
||||
is_default: false,
|
||||
enabled: false,
|
||||
context_limit: 128000,
|
||||
};
|
||||
|
||||
export const MockOpenAIProviderState: ProviderState = {
|
||||
key: "prov-openai",
|
||||
provider: "openai",
|
||||
label: "OpenAI",
|
||||
providerConfig: MockOpenAIProviderConfig,
|
||||
modelConfigs: [mockGPT5, mockDisabledModel],
|
||||
catalogModelCount: 0,
|
||||
hasManagedAPIKey: true,
|
||||
hasCatalogAPIKey: true,
|
||||
hasEffectiveAPIKey: true,
|
||||
allowUserAPIKey: false,
|
||||
isEnvPreset: false,
|
||||
baseURL: "",
|
||||
};
|
||||
|
||||
export const MockAnthropicProviderState: ProviderState = {
|
||||
...MockOpenAIProviderState,
|
||||
key: "prov-anthropic",
|
||||
provider: "anthropic",
|
||||
label: "Anthropic",
|
||||
providerConfig: MockAnthropicProviderConfig,
|
||||
modelConfigs: [mockClaude],
|
||||
};
|
||||
+1
-1
@@ -158,7 +158,7 @@ const UpdateProviderPageView: React.FC = () => {
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
Add or update models for this provider.{" "}
|
||||
<a
|
||||
href="/agents/settings/models"
|
||||
href="/ai/settings/models"
|
||||
className="text-content-link no-underline hover:underline"
|
||||
>
|
||||
Model settings
|
||||
|
||||
@@ -548,7 +548,7 @@ export const MissingProviderAndModelSetup: Story = {
|
||||
);
|
||||
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
"/ai/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -579,7 +579,7 @@ export const MissingModelSetup: Story = {
|
||||
});
|
||||
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
"/ai/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
chatModelConfigs,
|
||||
chatModels,
|
||||
chatProviderConfigs,
|
||||
createChatModelConfig,
|
||||
createChatProviderConfig,
|
||||
deleteChatModelConfig,
|
||||
deleteChatProviderConfig,
|
||||
updateChatModelConfig,
|
||||
updateChatProviderConfig,
|
||||
} from "#/api/queries/chats";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
|
||||
const AgentSettingsModelsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Queries.
|
||||
const providerConfigsQuery = useQuery({
|
||||
...chatProviderConfigs(),
|
||||
enabled: permissions.editDeploymentConfig,
|
||||
});
|
||||
const modelConfigsQuery = useQuery(chatModelConfigs());
|
||||
const modelCatalogQuery = useQuery(chatModels());
|
||||
|
||||
// Mutations.
|
||||
const createProviderMutation = useMutation(
|
||||
createChatProviderConfig(queryClient),
|
||||
);
|
||||
const updateProviderMutation = useMutation(
|
||||
updateChatProviderConfig(queryClient),
|
||||
);
|
||||
const deleteProviderMutation = useMutation(
|
||||
deleteChatProviderConfig(queryClient),
|
||||
);
|
||||
const createModelMutation = useMutation(createChatModelConfig(queryClient));
|
||||
const updateModelMutation = useMutation(updateChatModelConfig(queryClient));
|
||||
const deleteModelMutation = useMutation(deleteChatModelConfig(queryClient));
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<ChatModelAdminPanel
|
||||
section="models"
|
||||
sectionLabel="Models"
|
||||
sectionDescription="Choose which models from your configured providers are available for Coder Agents. Set a default and adjust context limits."
|
||||
providerConfigsData={providerConfigsQuery.data}
|
||||
modelConfigsData={modelConfigsQuery.data}
|
||||
modelCatalogData={modelCatalogQuery.data}
|
||||
isLoading={
|
||||
providerConfigsQuery.isLoading ||
|
||||
modelConfigsQuery.isLoading ||
|
||||
modelCatalogQuery.isLoading
|
||||
}
|
||||
providerConfigsError={
|
||||
providerConfigsQuery.isError ? providerConfigsQuery.error : null
|
||||
}
|
||||
modelConfigsError={
|
||||
modelConfigsQuery.isError ? modelConfigsQuery.error : null
|
||||
}
|
||||
modelCatalogError={
|
||||
modelCatalogQuery.isError ? modelCatalogQuery.error : null
|
||||
}
|
||||
onCreateProvider={(req) => createProviderMutation.mutateAsync(req)}
|
||||
onUpdateProvider={(providerConfigId, req) =>
|
||||
updateProviderMutation.mutateAsync({ providerConfigId, req })
|
||||
}
|
||||
onDeleteProvider={(id) => deleteProviderMutation.mutateAsync(id)}
|
||||
isProviderMutationPending={
|
||||
createProviderMutation.isPending ||
|
||||
updateProviderMutation.isPending ||
|
||||
deleteProviderMutation.isPending
|
||||
}
|
||||
providerMutationError={
|
||||
createProviderMutation.error ??
|
||||
updateProviderMutation.error ??
|
||||
deleteProviderMutation.error
|
||||
}
|
||||
onCreateModel={(req) => createModelMutation.mutateAsync(req)}
|
||||
onUpdateModel={(modelConfigId, req) =>
|
||||
updateModelMutation.mutateAsync({ modelConfigId, req })
|
||||
}
|
||||
onDeleteModel={(id) => deleteModelMutation.mutateAsync(id)}
|
||||
isCreatingModel={createModelMutation.isPending}
|
||||
isUpdatingModel={updateModelMutation.isPending}
|
||||
isDeletingModel={deleteModelMutation.isPending}
|
||||
modelMutationError={
|
||||
createModelMutation.error ??
|
||||
updateModelMutation.error ??
|
||||
deleteModelMutation.error
|
||||
}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsModelsPage;
|
||||
@@ -494,7 +494,7 @@ export const MissingProviderAndModelSetup: Story = {
|
||||
);
|
||||
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
|
||||
"href",
|
||||
"/agents/settings/models",
|
||||
"/ai/settings/models",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
|
||||
{" "}
|
||||
then add a{" "}
|
||||
<Link
|
||||
to="/agents/settings/models"
|
||||
to="/ai/settings/models"
|
||||
className="text-content-link transition-colors hover:text-content-link/80"
|
||||
>
|
||||
model
|
||||
@@ -62,7 +62,7 @@ export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
|
||||
<NoticeContainer>
|
||||
To chat with Coder Agents, set up a{" "}
|
||||
<Link
|
||||
to="/agents/settings/models"
|
||||
to="/ai/settings/models"
|
||||
className="text-content-link transition-colors hover:text-content-link/80"
|
||||
>
|
||||
model
|
||||
|
||||
-2380
File diff suppressed because it is too large
Load Diff
@@ -1,373 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import {
|
||||
getDefaultProviderBaseURL,
|
||||
normalizeProvider,
|
||||
readOptionalString,
|
||||
} from "./helpers";
|
||||
import { ModelsSection } from "./ModelsSection";
|
||||
import { ProvidersSection } from "./ProvidersSection";
|
||||
|
||||
export type CreateProviderResult = { id: string };
|
||||
|
||||
export type ProviderState = {
|
||||
key: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
catalogModelCount: number;
|
||||
hasManagedAPIKey: boolean;
|
||||
hasCatalogAPIKey: boolean;
|
||||
hasEffectiveAPIKey: boolean;
|
||||
allowUserAPIKey: boolean;
|
||||
isEnvPreset: boolean;
|
||||
baseURL: string;
|
||||
};
|
||||
|
||||
export type ChatModelAdminSection = "providers" | "models";
|
||||
|
||||
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
|
||||
|
||||
const nilUUID = "00000000-0000-0000-0000-000000000000";
|
||||
const envPresetProviders = new Set(["openai", "anthropic"]);
|
||||
|
||||
const hasProviderAPIKey = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
): boolean => {
|
||||
if (!providerConfig) return false;
|
||||
return providerConfig.has_api_key;
|
||||
};
|
||||
|
||||
const getProviderConfigSource = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
): TypesGen.ChatProviderConfigSource | undefined => {
|
||||
return providerConfig?.source;
|
||||
};
|
||||
|
||||
const isDatabaseProviderConfig = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
source: TypesGen.ChatProviderConfigSource | undefined,
|
||||
): providerConfig is TypesGen.ChatProviderConfig => {
|
||||
if (!providerConfig) return false;
|
||||
if (providerConfig.id === nilUUID) return false;
|
||||
return source === undefined || source === "database";
|
||||
};
|
||||
|
||||
const getCatalogProviders = (
|
||||
catalog: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): readonly CatalogProvider[] => {
|
||||
const providers = catalog?.providers;
|
||||
return Array.isArray(providers) ? providers : [];
|
||||
};
|
||||
|
||||
const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean =>
|
||||
provider.available ||
|
||||
(Boolean(provider.unavailable_reason) &&
|
||||
provider.unavailable_reason !== "missing_api_key");
|
||||
|
||||
const getProviderModels = (
|
||||
provider: CatalogProvider | undefined,
|
||||
): readonly CatalogProvider["models"][number][] => {
|
||||
const models = provider?.models;
|
||||
return Array.isArray(models) ? models : [];
|
||||
};
|
||||
|
||||
const getProviderBaseURL = (
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined,
|
||||
): string => {
|
||||
return (
|
||||
readOptionalString(providerConfig?.base_url) ??
|
||||
getDefaultProviderBaseURL(providerConfig?.provider ?? "")
|
||||
);
|
||||
};
|
||||
|
||||
const providerConfigStateKey = (
|
||||
providerConfig: TypesGen.ChatProviderConfig,
|
||||
): string => {
|
||||
const providerID = readOptionalString(providerConfig.id);
|
||||
if (providerID && providerID !== nilUUID) {
|
||||
return providerID;
|
||||
}
|
||||
return normalizeProvider(providerConfig.provider);
|
||||
};
|
||||
|
||||
type ProviderEntry = {
|
||||
key: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const useProviderStates = (
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[],
|
||||
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
|
||||
catalogData: TypesGen.ChatModelsResponse | null | undefined,
|
||||
): readonly ProviderState[] => {
|
||||
const orderedEntries: ProviderEntry[] = [];
|
||||
const seenEntries = new Set<string>();
|
||||
const includeEntry = (keyValue: string, providerValue: string) => {
|
||||
const key = readOptionalString(keyValue);
|
||||
const provider = normalizeProvider(providerValue);
|
||||
if (!key || !provider || seenEntries.has(key)) return;
|
||||
seenEntries.add(key);
|
||||
orderedEntries.push({ key, provider });
|
||||
};
|
||||
|
||||
const catalogProviders = getCatalogProviders(catalogData);
|
||||
const catalogProvidersByProvider = new Map<string, CatalogProvider>();
|
||||
for (const cp of catalogProviders) {
|
||||
const provider = normalizeProvider(cp.provider);
|
||||
if (!provider) continue;
|
||||
catalogProvidersByProvider.set(provider, cp);
|
||||
}
|
||||
|
||||
const providerConfigKeysByProvider = new Map<string, string[]>();
|
||||
const providerTypesWithConfigs = new Set<string>();
|
||||
for (const pc of providerConfigsData ?? []) {
|
||||
const provider = normalizeProvider(pc.provider);
|
||||
if (!provider) continue;
|
||||
const key = providerConfigStateKey(pc);
|
||||
providerTypesWithConfigs.add(provider);
|
||||
providerConfigKeysByProvider.set(provider, [
|
||||
...(providerConfigKeysByProvider.get(provider) ?? []),
|
||||
key,
|
||||
]);
|
||||
includeEntry(key, provider);
|
||||
}
|
||||
const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => {
|
||||
const aiProviderID = readOptionalString(modelConfig.ai_provider_id);
|
||||
if (aiProviderID) {
|
||||
return aiProviderID;
|
||||
}
|
||||
const provider = normalizeProvider(modelConfig.provider);
|
||||
const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? [];
|
||||
if (providerConfigKeys.length === 1) {
|
||||
return providerConfigKeys[0];
|
||||
}
|
||||
return providerConfigKeys.length === 0 ? provider : "";
|
||||
};
|
||||
|
||||
for (const cp of catalogProviders) {
|
||||
const provider = normalizeProvider(cp.provider);
|
||||
if (!provider || providerTypesWithConfigs.has(provider)) continue;
|
||||
includeEntry(provider, provider);
|
||||
}
|
||||
for (const mc of modelConfigs) {
|
||||
includeEntry(modelStateKey(mc), mc.provider);
|
||||
}
|
||||
|
||||
const providerConfigsByKey = new Map<string, TypesGen.ChatProviderConfig>();
|
||||
for (const pc of providerConfigsData ?? []) {
|
||||
const key = providerConfigStateKey(pc);
|
||||
if (!key) continue;
|
||||
providerConfigsByKey.set(key, pc);
|
||||
}
|
||||
|
||||
const modelConfigsByKey = new Map<string, TypesGen.ChatModelConfig[]>();
|
||||
for (const mc of modelConfigs) {
|
||||
const key = modelStateKey(mc);
|
||||
if (!key) continue;
|
||||
const existing = modelConfigsByKey.get(key);
|
||||
if (existing) {
|
||||
existing.push(mc);
|
||||
} else {
|
||||
modelConfigsByKey.set(key, [mc]);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedEntries.map(({ key, provider }) => {
|
||||
const providerConfigEntry = providerConfigsByKey.get(key);
|
||||
const providerConfigSource = getProviderConfigSource(providerConfigEntry);
|
||||
const providerConfig = isDatabaseProviderConfig(
|
||||
providerConfigEntry,
|
||||
providerConfigSource,
|
||||
)
|
||||
? providerConfigEntry
|
||||
: undefined;
|
||||
const catalogProvider = catalogProvidersByProvider.get(provider);
|
||||
const hasManagedAPIKey = hasProviderAPIKey(providerConfig);
|
||||
const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry);
|
||||
const hasCatalogAPIKey = catalogProvider
|
||||
? providerHasCatalogAPIKey(catalogProvider)
|
||||
: false;
|
||||
const label =
|
||||
readOptionalString(providerConfigEntry?.display_name) ??
|
||||
formatProviderLabel(provider);
|
||||
const hasBedrockAmbientCredentials =
|
||||
provider === "bedrock" &&
|
||||
providerConfig?.central_api_key_enabled === true;
|
||||
const modelConfigsForProvider = modelConfigsByKey.get(key) ?? [];
|
||||
const isCatalogEnvPreset =
|
||||
!providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey;
|
||||
const isEnvPreset =
|
||||
providerConfigSource === "env_preset" || isCatalogEnvPreset;
|
||||
|
||||
return {
|
||||
key,
|
||||
provider,
|
||||
label,
|
||||
providerConfig,
|
||||
modelConfigs: modelConfigsForProvider,
|
||||
catalogModelCount: getProviderModels(catalogProvider).length,
|
||||
hasManagedAPIKey,
|
||||
hasCatalogAPIKey,
|
||||
hasEffectiveAPIKey: providerConfigEntry
|
||||
? hasProviderEntryAPIKey || hasBedrockAmbientCredentials
|
||||
: hasManagedAPIKey || hasCatalogAPIKey,
|
||||
allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true,
|
||||
isEnvPreset,
|
||||
baseURL: getProviderBaseURL(providerConfigEntry),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface ChatModelAdminPanelProps {
|
||||
className?: string;
|
||||
section?: ChatModelAdminSection;
|
||||
sectionLabel?: string;
|
||||
sectionDescription?: string;
|
||||
// Data from queries.
|
||||
providerConfigsData: TypesGen.ChatProviderConfig[] | undefined;
|
||||
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
|
||||
modelCatalogData: TypesGen.ChatModelsResponse | undefined;
|
||||
isLoading: boolean;
|
||||
// Query error states.
|
||||
providerConfigsError: Error | null;
|
||||
modelConfigsError: Error | null;
|
||||
modelCatalogError: Error | null;
|
||||
// Provider mutation handlers.
|
||||
onCreateProvider: (
|
||||
req: TypesGen.CreateChatProviderConfigRequest,
|
||||
) => Promise<CreateProviderResult>;
|
||||
onUpdateProvider: (
|
||||
providerConfigId: string,
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
isProviderMutationPending: boolean;
|
||||
providerMutationError: Error | null;
|
||||
// Model mutation handlers.
|
||||
onCreateModel: (
|
||||
req: TypesGen.CreateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onUpdateModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteModel: (modelConfigId: string) => Promise<void>;
|
||||
isCreatingModel: boolean;
|
||||
isUpdatingModel: boolean;
|
||||
isDeletingModel: boolean;
|
||||
modelMutationError: Error | null;
|
||||
}
|
||||
|
||||
export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
className,
|
||||
section = "providers",
|
||||
sectionLabel,
|
||||
sectionDescription,
|
||||
providerConfigsData,
|
||||
modelConfigsData,
|
||||
modelCatalogData,
|
||||
isLoading,
|
||||
providerConfigsError,
|
||||
modelConfigsError,
|
||||
modelCatalogError,
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
isProviderMutationPending,
|
||||
providerMutationError,
|
||||
onCreateModel,
|
||||
onUpdateModel,
|
||||
onDeleteModel,
|
||||
isCreatingModel,
|
||||
isUpdatingModel,
|
||||
isDeletingModel,
|
||||
modelMutationError,
|
||||
}) => {
|
||||
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
|
||||
const cmp = a.provider.localeCompare(b.provider);
|
||||
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
|
||||
});
|
||||
const providerStates = useProviderStates(
|
||||
modelConfigs,
|
||||
providerConfigsData,
|
||||
modelCatalogData,
|
||||
);
|
||||
|
||||
const providerConfigsUnavailable = providerConfigsData === null;
|
||||
const modelConfigsUnavailable = modelConfigsData === null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-full flex-col", className)}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
Loading
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-8">
|
||||
{section === "providers" ? (
|
||||
<ProvidersSection
|
||||
sectionLabel={sectionLabel}
|
||||
sectionDescription={sectionDescription}
|
||||
providerStates={providerStates}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={onCreateProvider}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={onDeleteProvider}
|
||||
/>
|
||||
) : (
|
||||
<ModelsSection
|
||||
sectionLabel={sectionLabel}
|
||||
sectionDescription={sectionDescription}
|
||||
providerStates={providerStates}
|
||||
modelConfigs={modelConfigs}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isCreating={isCreatingModel}
|
||||
isUpdating={isUpdatingModel}
|
||||
isDeleting={isDeletingModel}
|
||||
onCreateModel={onCreateModel}
|
||||
onUpdateModel={onUpdateModel}
|
||||
onDeleteModel={onDeleteModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{providerConfigsError && <ErrorAlert error={providerConfigsError} />}
|
||||
{modelConfigsError && <ErrorAlert error={modelConfigsError} />}
|
||||
{modelCatalogError && <ErrorAlert error={modelCatalogError} />}
|
||||
{providerMutationError && <ErrorAlert error={providerMutationError} />}
|
||||
{modelMutationError && <ErrorAlert error={modelMutationError} />}
|
||||
|
||||
{providerConfigsUnavailable && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
Chat provider admin API is unavailable on this deployment.
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
/api/v2/chats/providers is missing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{modelConfigsUnavailable && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
Chat model admin API is unavailable on this deployment.
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
/api/v2/chats/model-configs is missing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -29,17 +29,24 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { normalizeProvider } from "#/modules/aiModels/helpers";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { normalizeProvider } from "./helpers";
|
||||
import type {
|
||||
ModelConfigFormBuildResult,
|
||||
ModelFormValues,
|
||||
import {
|
||||
isFieldConflictDisabled,
|
||||
isVisibleWhenSatisfied,
|
||||
type ModelConfigFormBuildResult,
|
||||
type ModelFormValues,
|
||||
} from "./modelConfigFormLogic";
|
||||
import {
|
||||
getPricingPlaceholderForField,
|
||||
pricingFieldNames,
|
||||
} from "./pricingFields";
|
||||
|
||||
const booleanFieldOptions = [
|
||||
{ label: "On", value: "true" },
|
||||
{ label: "Off", value: "false" },
|
||||
] as const;
|
||||
|
||||
/** Sentinel value for Select components to represent "no selection". */
|
||||
const unsetSelectValue = "__unset__";
|
||||
|
||||
@@ -143,7 +150,7 @@ const FieldLabel: FC<{
|
||||
}> = ({ htmlFor, label, description }) => (
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
{label}
|
||||
{description && (
|
||||
@@ -184,12 +191,10 @@ const InputField: FC<
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
|
||||
const inputEl = suffix ? (
|
||||
<InputGroup
|
||||
className={cn("h-9", fieldError && "border-border-destructive")}
|
||||
>
|
||||
<InputGroup className={cn(fieldError && "border-border-destructive")}>
|
||||
<InputGroupInput
|
||||
id={fieldKey}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
className="min-w-0 placeholder:text-content-disabled"
|
||||
placeholder={placeholder}
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
@@ -204,7 +209,7 @@ const InputField: FC<
|
||||
<Input
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
|
||||
"min-w-0 placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
@@ -264,10 +269,7 @@ const SelectField: FC<
|
||||
>
|
||||
<SelectTrigger
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-9 min-w-0 text-[13px]",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
className={cn("min-w-0", fieldError && "border-content-destructive")}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
>
|
||||
@@ -277,7 +279,7 @@ const SelectField: FC<
|
||||
<SelectItem value={unsetSelectValue}>Default</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
{capitalize(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -379,6 +381,10 @@ const JSONField: FC<
|
||||
const errorId = `${fieldKey}-error`;
|
||||
const fieldError = fieldErrors[errorKey ?? fieldKey];
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
// Only surface the error once the field has been blurred, so a partially
|
||||
// typed array like "[" doesn't complain mid-edit.
|
||||
const showError =
|
||||
Boolean(fieldError) && Boolean(getIn(form.touched, fieldKey));
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
|
||||
@@ -387,15 +393,15 @@ const JSONField: FC<
|
||||
rows={1}
|
||||
className={cn(
|
||||
"min-h-0 resize-y font-mono text-xs leading-tight placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
showError && "border-content-destructive",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
aria-invalid={showError}
|
||||
aria-describedby={showError ? errorId : undefined}
|
||||
/>
|
||||
{fieldError && (
|
||||
{showError && (
|
||||
<p id={errorId} className="m-0 text-xs text-content-destructive">
|
||||
{fieldError}
|
||||
</p>
|
||||
@@ -451,10 +457,7 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
options={[
|
||||
{ label: "On", value: "true" },
|
||||
{ label: "Off", value: "false" },
|
||||
]}
|
||||
options={booleanFieldOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +471,10 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
options={options.map((v) => ({ label: capitalize(v), value: v }))}
|
||||
options={options.map((value) => ({
|
||||
label: capitalize(value),
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -503,8 +509,8 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
|
||||
/**
|
||||
* How many grid columns a field should span in the 3-col layout.
|
||||
* 1 = default (inputs, booleans, small enums ≤3)
|
||||
* 3 = full-width (4+ option enums, json textareas)
|
||||
* 1 = default (inputs, booleans, small enums)
|
||||
* 3 = full-width (large enums, json textareas)
|
||||
*/
|
||||
function colSpan(field: FieldSchema): 1 | 3 {
|
||||
if (field.input_type === "json") {
|
||||
@@ -553,11 +559,17 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
const fieldValueByName = (jsonName: string): unknown =>
|
||||
getIn(form.values, `config.${toFormFieldKey(resolved, jsonName)}`);
|
||||
|
||||
const isFieldVisible = (field: FieldSchema): boolean =>
|
||||
isVisibleWhenSatisfied(field, fieldValueByName);
|
||||
|
||||
// Sort wider fields to the end so compact fields fill the
|
||||
// grid first, keeping the layout dense.
|
||||
const sorted = [...fields].sort((a, b) => colSpan(a) - colSpan(b));
|
||||
const sorted = [...fields]
|
||||
.filter(isFieldVisible)
|
||||
.sort((a, b) => colSpan(a) - colSpan(b));
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||
@@ -570,7 +582,11 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
field={field}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
{...ctx}
|
||||
form={form}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={
|
||||
disabled || isFieldConflictDisabled(field, fieldValueByName)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -579,50 +595,6 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared renderer for general model config fields backed by the
|
||||
* top-level ChatModelCallConfig schema.
|
||||
*/
|
||||
const GeneralFieldsGroup: FC<
|
||||
ModelConfigFieldsProps & {
|
||||
fields: FieldSchema[];
|
||||
suppressDescriptions?: boolean;
|
||||
}
|
||||
> = ({ form, fieldErrors, disabled, fields, suppressDescriptions }) => {
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field) => {
|
||||
// General field keys support nested json_name values, such as
|
||||
// cost.input_price_per_million_tokens.
|
||||
const camelName = field.json_name
|
||||
.split(".")
|
||||
.map(snakeToCamel)
|
||||
.join(".");
|
||||
const fieldKey = `config.${camelName}`;
|
||||
const label = snakeToPrettyLabel(field);
|
||||
|
||||
return (
|
||||
<InputField
|
||||
key={fieldKey}
|
||||
{...ctx}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={camelName}
|
||||
label={label}
|
||||
description={suppressDescriptions ? undefined : field.description}
|
||||
placeholder={
|
||||
placeholderOverrides[field.json_name] ??
|
||||
placeholderForField(field)
|
||||
}
|
||||
suffix={fieldSuffix[field.json_name]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pricing fields rendered with $ prefix and /1M suffix using
|
||||
* InputGroup for a compact, readable layout.
|
||||
@@ -653,12 +625,12 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
|
||||
<FieldLabel htmlFor={fieldKey} label={label} />
|
||||
<InputGroup
|
||||
className={cn("h-9", fieldError && "border-border-destructive")}
|
||||
className={cn(fieldError && "border-border-destructive")}
|
||||
>
|
||||
<InputGroupAddon align="inline-start">$</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id={fieldKey}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
className="min-w-0 placeholder:text-content-disabled"
|
||||
placeholder="0"
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
@@ -691,20 +663,43 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
* `api/chatModelOptions`.
|
||||
*/
|
||||
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
provider,
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
const fields = getVisibleGeneralFields().filter(
|
||||
({ json_name }) => !pricingFieldNames.has(json_name),
|
||||
);
|
||||
|
||||
return (
|
||||
<GeneralFieldsGroup
|
||||
provider={provider}
|
||||
form={form}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={disabled}
|
||||
fields={getVisibleGeneralFields().filter(
|
||||
({ json_name }) => !pricingFieldNames.has(json_name),
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
{fields.map((field) => {
|
||||
// General field keys support nested json_name values, such as
|
||||
// cost.input_price_per_million_tokens.
|
||||
const camelName = field.json_name
|
||||
.split(".")
|
||||
.map(snakeToCamel)
|
||||
.join(".");
|
||||
const fieldKey = `config.${camelName}`;
|
||||
const label = snakeToPrettyLabel(field);
|
||||
|
||||
return (
|
||||
<InputField
|
||||
key={fieldKey}
|
||||
{...ctx}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={camelName}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
placeholderOverrides[field.json_name] ??
|
||||
placeholderForField(field)
|
||||
}
|
||||
suffix={fieldSuffix[field.json_name]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
import { useFormik } from "formik";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InfoIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import { BackButton } from "../BackButton";
|
||||
import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { readOptionalString } from "./helpers";
|
||||
import {
|
||||
GeneralModelConfigFields,
|
||||
ModelConfigFields,
|
||||
PricingModelConfigFields,
|
||||
} from "./ModelConfigFields";
|
||||
import { ModelIdentifierField } from "./ModelIdentifierField";
|
||||
import {
|
||||
buildInitialModelFormValues,
|
||||
buildModelConfigFromForm,
|
||||
type ModelFormValues,
|
||||
parsePositiveInteger,
|
||||
parseThresholdInteger,
|
||||
} from "./modelConfigFormLogic";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
model: Yup.string().trim().required("Model ID is required."),
|
||||
displayName: Yup.string(),
|
||||
enabled: Yup.boolean(),
|
||||
contextLimit: Yup.string()
|
||||
.required("Context limit is required.")
|
||||
.test(
|
||||
"positive-integer",
|
||||
"Context limit must be a positive integer.",
|
||||
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
|
||||
),
|
||||
compressionThreshold: Yup.string().test(
|
||||
"threshold-range",
|
||||
"Compression threshold must be a number between 0 and 100.",
|
||||
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
|
||||
),
|
||||
isDefault: Yup.boolean(),
|
||||
});
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
|
||||
interface ModelFormProps {
|
||||
/** When set, the form is in "edit" mode for the given model. */
|
||||
editingModel?: TypesGen.ChatModelConfig;
|
||||
/** When set without editingModel, the form creates from this model. */
|
||||
duplicateSourceModel?: TypesGen.ChatModelConfig;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProvider: string | null;
|
||||
selectedProviderState: ProviderState | null;
|
||||
onSelectedProviderChange: (provider: string) => void;
|
||||
modelConfigsUnavailable: boolean;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
onCreateModel: (
|
||||
req: TypesGen.CreateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onUpdateModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onCancel: () => void;
|
||||
onDeleteModel?: (modelConfigId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ModelForm: FC<ModelFormProps> = ({
|
||||
editingModel,
|
||||
duplicateSourceModel,
|
||||
providerStates,
|
||||
selectedProvider,
|
||||
selectedProviderState,
|
||||
onSelectedProviderChange,
|
||||
modelConfigsUnavailable,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
onCreateModel,
|
||||
onUpdateModel,
|
||||
onCancel,
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const initialModel = editingModel ?? duplicateSourceModel;
|
||||
const isEditing = Boolean(editingModel);
|
||||
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
|
||||
const initialValues = {
|
||||
...buildInitialModelFormValues(initialModel),
|
||||
...(isDuplicating && { isDefault: false }),
|
||||
};
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showPricing, setShowPricing] = useState(false);
|
||||
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const canManageModels = Boolean(
|
||||
selectedProviderState?.providerConfig &&
|
||||
(selectedProviderState.hasEffectiveAPIKey ||
|
||||
selectedProviderState.providerConfig.allow_user_api_key),
|
||||
);
|
||||
const formTitle = isEditing
|
||||
? "Edit model"
|
||||
: isDuplicating
|
||||
? "Duplicate model"
|
||||
: "Add model";
|
||||
const formDescription = isDuplicating
|
||||
? "Review the copied settings, then save to create a new model."
|
||||
: undefined;
|
||||
const mode: "add" | "edit" | "duplicate" = (() => {
|
||||
if (isEditing) return "edit";
|
||||
if (isDuplicating) return "duplicate";
|
||||
return "add";
|
||||
})();
|
||||
|
||||
const selectedProviderType =
|
||||
selectedProviderState?.provider ?? selectedProvider;
|
||||
|
||||
const form = useFormik<ModelFormValues>({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
validateOnMount: true,
|
||||
validateOnBlur: false,
|
||||
onSubmit: async (values) => {
|
||||
if (isSaving) return;
|
||||
|
||||
const trimmedModel = values.model.trim();
|
||||
if (!trimmedModel) return;
|
||||
|
||||
const parsedContextLimit = parsePositiveInteger(values.contextLimit);
|
||||
const parsedCompressionThreshold = parseThresholdInteger(
|
||||
values.compressionThreshold,
|
||||
);
|
||||
|
||||
const buildResult = buildModelConfigFromForm(
|
||||
selectedProviderType,
|
||||
values.config,
|
||||
);
|
||||
if (Object.keys(buildResult.fieldErrors).length > 0) return;
|
||||
|
||||
const trimmedDisplayName = values.displayName.trim();
|
||||
const builtModelConfig = buildResult.modelConfig;
|
||||
|
||||
const selectedProviderConfigID =
|
||||
selectedProviderState?.providerConfig?.id;
|
||||
|
||||
if (isEditing && editingModel) {
|
||||
const req: TypesGen.UpdateChatModelConfigRequest = {
|
||||
...(selectedProviderConfigID &&
|
||||
selectedProviderConfigID !==
|
||||
readOptionalString(editingModel.ai_provider_id) && {
|
||||
provider: selectedProviderState.provider,
|
||||
ai_provider_id: selectedProviderConfigID,
|
||||
}),
|
||||
...(trimmedModel !== editingModel.model && {
|
||||
model: trimmedModel,
|
||||
}),
|
||||
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(values.enabled !== editingModel.enabled && {
|
||||
enabled: values.enabled,
|
||||
}),
|
||||
...(parsedContextLimit !== null &&
|
||||
parsedContextLimit !== editingModel.context_limit && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null &&
|
||||
parsedCompressionThreshold !==
|
||||
editingModel.compression_threshold && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(values.isDefault !== editingModel.is_default && {
|
||||
is_default: values.isDefault,
|
||||
}),
|
||||
// Always send model_config so it can be cleared or updated.
|
||||
model_config: builtModelConfig,
|
||||
};
|
||||
|
||||
await onUpdateModel(editingModel.id, req);
|
||||
} else {
|
||||
if (!selectedProvider || !selectedProviderState?.providerConfig) return;
|
||||
|
||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: selectedProviderState.provider,
|
||||
ai_provider_id: selectedProviderState.providerConfig.id,
|
||||
model: trimmedModel,
|
||||
enabled: values.enabled,
|
||||
is_default: values.isDefault,
|
||||
...(parsedContextLimit !== null && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(builtModelConfig && {
|
||||
model_config: builtModelConfig,
|
||||
}),
|
||||
};
|
||||
|
||||
await onCreateModel(req);
|
||||
}
|
||||
// Navigation is handled by the parent (ModelsSection) after
|
||||
// the mutation promise resolves, so we do not call onCancel()
|
||||
// here to avoid a double view-transition.
|
||||
},
|
||||
});
|
||||
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
|
||||
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
||||
selectedProviderType,
|
||||
form.values.config,
|
||||
);
|
||||
|
||||
const hasFieldErrors =
|
||||
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
|
||||
const defaultModelDisableGuard =
|
||||
isEditing && form.values.isDefault && form.values.enabled;
|
||||
|
||||
// ── Provider select (shared across all form states) ───────
|
||||
|
||||
const providerSelect = (
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor="providerSelect"
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Provider
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedProvider ?? ""}
|
||||
onValueChange={onSelectedProviderChange}
|
||||
disabled={
|
||||
((isEditing || isDuplicating) && selectedProviderState !== null) ||
|
||||
providerStates.length === 0
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="providerSelect"
|
||||
className="h-10 max-w-[240px] text-[13px]"
|
||||
>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerStates.map((ps) => (
|
||||
<SelectItem key={ps.key} value={ps.key}>
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon provider={ps.provider} className="size-4" />
|
||||
{ps.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// No provider selected or configs unavailable.
|
||||
if (!selectedProviderState || modelConfigsUnavailable) {
|
||||
return (
|
||||
<div>
|
||||
<BackButton onClick={onCancel} />
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{formTitle}
|
||||
</h2>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">{providerSelect}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider can't manage models.
|
||||
if (!canManageModels && !isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<BackButton onClick={onCancel} />
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{formTitle}
|
||||
</h2>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">
|
||||
{providerSelect}
|
||||
<p className="text-sm text-content-secondary">
|
||||
{!selectedProviderState.providerConfig
|
||||
? "Create a managed provider config on the Providers tab before managing models."
|
||||
: "Set an API key for this provider on the Providers tab before managing models."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full form ─────────────────────────────────────────────
|
||||
|
||||
const modelField = getFieldHelpers("model");
|
||||
const contextLimitField = getFieldHelpers("contextLimit");
|
||||
const compressionThresholdField = getFieldHelpers("compressionThreshold");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
{/* Back */}
|
||||
<BackButton onClick={onCancel} />
|
||||
<div className="mb-4">
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{formTitle}
|
||||
</h2>
|
||||
{formDescription && (
|
||||
<p className="m-0 mt-1 text-sm text-content-secondary">
|
||||
{formDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Header - editable display name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProviderState && (
|
||||
<ProviderIcon
|
||||
provider={selectedProviderState.provider}
|
||||
className="size-8"
|
||||
/>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div className="relative inline-grid">
|
||||
<span
|
||||
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{form.values.displayName || initialModel?.model || "Model name"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
{...form.getFieldProps("displayName")}
|
||||
disabled={isSaving}
|
||||
spellCheck={false}
|
||||
className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
|
||||
placeholder={initialModel?.model ?? "Model name"}
|
||||
/>
|
||||
</div>
|
||||
<PencilIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</div>{" "}
|
||||
{initialModel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="ml-auto inline-flex">
|
||||
<Switch
|
||||
checked={form.values.enabled}
|
||||
onCheckedChange={(v) => {
|
||||
form.setFieldValue("enabled", v);
|
||||
}}
|
||||
aria-label="Enabled"
|
||||
disabled={isSaving || defaultModelDisableGuard}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{defaultModelDisableGuard
|
||||
? "Default model cannot be disabled. Remove default status first."
|
||||
: form.values.enabled
|
||||
? "Disable this model. It will be hidden from users."
|
||||
: "Enable this model. It will be visible to users."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
{/* Form body */}
|
||||
<form
|
||||
className="flex flex-1 flex-col"
|
||||
onSubmit={form.handleSubmit}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Model ID + Context limit + Pricing */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
{" "}
|
||||
<ModelIdentifierField
|
||||
form={form}
|
||||
modelField={modelField}
|
||||
mode={mode}
|
||||
selectedProvider={selectedProviderType}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
|
||||
>
|
||||
Context limit{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
Max tokens in the context window.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-9",
|
||||
contextLimitField.error && "border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={contextLimitField.id}
|
||||
name={contextLimitField.name}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="200000"
|
||||
value={contextLimitField.value}
|
||||
onChange={contextLimitField.onChange}
|
||||
onBlur={contextLimitField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={contextLimitField.error}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">
|
||||
tokens
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>{" "}
|
||||
{contextLimitField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{contextLimitField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost tracking */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPricing((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Cost tracking
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Set per-token pricing so Coder can track costs and enforce
|
||||
spending limits.
|
||||
</p>
|
||||
</div>
|
||||
{showPricing ? (
|
||||
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</button>
|
||||
{showPricing && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-4">
|
||||
<PricingModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider configuration */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProviderConfig((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Provider configuration
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Tune provider-specific behavior like reasoning, tool calling,
|
||||
and web search.
|
||||
</p>
|
||||
</div>
|
||||
{showProviderConfig ? (
|
||||
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</button>
|
||||
{showProviderConfig && (
|
||||
<div className="pt-3">
|
||||
<ModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Advanced
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Low-level parameters like temperature and penalties. Rarely
|
||||
need changing.
|
||||
</p>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-3">
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Compression threshold
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
Percentage at which context is compressed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-9",
|
||||
compressionThresholdField.error &&
|
||||
"border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className="h-9 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">%</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto py-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing && editingModel && onDeleteModel ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
||||
{isEditing
|
||||
? "Save"
|
||||
: isDuplicating
|
||||
? "Create duplicate"
|
||||
: "Add model"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{editingModel && onDeleteModel && (
|
||||
<ConfirmDeleteDialog
|
||||
entity="model"
|
||||
onConfirm={() => void onDeleteModel(editingModel.id)}
|
||||
isPending={isDeleting}
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
/>
|
||||
)}{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FormikContextType } from "formik";
|
||||
import { CheckIcon, InfoIcon } from "lucide-react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
@@ -10,14 +10,9 @@ import {
|
||||
import { Autocomplete } from "#/components/Autocomplete/Autocomplete";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { normalizeProvider } from "#/modules/aiModels/helpers";
|
||||
import { cn } from "#/utils/cn";
|
||||
import type { FormHelpers } from "#/utils/formUtils";
|
||||
import { normalizeProvider } from "./helpers";
|
||||
import {
|
||||
findKnownModelByCanonicalId,
|
||||
findKnownModelByExactAlias,
|
||||
@@ -37,6 +32,7 @@ type ModelIdentifierFieldProps = {
|
||||
mode: ModelFormMode;
|
||||
selectedProvider: string | null;
|
||||
disabled: boolean;
|
||||
controlClassName?: string;
|
||||
};
|
||||
|
||||
type ModelIdentifierOption = {
|
||||
@@ -69,6 +65,7 @@ export const ModelIdentifierField = ({
|
||||
mode,
|
||||
selectedProvider,
|
||||
disabled,
|
||||
controlClassName,
|
||||
}: ModelIdentifierFieldProps) => {
|
||||
const [initialFormValues] = useState(() => form.initialValues);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -383,7 +380,8 @@ export const ModelIdentifierField = ({
|
||||
id={modelField.id}
|
||||
name={modelField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
"placeholder:text-content-disabled",
|
||||
controlClassName,
|
||||
hasError && "border-content-destructive",
|
||||
)}
|
||||
placeholder="e.g. gpt-5, claude-sonnet-4-5"
|
||||
@@ -437,7 +435,8 @@ export const ModelIdentifierField = ({
|
||||
onEnterEmpty={() => handleOpenChange(false)}
|
||||
placeholder="e.g. gpt-5, claude-sonnet-4-5"
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
"placeholder:text-content-disabled",
|
||||
controlClassName,
|
||||
hasError && "border-content-destructive",
|
||||
)}
|
||||
triggerAriaInvalid={hasError}
|
||||
@@ -457,19 +456,14 @@ export const ModelIdentifierField = ({
|
||||
>
|
||||
<Label
|
||||
htmlFor={modelField.id}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
Model identifier{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">*</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
The model identifier sent to the provider API.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
The model identifier sent to the provider API.
|
||||
</p>
|
||||
{renderControl()}
|
||||
{hasError && (
|
||||
<p id={errorId} className="m-0 text-xs text-content-destructive">
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
|
||||
import {
|
||||
MockChatModelConfig,
|
||||
MockChatProviderConfig,
|
||||
} from "#/testHelpers/chatModels";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { ModelsSection } from "./ModelsSection";
|
||||
|
||||
const providerState: ProviderState = {
|
||||
key: "provider-config-id",
|
||||
provider: "openai",
|
||||
label: "OpenAI",
|
||||
providerConfig: {
|
||||
...MockChatProviderConfig,
|
||||
id: "provider-config-id",
|
||||
allow_central_api_key_fallback: false,
|
||||
base_url: undefined,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
modelConfigs: [],
|
||||
catalogModelCount: 0,
|
||||
hasManagedAPIKey: true,
|
||||
hasCatalogAPIKey: true,
|
||||
hasEffectiveAPIKey: true,
|
||||
allowUserAPIKey: false,
|
||||
isEnvPreset: false,
|
||||
baseURL: "",
|
||||
};
|
||||
|
||||
const providerStateWithoutAPIKey: ProviderState = {
|
||||
...providerState,
|
||||
providerConfig: {
|
||||
...providerState.providerConfig!,
|
||||
has_api_key: false,
|
||||
central_api_key_enabled: false,
|
||||
allow_user_api_key: false,
|
||||
},
|
||||
hasManagedAPIKey: false,
|
||||
hasCatalogAPIKey: false,
|
||||
hasEffectiveAPIKey: false,
|
||||
allowUserAPIKey: false,
|
||||
};
|
||||
|
||||
const baseModelConfig: TypesGen.ChatModelConfig = {
|
||||
...MockChatModelConfig,
|
||||
id: "model-config-id",
|
||||
model: "gpt-4.1",
|
||||
display_name: "GPT-4.1",
|
||||
context_limit: 128000,
|
||||
compression_threshold: 80,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const disabledModelConfig: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "disabled-model-config-id",
|
||||
model: "gpt-4.1-disabled",
|
||||
display_name: "GPT-4.1 Disabled",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const defaultModelConfig: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "default-model-config-id",
|
||||
model: "gpt-4o",
|
||||
display_name: "GPT-4o",
|
||||
is_default: true,
|
||||
};
|
||||
|
||||
const duplicateSourceModel: TypesGen.ChatModelConfig = {
|
||||
...baseModelConfig,
|
||||
id: "duplicate-source-model-id",
|
||||
model: "gpt-4.1-default",
|
||||
display_name: "GPT-4.1 Default",
|
||||
is_default: true,
|
||||
context_limit: 200000,
|
||||
compression_threshold: 65,
|
||||
model_config: {
|
||||
max_output_tokens: 4096,
|
||||
provider_options: {
|
||||
openai: {
|
||||
max_tool_calls: 4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ModelsSection> = {
|
||||
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
|
||||
component: ModelsSection,
|
||||
args: {
|
||||
sectionLabel: "Models",
|
||||
providerStates: [providerState],
|
||||
modelConfigs: [baseModelConfig],
|
||||
modelConfigsUnavailable: false,
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
onDeleteModel: fn(async () => undefined),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipProvider>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ModelsSection>;
|
||||
|
||||
export const ShowsPricingWarning: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("Model pricing is not defined"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const HidesPricingWarningForExplicitZeroPricing: Story = {
|
||||
args: {
|
||||
modelConfigs: [
|
||||
{
|
||||
...baseModelConfig,
|
||||
id: "model-config-id-zero-pricing",
|
||||
model_config: {
|
||||
cost: {
|
||||
output_price_per_million_tokens: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.queryByText("Model pricing is not defined"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LinksToProvidersFromEmptyState: Story = {
|
||||
args: {
|
||||
providerStates: [providerStateWithoutAPIKey],
|
||||
modelConfigs: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const providerLink = canvas.getByRole("link", { name: /provider/i });
|
||||
|
||||
await expect(canvas.getByText("No models configured yet.")).toBeVisible();
|
||||
await expect(providerLink).toBeVisible();
|
||||
expect(providerLink).toHaveAttribute("href", "/ai/settings");
|
||||
},
|
||||
};
|
||||
|
||||
export const ShowsExplicitRowActions: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const rowButton = canvas.getByRole("button", {
|
||||
name: "Open model: GPT-4.1",
|
||||
});
|
||||
const starButton = canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1",
|
||||
});
|
||||
const editButton = canvas.getByRole("button", {
|
||||
name: "Edit model: GPT-4.1",
|
||||
});
|
||||
const copyButton = canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1",
|
||||
});
|
||||
|
||||
await expect(starButton).toBeVisible();
|
||||
await expect(editButton).toBeVisible();
|
||||
await expect(copyButton).toBeVisible();
|
||||
rowButton.focus();
|
||||
await expect(rowButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(starButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(editButton).toHaveFocus();
|
||||
await user.tab();
|
||||
await expect(copyButton).toHaveFocus();
|
||||
},
|
||||
};
|
||||
|
||||
export const OpensDuplicateFormWithoutCreating: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Default",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
expect(canvas.getByDisplayValue("GPT-4.1 Default")).toBeVisible();
|
||||
expect(canvas.getByLabelText(/Model identifier/)).toHaveValue(
|
||||
"gpt-4.1-default",
|
||||
);
|
||||
expect(canvas.getByLabelText(/Context limit/)).toHaveValue("200000");
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: /Advanced/ }));
|
||||
expect(canvas.getByLabelText(/Compression threshold/)).toHaveValue("65");
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /Provider configuration/ }),
|
||||
);
|
||||
expect(canvas.getByLabelText("Max Tool Calls")).toHaveValue("4");
|
||||
},
|
||||
};
|
||||
|
||||
export const AbandonsDuplicateWithoutSaving: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const copyButtonName = "Duplicate model: GPT-4.1 Default";
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
|
||||
await expect(
|
||||
canvas.findByRole("button", { name: copyButtonName }),
|
||||
).resolves.toBeVisible();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
|
||||
await expect(
|
||||
canvas.findByRole("button", { name: copyButtonName }),
|
||||
).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesDuplicateAsCreateRequest: Story = {
|
||||
args: {
|
||||
modelConfigs: [duplicateSourceModel],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Default",
|
||||
}),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-copy");
|
||||
const displayNameInput = canvas.getByDisplayValue("GPT-4.1 Default");
|
||||
await userEvent.clear(displayNameInput);
|
||||
await userEvent.type(displayNameInput, "GPT-4.1 Copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
const createReq = createModelMock.mock.calls[0]?.[0];
|
||||
if (!createReq) {
|
||||
throw new Error("Expected create request.");
|
||||
}
|
||||
expect(createReq).toEqual({
|
||||
ai_provider_id: "provider-config-id",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-copy",
|
||||
display_name: "GPT-4.1 Copy",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 200000,
|
||||
compression_threshold: 65,
|
||||
model_config: {
|
||||
max_output_tokens: 4096,
|
||||
provider_options: {
|
||||
openai: {
|
||||
max_tool_calls: 4,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesNonDefaultDuplicateWithEditableEnabled: Story = {
|
||||
args: {
|
||||
modelConfigs: [baseModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
await userEvent.click(enabledSwitch);
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
expect(args.onUpdateModel).not.toHaveBeenCalled();
|
||||
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
|
||||
ai_provider_id: "provider-config-id",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-copy",
|
||||
display_name: "GPT-4.1",
|
||||
enabled: false,
|
||||
is_default: false,
|
||||
context_limit: 128000,
|
||||
compression_threshold: 80,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SavesDisabledDuplicateWithEditableEnabled: Story = {
|
||||
args: {
|
||||
modelConfigs: [disabledModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1 Disabled",
|
||||
}),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
|
||||
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
|
||||
expect(enabledSwitch).not.toBeChecked();
|
||||
expect(enabledSwitch).toBeEnabled();
|
||||
await userEvent.click(enabledSwitch);
|
||||
|
||||
const modelInput = canvas.getByLabelText(/Model identifier/);
|
||||
await userEvent.clear(modelInput);
|
||||
await userEvent.type(modelInput, "gpt-4.1-disabled-copy");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Create duplicate" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
|
||||
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
|
||||
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
|
||||
ai_provider_id: "provider-config-id",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-disabled-copy",
|
||||
display_name: "GPT-4.1 Disabled",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
context_limit: 128000,
|
||||
compression_threshold: 80,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DisablesDuplicateWhenProviderCannotManageModels: Story = {
|
||||
args: {
|
||||
providerStates: [providerStateWithoutAPIKey],
|
||||
modelConfigs: [baseModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const duplicateButton = canvas.getByRole("button", {
|
||||
name: "Duplicate model: GPT-4.1",
|
||||
});
|
||||
|
||||
expect(duplicateButton).toHaveAttribute("aria-disabled", "true");
|
||||
await userEvent.click(duplicateButton);
|
||||
expect(canvas.queryByText("Duplicate model")).not.toBeInTheDocument();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const RowActionsDoNotOpenRowBody: Story = {
|
||||
args: {
|
||||
modelConfigs: [baseModelConfig, defaultModelConfig, disabledModelConfig],
|
||||
onCreateModel: fn(async () => undefined),
|
||||
onUpdateModel: fn(async () => undefined),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const updateModelMock = args.onUpdateModel as ReturnType<typeof fn>;
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(args.onUpdateModel).toHaveBeenCalledTimes(1));
|
||||
expect(updateModelMock.mock.calls[0]).toEqual([
|
||||
"model-config-id",
|
||||
{ is_default: true },
|
||||
]);
|
||||
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Default model: GPT-4o" }),
|
||||
);
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
|
||||
|
||||
const disabledStarButton = canvas.getByRole("button", {
|
||||
name: "Set as default model: GPT-4.1 Disabled",
|
||||
});
|
||||
expect(disabledStarButton).toHaveAttribute("aria-disabled", "true");
|
||||
await userEvent.click(disabledStarButton);
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
|
||||
expect(args.onCreateModel).not.toHaveBeenCalled();
|
||||
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
|
||||
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Edit model: GPT-4.1" }),
|
||||
);
|
||||
await expect(canvas.findByText("Edit model")).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
@@ -1,471 +0,0 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { normalizeProvider, readOptionalString } from "./helpers";
|
||||
import { ModelForm } from "./ModelForm";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { hasCustomPricing } from "./pricingFields";
|
||||
|
||||
type ModelView =
|
||||
| { mode: "list" }
|
||||
| { mode: "add"; provider: string }
|
||||
| { mode: "edit"; model: TypesGen.ChatModelConfig }
|
||||
| { mode: "duplicate"; sourceModel: TypesGen.ChatModelConfig };
|
||||
|
||||
const MODEL_VIEW_PARAMS = ["model", "newModel", "duplicate"] as const;
|
||||
type ModelViewParam = (typeof MODEL_VIEW_PARAMS)[number];
|
||||
|
||||
const clearModelViewParams = (params: URLSearchParams) => {
|
||||
for (const param of MODEL_VIEW_PARAMS) {
|
||||
params.delete(param);
|
||||
}
|
||||
};
|
||||
|
||||
const modelConfigProviderKey = (
|
||||
modelConfig: TypesGen.ChatModelConfig,
|
||||
providerStates: readonly ProviderState[],
|
||||
): string => {
|
||||
const providerID = readOptionalString(modelConfig.ai_provider_id);
|
||||
if (providerID) {
|
||||
return providerID;
|
||||
}
|
||||
|
||||
const provider = normalizeProvider(modelConfig.provider);
|
||||
const providerMatches = providerStates.filter(
|
||||
(providerState) => providerState.provider === provider,
|
||||
);
|
||||
if (providerMatches.length === 1) {
|
||||
return providerMatches[0].key;
|
||||
}
|
||||
if (providerMatches.length > 1) {
|
||||
return "";
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
const canManageProviderModels = (providerState: ProviderState | undefined) => {
|
||||
return Boolean(
|
||||
providerState?.providerConfig &&
|
||||
(providerState.hasEffectiveAPIKey ||
|
||||
providerState.providerConfig.allow_user_api_key),
|
||||
);
|
||||
};
|
||||
|
||||
interface ModelsSectionProps {
|
||||
sectionLabel?: string;
|
||||
sectionDescription?: string;
|
||||
providerStates: readonly ProviderState[];
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
modelConfigsUnavailable: boolean;
|
||||
isCreating: boolean;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
onCreateModel: (
|
||||
req: TypesGen.CreateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onUpdateModel: (
|
||||
modelConfigId: string,
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteModel: (modelConfigId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
sectionLabel,
|
||||
sectionDescription,
|
||||
providerStates,
|
||||
modelConfigs,
|
||||
modelConfigsUnavailable,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
onCreateModel,
|
||||
onUpdateModel,
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedProviderOverride, setSelectedProviderOverride] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Derive the current view from URL search params so that
|
||||
// browser back/forward navigation works as expected.
|
||||
const view: ModelView = (() => {
|
||||
const editModelId = searchParams.get("model");
|
||||
if (editModelId) {
|
||||
const model = modelConfigs.find((m) => m.id === editModelId);
|
||||
return model ? { mode: "edit", model } : { mode: "list" };
|
||||
}
|
||||
const duplicateModelId = searchParams.get("duplicate");
|
||||
if (duplicateModelId) {
|
||||
const sourceModel = modelConfigs.find((m) => m.id === duplicateModelId);
|
||||
return sourceModel
|
||||
? { mode: "duplicate", sourceModel }
|
||||
: { mode: "list" };
|
||||
}
|
||||
const addProvider = searchParams.get("newModel");
|
||||
if (addProvider) {
|
||||
return { mode: "add", provider: addProvider };
|
||||
}
|
||||
return { mode: "list" };
|
||||
})();
|
||||
|
||||
const setModelViewParam = (
|
||||
param: ModelViewParam,
|
||||
value: string,
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
clearModelViewParams(nextParams);
|
||||
nextParams.set(param, value);
|
||||
setSearchParams(nextParams, {
|
||||
replace: options?.replace,
|
||||
state: options?.replace ? location.state : { pushed: true },
|
||||
});
|
||||
};
|
||||
const modelViewIdentity = (() => {
|
||||
switch (view.mode) {
|
||||
case "add":
|
||||
return `add:${view.provider}`;
|
||||
case "edit":
|
||||
return `edit:${view.model.id}`;
|
||||
case "duplicate":
|
||||
return `duplicate:${view.sourceModel.id}`;
|
||||
default:
|
||||
return "list";
|
||||
}
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
void modelViewIdentity;
|
||||
setSelectedProviderOverride(null);
|
||||
}, [modelViewIdentity]);
|
||||
|
||||
// Clear model-related search params and return to the list.
|
||||
const clearModelView = () => {
|
||||
setSelectedProviderOverride(null);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const exitModelView = () => {
|
||||
setSelectedProviderOverride(null);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
clearModelViewParams(next);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
// When the form is open it takes over the full panel.
|
||||
if (
|
||||
view.mode === "add" ||
|
||||
view.mode === "edit" ||
|
||||
view.mode === "duplicate"
|
||||
) {
|
||||
const editingModel = view.mode === "edit" ? view.model : undefined;
|
||||
const duplicateSourceModel =
|
||||
view.mode === "duplicate" ? view.sourceModel : undefined;
|
||||
const effectiveProvider =
|
||||
selectedProviderOverride ??
|
||||
(view.mode === "edit"
|
||||
? modelConfigProviderKey(view.model, providerStates)
|
||||
: view.mode === "duplicate"
|
||||
? modelConfigProviderKey(view.sourceModel, providerStates)
|
||||
: view.provider);
|
||||
const effectiveProviderState =
|
||||
providerStates.find((ps) => ps.key === effectiveProvider) ?? null;
|
||||
const formKey =
|
||||
view.mode === "edit"
|
||||
? `edit:${view.model.id}`
|
||||
: view.mode === "duplicate"
|
||||
? `duplicate:${view.sourceModel.id}`
|
||||
: `add:${view.provider}`;
|
||||
|
||||
return (
|
||||
<ModelForm
|
||||
key={formKey}
|
||||
editingModel={editingModel}
|
||||
duplicateSourceModel={duplicateSourceModel}
|
||||
providerStates={providerStates}
|
||||
selectedProvider={effectiveProvider}
|
||||
selectedProviderState={effectiveProviderState}
|
||||
onSelectedProviderChange={(provider) => {
|
||||
if (view.mode === "add") {
|
||||
setModelViewParam("newModel", provider, { replace: true });
|
||||
return;
|
||||
}
|
||||
setSelectedProviderOverride(provider);
|
||||
}}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isSaving={isCreating || isUpdating}
|
||||
isDeleting={isDeleting}
|
||||
onCreateModel={async (req) => {
|
||||
await onCreateModel(req);
|
||||
exitModelView();
|
||||
}}
|
||||
onUpdateModel={async (id, req) => {
|
||||
await onUpdateModel(id, req);
|
||||
clearModelView();
|
||||
}}
|
||||
onCancel={clearModelView}
|
||||
onDeleteModel={
|
||||
editingModel
|
||||
? async (id) => {
|
||||
await onDeleteModel(id);
|
||||
exitModelView();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ──────────────────────────────────────────────
|
||||
|
||||
// Only show providers that have a deployment key configured or allow
|
||||
// end users to bring their own key.
|
||||
const addableProviders = providerStates.filter(canManageProviderModels);
|
||||
|
||||
const addButton = addableProviders.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="gap-1.5" aria-label="Add model">
|
||||
<PlusIcon className="size-4" />
|
||||
Add
|
||||
<ChevronDownIcon className="size-3.5 text-content-secondary" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{addableProviders.map((ps) => (
|
||||
<DropdownMenuItem
|
||||
key={ps.key}
|
||||
onClick={() => {
|
||||
setModelViewParam("newModel", ps.key);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<ProviderIcon provider={ps.provider} className="size-5" />
|
||||
{ps.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
|
||||
if (isUpdating || modelConfig.is_default || !modelConfig.enabled) return;
|
||||
void onUpdateModel(modelConfig.id, { is_default: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{sectionLabel && (
|
||||
<SectionHeader
|
||||
label={sectionLabel}
|
||||
description={
|
||||
sectionDescription ?? "Manage models available to Agents."
|
||||
}
|
||||
action={addButton || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modelConfigs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
No models configured yet.
|
||||
</p>
|
||||
{addableProviders.length > 0 && addButton}
|
||||
{addableProviders.length === 0 && (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Connect a{" "}
|
||||
<Link
|
||||
to="/ai/settings"
|
||||
className="underline transition-colors hover:text-content-primary"
|
||||
>
|
||||
provider
|
||||
</Link>{" "}
|
||||
first to add models.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{modelConfigs.map((modelConfig, i) => {
|
||||
const showPricingWarning = !hasCustomPricing(
|
||||
modelConfig.model_config,
|
||||
);
|
||||
const modelName = modelConfig.display_name || modelConfig.model;
|
||||
const starLabel = modelConfig.is_default
|
||||
? `Default model: ${modelName}`
|
||||
: `Set as default model: ${modelName}`;
|
||||
const starUnavailable =
|
||||
isUpdating || modelConfig.is_default || !modelConfig.enabled;
|
||||
const providerState = providerStates.find(
|
||||
(ps) =>
|
||||
ps.key === modelConfigProviderKey(modelConfig, providerStates),
|
||||
);
|
||||
const duplicateUnavailable = Boolean(
|
||||
providerState && !canManageProviderModels(providerState),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelConfig.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3.5 px-3 py-3 transition-colors hover:bg-surface-secondary/30",
|
||||
i > 0 && "border-0 border-t border-solid border-border/50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModelViewParam("model", modelConfig.id)}
|
||||
aria-label={`Open model: ${modelName}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={modelConfig.provider}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[15px] font-medium",
|
||||
modelConfig.enabled === false
|
||||
? "text-content-secondary"
|
||||
: "text-content-primary",
|
||||
)}
|
||||
>
|
||||
{modelName}
|
||||
</span>
|
||||
{showPricingWarning && (
|
||||
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
||||
<TriangleAlertIcon className="size-3.5 shrink-0" />
|
||||
Model pricing is not defined
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="xs" variant="warning">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
aria-disabled={starUnavailable}
|
||||
aria-label={starLabel}
|
||||
className={cn(
|
||||
"hover:bg-surface-secondary",
|
||||
starUnavailable &&
|
||||
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
||||
modelConfig.is_default && "text-content-primary",
|
||||
)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
modelConfig.is_default && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{!modelConfig.enabled
|
||||
? "Cannot set a disabled model as default"
|
||||
: modelConfig.is_default
|
||||
? "Default for new conversations"
|
||||
: "Set as default for new conversations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setModelViewParam("model", modelConfig.id);
|
||||
}}
|
||||
aria-label={`Edit model: ${modelName}`}
|
||||
className="hover:bg-surface-secondary"
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit model</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (duplicateUnavailable) return;
|
||||
setModelViewParam("duplicate", modelConfig.id);
|
||||
}}
|
||||
aria-disabled={duplicateUnavailable}
|
||||
aria-label={`Duplicate model: ${modelName}`}
|
||||
className={cn(
|
||||
"hover:bg-surface-secondary",
|
||||
duplicateUnavailable &&
|
||||
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
||||
)}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{duplicateUnavailable
|
||||
? "Set an API key for this provider before duplicating models"
|
||||
: "Duplicate model"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
interface ProviderFieldProps {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProviderField: FC<ProviderFieldProps> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
description,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{required && (
|
||||
<span className="text-xs font-bold text-content-destructive">*</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,413 +0,0 @@
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import { BackButton } from "../BackButton";
|
||||
import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
|
||||
import type {
|
||||
CreateProviderResult,
|
||||
ProviderState,
|
||||
} from "./ChatModelAdminPanel";
|
||||
import { getProviderBaseURLPlaceholder, readOptionalString } from "./helpers";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
// Sentinel value used to represent an existing API key that the
|
||||
// backend will not reveal. If the user has not touched the field,
|
||||
// we know nothing changed.
|
||||
const API_KEY_PLACEHOLDER = "••••••••••••••••";
|
||||
|
||||
interface ProviderFormProps {
|
||||
providerState: ProviderState;
|
||||
providerConfigsUnavailable: boolean;
|
||||
isProviderMutationPending: boolean;
|
||||
onCreateProvider: (
|
||||
req: TypesGen.CreateChatProviderConfigRequest,
|
||||
) => Promise<CreateProviderResult>;
|
||||
onUpdateProvider: (
|
||||
providerConfigId: string,
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
providerState,
|
||||
providerConfigsUnavailable,
|
||||
isProviderMutationPending,
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onBack,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
const baseURLInputId = useId();
|
||||
|
||||
const baseURLPlaceholder = getProviderBaseURLPlaceholder(provider);
|
||||
|
||||
// Initial values are snapshotted when the provider config changes
|
||||
// so we can detect dirty state.
|
||||
const [initialValues] = useState(() => ({
|
||||
displayName: readOptionalString(providerConfig?.display_name) ?? "",
|
||||
baseURL,
|
||||
}));
|
||||
|
||||
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
||||
const [apiKey, setApiKey] = useState(
|
||||
providerState.hasManagedAPIKey ? API_KEY_PLACEHOLDER : "",
|
||||
);
|
||||
const [apiKeyTouched, setApiKeyTouched] = useState(false);
|
||||
const [apiKeyModified, setApiKeyModified] = useState(false);
|
||||
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const isBedrockProvider = provider === "bedrock";
|
||||
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
|
||||
const requiresAPIKey =
|
||||
!providerState.allowUserAPIKey &&
|
||||
!isBedrockProvider &&
|
||||
!providerState.hasManagedAPIKey;
|
||||
|
||||
const effectiveApiKey =
|
||||
apiKeyTouched && apiKey !== API_KEY_PLACEHOLDER ? apiKey : "";
|
||||
const hasTypedAPIKey = effectiveApiKey.length > 0;
|
||||
const hasAPIKeyWhitespace =
|
||||
hasTypedAPIKey && effectiveApiKey.trim() !== effectiveApiKey;
|
||||
// Clearing a saved provider-scoped key switches the provider to
|
||||
// BYOK-only behavior, or ambient AWS credentials for Bedrock.
|
||||
const isClearingAPIKey =
|
||||
providerState.hasManagedAPIKey && apiKeyModified && effectiveApiKey === "";
|
||||
const hasPendingAPIKeyChange = hasTypedAPIKey || isClearingAPIKey;
|
||||
const shouldCreateAPIKey = hasTypedAPIKey;
|
||||
const apiKeyDescription = isBedrockProvider
|
||||
? "Bearer token for Bedrock authentication. Leave empty to use ambient AWS credentials."
|
||||
: "Secret key used to authenticate requests to this provider.";
|
||||
const baseURLDescription = isBedrockProvider
|
||||
? "Bedrock runtime endpoint. Use the AWS region for the models this provider should call."
|
||||
: "Endpoint used to call this provider.";
|
||||
const apiKeyPlaceholder = isBedrockProvider ? "Enter bearer token" : "sk-...";
|
||||
const deleteProviderDescription =
|
||||
"Are you sure you want to delete this provider? The provider will be " +
|
||||
"disabled and hidden from new model configuration. Existing model " +
|
||||
"configs that reference it remain saved but cannot run until updated.";
|
||||
const hasNewProviderConfiguration = !providerConfig;
|
||||
|
||||
const isDirty =
|
||||
displayName.trim() !== initialValues.displayName ||
|
||||
hasPendingAPIKeyChange ||
|
||||
baseURLValue.trim() !== initialValues.baseURL.trim() ||
|
||||
hasNewProviderConfiguration;
|
||||
|
||||
const hasBaseURL = baseURLValue.trim().length > 0;
|
||||
const canSave =
|
||||
!providerConfigsUnavailable &&
|
||||
!isProviderMutationPending &&
|
||||
!isAPIKeyEnvManaged &&
|
||||
isDirty &&
|
||||
hasBaseURL &&
|
||||
!hasAPIKeyWhitespace &&
|
||||
(!requiresAPIKey || hasTypedAPIKey);
|
||||
const canAddModel =
|
||||
Boolean(providerConfig) &&
|
||||
(providerState.hasEffectiveAPIKey ||
|
||||
providerConfig?.allow_user_api_key === true);
|
||||
|
||||
const handleAddModel = () => {
|
||||
const params = new URLSearchParams({ newModel: providerState.key });
|
||||
navigate(`/agents/settings/models?${params.toString()}`, {
|
||||
state: { pushed: true },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
providerConfigsUnavailable ||
|
||||
isProviderMutationPending ||
|
||||
isAPIKeyEnvManaged ||
|
||||
!hasBaseURL ||
|
||||
hasAPIKeyWhitespace
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresAPIKey && !hasTypedAPIKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const trimmedBaseURL = baseURLValue.trim();
|
||||
|
||||
if (providerConfig) {
|
||||
const currentDisplayName =
|
||||
readOptionalString(providerConfig.display_name) ?? "";
|
||||
const currentBaseURL = baseURL.trim();
|
||||
const req: TypesGen.UpdateChatProviderConfigRequest = {
|
||||
...(trimmedDisplayName !== currentDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(hasPendingAPIKeyChange && { api_key: effectiveApiKey }),
|
||||
...(trimmedBaseURL !== currentBaseURL && {
|
||||
base_url: trimmedBaseURL,
|
||||
}),
|
||||
};
|
||||
|
||||
if (Object.keys(req).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onUpdateProvider(providerConfig.id, req);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const req: TypesGen.CreateChatProviderConfigRequest = {
|
||||
provider,
|
||||
base_url: trimmedBaseURL,
|
||||
...(shouldCreateAPIKey && { api_key: effectiveApiKey }),
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
await onCreateProvider(req);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setApiKeyTouched(false);
|
||||
setApiKeyModified(false);
|
||||
setApiKey(API_KEY_PLACEHOLDER);
|
||||
};
|
||||
|
||||
const handleApiKeyFocus = () => {
|
||||
// Clear the placeholder on first focus so the user starts
|
||||
// with a blank field and Chrome does not try to autofill.
|
||||
if (!apiKeyTouched && apiKey === API_KEY_PLACEHOLDER) {
|
||||
setApiKey("");
|
||||
setApiKeyTouched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = providerConfigsUnavailable || isProviderMutationPending;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<BackButton onClick={onBack} />
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={provider} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName || formatProviderLabel(provider)}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
disabled={isDisabled || isAPIKeyEnvManaged}
|
||||
className="m-0 w-full border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
|
||||
placeholder={formatProviderLabel(provider)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-4 shrink-0 cursor-help text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Uses the {formatProviderLabel(provider)} API specification
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
{isAPIKeyEnvManaged ? (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>API key managed by environment variable</AlertTitle>
|
||||
<AlertDescription>
|
||||
This provider key is configured from deployment environment settings
|
||||
and cannot be edited in this UI.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<form
|
||||
className="flex flex-1 flex-col"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
autoComplete="off"
|
||||
data-form-type="other"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<ProviderField
|
||||
label="API Key"
|
||||
htmlFor={apiKeyInputId}
|
||||
required={requiresAPIKey}
|
||||
description={apiKeyDescription}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
id={apiKeyInputId}
|
||||
name="provider_api_token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
|
||||
placeholder={apiKeyPlaceholder}
|
||||
required={requiresAPIKey}
|
||||
value={apiKey}
|
||||
onFocus={handleApiKeyFocus}
|
||||
onChange={(event) => {
|
||||
setApiKey(event.target.value);
|
||||
setApiKeyTouched(true);
|
||||
setApiKeyModified(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
{hasAPIKeyWhitespace && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
API key must not contain leading or trailing whitespace.
|
||||
</p>
|
||||
)}
|
||||
{isBedrockProvider &&
|
||||
providerState.hasManagedAPIKey &&
|
||||
!isDisabled &&
|
||||
(!apiKeyModified || apiKey !== "") && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="appearance-none border-0 bg-transparent p-0 text-xs text-content-link hover:cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
setApiKey("");
|
||||
setApiKeyTouched(true);
|
||||
setApiKeyModified(true);
|
||||
}}
|
||||
>
|
||||
Clear stored token
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProviderField>
|
||||
|
||||
<ProviderField
|
||||
label="Base URL"
|
||||
htmlFor={baseURLInputId}
|
||||
description={baseURLDescription}
|
||||
>
|
||||
<Input
|
||||
id={baseURLInputId}
|
||||
name="provider_base_url"
|
||||
className="h-9 text-[13px]"
|
||||
placeholder={baseURLPlaceholder}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={baseURLValue}
|
||||
onChange={(event) => setBaseURLValue(event.target.value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</ProviderField>
|
||||
</div>
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
<div className="flex items-center justify-between">
|
||||
{providerConfig ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{canAddModel && (
|
||||
<Button size="lg" type="button" onClick={handleAddModel}>
|
||||
Add model
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
variant={canAddModel ? "outline" : undefined}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{providerConfig && (
|
||||
<ConfirmDeleteDialog
|
||||
entity="provider"
|
||||
description={deleteProviderDescription}
|
||||
onConfirm={() => void onDeleteProvider(providerConfig.id)}
|
||||
isPending={isProviderMutationPending}
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface ProviderFieldProps {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProviderField: FC<ProviderFieldProps> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
description,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{required && (
|
||||
<span className="text-xs font-bold text-content-destructive">*</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ServerIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import { normalizeProvider } from "#/modules/aiModels/helpers";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import { normalizeProvider } from "./helpers";
|
||||
|
||||
const providerIconMap: Record<string, string> = {
|
||||
openai: "/icon/openai.svg",
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import {
|
||||
type AIProviderType,
|
||||
AIProviderTypes,
|
||||
type CreateChatProviderConfigRequest,
|
||||
type UpdateChatProviderConfigRequest,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatProviderLabel } from "../../utils/modelOptions";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type {
|
||||
CreateProviderResult,
|
||||
ProviderState,
|
||||
} from "./ChatModelAdminPanel";
|
||||
import { getDefaultProviderBaseURL } from "./helpers";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
type ProviderView =
|
||||
| { mode: "list" }
|
||||
| { mode: "detail"; provider: string }
|
||||
| { mode: "new"; providerType: AIProviderType };
|
||||
|
||||
const providerTypeOptions = AIProviderTypes.map((providerType) => ({
|
||||
providerType,
|
||||
label: formatProviderLabel(providerType),
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const getAIProviderType = (
|
||||
value: string | null,
|
||||
): AIProviderType | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return AIProviderTypes.find((providerType) => providerType === value);
|
||||
};
|
||||
|
||||
const newProviderState = (providerType: AIProviderType): ProviderState => ({
|
||||
key: `new:${providerType}`,
|
||||
provider: providerType,
|
||||
label: formatProviderLabel(providerType),
|
||||
providerConfig: undefined,
|
||||
modelConfigs: [],
|
||||
catalogModelCount: 0,
|
||||
hasManagedAPIKey: false,
|
||||
hasCatalogAPIKey: false,
|
||||
hasEffectiveAPIKey: false,
|
||||
allowUserAPIKey: true,
|
||||
isEnvPreset: false,
|
||||
baseURL: getDefaultProviderBaseURL(providerType),
|
||||
});
|
||||
|
||||
interface ProvidersSectionProps {
|
||||
sectionLabel?: string;
|
||||
sectionDescription?: string;
|
||||
providerStates: readonly ProviderState[];
|
||||
providerConfigsUnavailable: boolean;
|
||||
isProviderMutationPending: boolean;
|
||||
onCreateProvider: (
|
||||
req: CreateChatProviderConfigRequest,
|
||||
) => Promise<CreateProviderResult>;
|
||||
onUpdateProvider: (
|
||||
providerConfigId: string,
|
||||
req: UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
sectionLabel,
|
||||
sectionDescription,
|
||||
providerStates,
|
||||
providerConfigsUnavailable,
|
||||
isProviderMutationPending,
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const canGoBack =
|
||||
(location.state as { pushed?: boolean } | null)?.pushed === true;
|
||||
|
||||
// Derive the current view from URL search params so that
|
||||
// browser back/forward navigation works as expected.
|
||||
const view: ProviderView = (() => {
|
||||
const providerParam = searchParams.get("provider");
|
||||
if (providerParam) {
|
||||
const exists = providerStates.some((ps) => ps.key === providerParam);
|
||||
return exists
|
||||
? { mode: "detail", provider: providerParam }
|
||||
: { mode: "list" };
|
||||
}
|
||||
|
||||
const newProviderType = getAIProviderType(searchParams.get("newProvider"));
|
||||
if (newProviderType) {
|
||||
return { mode: "new", providerType: newProviderType };
|
||||
}
|
||||
|
||||
return { mode: "list" };
|
||||
})();
|
||||
|
||||
// Clear provider search param and return to the list.
|
||||
const clearProviderView = () => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("provider");
|
||||
next.delete("newProvider");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const openNewProviderView = (providerType: AIProviderType) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("provider");
|
||||
next.set("newProvider", providerType);
|
||||
return next;
|
||||
},
|
||||
{ state: { pushed: true } },
|
||||
);
|
||||
};
|
||||
// Detail view.
|
||||
const detailProvider =
|
||||
view.mode === "detail"
|
||||
? providerStates.find((ps) => ps.key === view.provider)
|
||||
: view.mode === "new"
|
||||
? newProviderState(view.providerType)
|
||||
: undefined;
|
||||
|
||||
if ((view.mode === "detail" || view.mode === "new") && detailProvider) {
|
||||
const providerFormKey = [
|
||||
detailProvider.provider,
|
||||
detailProvider.providerConfig?.id ?? "new",
|
||||
detailProvider.providerConfig?.display_name ?? "",
|
||||
detailProvider.providerConfig?.base_url ?? detailProvider.baseURL,
|
||||
detailProvider.providerConfig?.central_api_key_enabled ?? true,
|
||||
detailProvider.providerConfig?.allow_user_api_key ?? false,
|
||||
detailProvider.providerConfig?.allow_central_api_key_fallback ?? false,
|
||||
detailProvider.providerConfig?.has_api_key ??
|
||||
detailProvider.hasManagedAPIKey,
|
||||
detailProvider.providerConfig?.updated_at ?? "",
|
||||
].join("|");
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
key={providerFormKey}
|
||||
providerState={detailProvider}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={async (req) => {
|
||||
const createdProvider = await onCreateProvider(req);
|
||||
if (createdProvider.id) {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("provider", createdProvider.id);
|
||||
next.delete("newProvider");
|
||||
return next;
|
||||
},
|
||||
{ replace: true, state: location.state },
|
||||
);
|
||||
}
|
||||
return createdProvider;
|
||||
}}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={async (id) => {
|
||||
await onDeleteProvider(id);
|
||||
if (canGoBack) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("provider");
|
||||
next.delete("newProvider");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBack={clearProviderView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// List view.
|
||||
const addProviderAction = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={providerConfigsUnavailable || isProviderMutationPending}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{providerTypeOptions.map(({ providerType, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={providerType}
|
||||
className="gap-2"
|
||||
onSelect={() => openNewProviderView(providerType)}
|
||||
>
|
||||
<ProviderIcon provider={providerType} className="size-5" />
|
||||
<span>{label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const header = sectionLabel ? (
|
||||
<SectionHeader
|
||||
label={sectionLabel}
|
||||
description={
|
||||
sectionDescription ?? "Configure AI providers to use with Agents."
|
||||
}
|
||||
action={addProviderAction}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{providerStates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-6 text-center text-[13px] text-content-secondary">
|
||||
No providers have been added yet.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{providerStates.map((providerState, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={providerState.key}
|
||||
aria-label={providerState.label}
|
||||
onClick={() => {
|
||||
setSearchParams(
|
||||
{ provider: providerState.key },
|
||||
{ state: { pushed: true } },
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 px-3 py-3 text-left transition-colors hover:bg-surface-secondary/30",
|
||||
i > 0 && "border-0 border-t border-solid border-border/50",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={providerState.provider}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[15px] font-medium text-content-primary text-left">
|
||||
{providerState.label}
|
||||
</span>
|
||||
{providerState.providerConfig?.allow_user_api_key && (
|
||||
<Badge size="xs" className="text-content-secondary">
|
||||
User keys enabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{providerState.hasEffectiveAPIKey ? (
|
||||
<CheckCircleIcon className="size-4 shrink-0 text-content-success" />
|
||||
) : (
|
||||
<CircleIcon className="size-4 shrink-0 text-content-secondary opacity-40" />
|
||||
)}
|
||||
<ChevronRightIcon className="size-5 shrink-0 text-content-secondary" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Reads a value as a non-empty string, returning undefined for
|
||||
* empty strings, null, or undefined values.
|
||||
*/
|
||||
export function readOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a provider name for case-insensitive comparison.
|
||||
*/
|
||||
export function normalizeProvider(provider: string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const canonicalProviderBaseURLs: Record<string, string> = {
|
||||
anthropic: "https://api.anthropic.com",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta",
|
||||
openai: "https://api.openai.com/v1",
|
||||
openrouter: "https://openrouter.ai/api/v1",
|
||||
vercel: "https://ai-gateway.vercel.sh/v1",
|
||||
};
|
||||
|
||||
export function getDefaultProviderBaseURL(provider: string): string {
|
||||
return canonicalProviderBaseURLs[normalizeProvider(provider)] ?? "";
|
||||
}
|
||||
|
||||
export function getProviderBaseURLPlaceholder(provider: string): string {
|
||||
switch (normalizeProvider(provider)) {
|
||||
case "azure":
|
||||
return "https://<resource-name>.openai.azure.com";
|
||||
case "bedrock":
|
||||
return "https://bedrock-runtime.<region>.amazonaws.com";
|
||||
case "openai-compat":
|
||||
return "https://api.example.com/v1";
|
||||
default:
|
||||
return getDefaultProviderBaseURL(provider) || "https://api.example.com";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeProvider } from "../helpers";
|
||||
import { normalizeProvider } from "#/modules/aiModels/helpers";
|
||||
import { anthropicKnownModels } from "./anthropic";
|
||||
import { openAIKnownModels } from "./openai";
|
||||
import type { KnownModel, KnownModelSourceMetadata } from "./types";
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FieldSchema } from "#/api/chatModelOptions";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockChatModelConfig } from "#/testHelpers/chatModels";
|
||||
import {
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
buildModelConfigFromForm,
|
||||
emptyModelConfigFormState,
|
||||
extractModelConfigFormState,
|
||||
hasFieldValue,
|
||||
isFieldConflictDisabled,
|
||||
type ModelConfigFormState,
|
||||
parsePositiveInteger,
|
||||
parseThresholdInteger,
|
||||
@@ -694,6 +697,50 @@ describe("buildModelConfigFromForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("visible_when gating", () => {
|
||||
it("drops gated sub-fields when the gating field is off", () => {
|
||||
const result = buildModelConfigFromForm(
|
||||
"openai",
|
||||
formWith({
|
||||
openai: {
|
||||
webSearchEnabled: "false",
|
||||
searchContextSize: "high",
|
||||
allowedDomains: '["example.com"]',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.fieldErrors).toEqual({});
|
||||
const openai = result.modelConfig?.provider_options?.openai as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openai).not.toHaveProperty("search_context_size");
|
||||
expect(openai).not.toHaveProperty("allowed_domains");
|
||||
expect(openai.web_search_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps gated sub-fields when the gating field is on", () => {
|
||||
const result = buildModelConfigFromForm(
|
||||
"openai",
|
||||
formWith({
|
||||
openai: {
|
||||
webSearchEnabled: "true",
|
||||
searchContextSize: "high",
|
||||
allowedDomains: '["example.com"]',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.fieldErrors).toEqual({});
|
||||
const openai = result.modelConfig?.provider_options?.openai as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openai.web_search_enabled).toBe(true);
|
||||
expect(openai.search_context_size).toBe("high");
|
||||
expect(openai.allowed_domains).toEqual(["example.com"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anthropic / Bedrock provider", () => {
|
||||
it("builds Anthropic provider options with effort", () => {
|
||||
const result = buildModelConfigFromForm(
|
||||
@@ -1177,3 +1224,92 @@ describe("buildModelConfigFromForm", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasFieldValue ─────────────────────────────────────────────
|
||||
|
||||
describe("hasFieldValue", () => {
|
||||
it("returns false for an empty string", () => {
|
||||
expect(hasFieldValue("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for whitespace-only string", () => {
|
||||
expect(hasFieldValue(" ")).toBe(false);
|
||||
expect(hasFieldValue("\t\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for the empty JSON array sentinel '[]'", () => {
|
||||
expect(hasFieldValue("[]")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for '[]' with surrounding whitespace", () => {
|
||||
expect(hasFieldValue(" [] ")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a non-empty string", () => {
|
||||
expect(hasFieldValue("hello")).toBe(true);
|
||||
expect(hasFieldValue(" hello ")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a non-empty JSON array", () => {
|
||||
expect(hasFieldValue('["example.com"]')).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-string values", () => {
|
||||
expect(hasFieldValue(undefined)).toBe(false);
|
||||
expect(hasFieldValue(null)).toBe(false);
|
||||
expect(hasFieldValue(42)).toBe(false);
|
||||
expect(hasFieldValue(true)).toBe(false);
|
||||
expect(hasFieldValue({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isFieldConflictDisabled ──────────────────────────────────
|
||||
|
||||
describe("isFieldConflictDisabled", () => {
|
||||
const makeReader =
|
||||
(values: Record<string, unknown>): ((jsonName: string) => unknown) =>
|
||||
(jsonName: string): unknown =>
|
||||
values[jsonName];
|
||||
|
||||
const field = (overrides: Partial<FieldSchema> = {}): FieldSchema => ({
|
||||
json_name: "field_a",
|
||||
go_name: "FieldA",
|
||||
type: "string",
|
||||
required: false,
|
||||
input_type: "input",
|
||||
conflicts_with: ["field_b"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("disables the field when a sibling has a value and this field is empty", () => {
|
||||
const reader = makeReader({ field_a: "", field_b: "some-value" });
|
||||
expect(isFieldConflictDisabled(field(), reader)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not disable when the field has its own value, even if a sibling is set", () => {
|
||||
const reader = makeReader({ field_a: "my-value", field_b: "some-value" });
|
||||
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable when the field has no conflicts_with", () => {
|
||||
const reader = makeReader({ field_a: "", field_b: "some-value" });
|
||||
expect(
|
||||
isFieldConflictDisabled(field({ conflicts_with: undefined }), reader),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable when the sibling value is empty", () => {
|
||||
const reader = makeReader({ field_a: "", field_b: "" });
|
||||
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable when the sibling value is whitespace-only", () => {
|
||||
const reader = makeReader({ field_a: "", field_b: " " });
|
||||
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable when the sibling value is the empty array sentinel '[]'", () => {
|
||||
const reader = makeReader({ field_a: "", field_b: "[]" });
|
||||
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,37 @@ export function deepGet(obj: unknown, path: string[]): unknown {
|
||||
const hasObjectKeys = (value: Record<string, unknown>): boolean =>
|
||||
Object.keys(value).length > 0;
|
||||
|
||||
export const isVisibleWhenSatisfied = (
|
||||
field: FieldSchema,
|
||||
readSiblingValue: (jsonName: string) => unknown,
|
||||
): boolean =>
|
||||
!field.visible_when || readSiblingValue(field.visible_when) === "true";
|
||||
|
||||
// A field counts as "set" when it holds a non-empty value. JSON array fields
|
||||
// serialize to "[]" when empty, so that is treated as unset too.
|
||||
export const hasFieldValue = (raw: unknown): boolean => {
|
||||
if (typeof raw !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 && trimmed !== "[]";
|
||||
};
|
||||
|
||||
/**
|
||||
* conflicts_with: disable the field while a mutually exclusive sibling holds
|
||||
* a value, unless this field also has one so a both-set state stays
|
||||
* recoverable.
|
||||
*/
|
||||
export const isFieldConflictDisabled = (
|
||||
field: FieldSchema,
|
||||
readSiblingValue: (jsonName: string) => unknown,
|
||||
): boolean =>
|
||||
Boolean(field.conflicts_with) &&
|
||||
!hasFieldValue(readSiblingValue(field.json_name)) &&
|
||||
(field.conflicts_with ?? []).some((sibling) =>
|
||||
hasFieldValue(readSiblingValue(sibling)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert a form string value to its API representation based on
|
||||
* the field schema type. Empty strings yield `undefined` so
|
||||
@@ -507,7 +538,14 @@ export const buildModelConfigFromForm = (
|
||||
if (providerFormState && typeof providerFormState === "object") {
|
||||
const providerPayload: Record<string, unknown> = {};
|
||||
|
||||
const readProviderValue = (jsonName: string): unknown =>
|
||||
deepGet(providerFormState, jsonName.split(".").map(snakeToCamel));
|
||||
|
||||
for (const field of getProviderFields(resolved)) {
|
||||
// Skip fields hidden by an unsatisfied `visible_when` gate so
|
||||
// stale values left in form state are not serialized.
|
||||
if (!isVisibleWhenSatisfied(field, readProviderValue)) continue;
|
||||
|
||||
// Read the form value from the nested camelCase structure.
|
||||
const camelSegments = field.json_name.split(".").map(snakeToCamel);
|
||||
const formValue = deepGet(providerFormState, camelSegments);
|
||||
|
||||
@@ -173,9 +173,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
|
||||
<SettingsNavItem
|
||||
icon={BoxesIcon}
|
||||
label="Models"
|
||||
active={settingsSection === "models"}
|
||||
to="/agents/settings/models"
|
||||
state={location.state}
|
||||
active={false}
|
||||
to="/ai/settings/models"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={ServerIcon}
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderForm";
|
||||
import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderField";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
|
||||
+20
-4
@@ -391,9 +391,6 @@ const AgentSettingsPersonalSkillsPage = lazy(
|
||||
const AgentSettingsAPIKeysPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"),
|
||||
);
|
||||
const AgentSettingsModelsPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsModelsPage"),
|
||||
);
|
||||
const AgentSettingsMCPServersPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsMCPServersPage"),
|
||||
);
|
||||
@@ -450,6 +447,16 @@ const AISettingsAddProviderPage = lazy(
|
||||
const AISettingsGatewayKeysPage = lazy(
|
||||
() => import("./pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage"),
|
||||
);
|
||||
const AISettingsModelsPage = lazy(
|
||||
() => import("./pages/AISettingsPage/ModelsPage/ModelsPage"),
|
||||
);
|
||||
const AISettingsAddModelPage = lazy(
|
||||
() => import("./pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage"),
|
||||
);
|
||||
const AISettingsUpdateModelPage = lazy(
|
||||
() =>
|
||||
import("./pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage"),
|
||||
);
|
||||
|
||||
const AISettingsIndexPage = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
@@ -723,6 +730,12 @@ export const router = createBrowserRouter(
|
||||
element={<AISettingsGatewayKeysPage />}
|
||||
/>
|
||||
<Route index element={<AISettingsIndexPage />} />
|
||||
<Route path="models" element={<AISettingsModelsPage />} />
|
||||
<Route path="models/add" element={<AISettingsAddModelPage />} />
|
||||
<Route
|
||||
path="models/:modelId"
|
||||
element={<AISettingsUpdateModelPage />}
|
||||
/>
|
||||
<Route path="add" element={<AISettingsAddProviderPage />} />
|
||||
<Route
|
||||
path=":providerId"
|
||||
@@ -812,7 +825,10 @@ export const router = createBrowserRouter(
|
||||
path="providers"
|
||||
element={<Navigate to="/ai/settings" replace />}
|
||||
/>
|
||||
<Route path="models" element={<AgentSettingsModelsPage />} />
|
||||
<Route
|
||||
path="models"
|
||||
element={<Navigate to="/ai/settings/models" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="mcp-servers"
|
||||
element={<AgentSettingsMCPServersPage />}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChatModelConfig, ChatProviderConfig } from "#/api/typesGenerated";
|
||||
import type {
|
||||
ChatModelConfig,
|
||||
ChatModelProvider,
|
||||
ChatProviderConfig,
|
||||
} from "#/api/typesGenerated";
|
||||
import { MOCK_TIMESTAMP } from "./chatEntities";
|
||||
|
||||
export const MockChatModelConfig: ChatModelConfig = {
|
||||
@@ -28,3 +32,9 @@ export const MockChatProviderConfig: ChatProviderConfig = {
|
||||
created_at: MOCK_TIMESTAMP,
|
||||
updated_at: MOCK_TIMESTAMP,
|
||||
};
|
||||
|
||||
export const MockChatModelProvider: ChatModelProvider = {
|
||||
provider: "openai",
|
||||
available: true,
|
||||
models: [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user