feat: add config v2 to simplify billing (#428)

* feat: add model config v2

* chore: centralize user org selection

* chore: move preferences to platform settings

* fix: decouple org preference and ai model preferences
This commit is contained in:
Abhishek 2026-06-09 16:10:26 +05:30 committed by GitHub
parent 49e68b49d5
commit cdbd06c8d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 5135 additions and 264 deletions

View file

@ -0,0 +1,419 @@
"use client";
import { KeyRound, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
import {
type ProviderSchema,
type ServiceConfigurationDefaults,
ServiceConfigurationForm,
type ServiceSegment,
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
type ModelMode = "dograh" | "byok";
interface DograhDefaults {
voices: string[];
speeds: number[];
languages: string[];
defaults: {
voice: string;
speed: number;
language: string;
};
}
export interface ModelConfigurationDefaultsV2 {
dograh: DograhDefaults;
byok: {
pipeline: ServiceConfigurationDefaults;
realtime: {
realtime: Record<string, ProviderSchema>;
llm: Record<string, ProviderSchema>;
embeddings: Record<string, ProviderSchema>;
default_providers: ServiceConfigurationDefaults["default_providers"];
};
};
}
interface DograhFormState {
api_key: string;
voice: string;
speed: number;
language: string;
}
interface AIModelConfigurationV2EditorProps {
defaults: ModelConfigurationDefaultsV2;
configuration?: OrganizationAiModelConfigurationV2 | Record<string, unknown> | null;
effectiveConfiguration?: Record<string, unknown> | null;
onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise<void>;
submitLabel?: string;
}
function firstApiKey(value: unknown): string {
if (Array.isArray(value)) return String(value[0] || "");
return typeof value === "string" ? value : "";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function isDograhEffectiveConfig(config: Record<string, unknown> | null | undefined): boolean {
if (!config || config.is_realtime) return false;
const llm = asRecord(config.llm);
const tts = asRecord(config.tts);
const stt = asRecord(config.stt);
return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh";
}
function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults {
return {
llm: defaults.byok.pipeline.llm,
tts: defaults.byok.pipeline.tts,
stt: defaults.byok.pipeline.stt,
embeddings: defaults.byok.pipeline.embeddings,
realtime: defaults.byok.realtime.realtime,
default_providers: defaults.byok.pipeline.default_providers,
};
}
function byokConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
if (!config || config.mode !== "byok") return null;
const byok = asRecord(config.byok);
if (!byok) return null;
if (byok.mode === "realtime") {
const realtime = asRecord(byok.realtime);
return {
is_realtime: true,
realtime: realtime?.realtime,
llm: realtime?.llm,
embeddings: realtime?.embeddings,
};
}
const pipeline = asRecord(byok.pipeline);
return {
is_realtime: false,
llm: pipeline?.llm,
tts: pipeline?.tts,
stt: pipeline?.stt,
embeddings: pipeline?.embeddings,
};
}
function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
if (!config) return null;
return {
is_realtime: Boolean(config.is_realtime),
llm: config.llm,
tts: config.tts,
stt: config.stt,
realtime: config.realtime,
embeddings: config.embeddings,
};
}
function emptyByokInitialConfig(): Record<string, unknown> {
return {
is_realtime: false,
};
}
function getByokInitialConfig(
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): Record<string, unknown> {
const byokConfiguration = byokConfigToLegacyShape(configuration);
if (byokConfiguration) return byokConfiguration;
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
return emptyByokInitialConfig();
}
return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
}
function buildDograhState(
defaults: ModelConfigurationDefaultsV2,
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): DograhFormState {
const fallback = defaults.dograh.defaults;
const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null;
if (configuredDograh) {
return {
api_key: String(configuredDograh.api_key || ""),
voice: String(configuredDograh.voice || fallback.voice),
speed: Number(configuredDograh.speed || fallback.speed),
language: String(configuredDograh.language || fallback.language),
};
}
if (isDograhEffectiveConfig(effectiveConfiguration)) {
const llm = asRecord(effectiveConfiguration?.llm);
const tts = asRecord(effectiveConfiguration?.tts);
const stt = asRecord(effectiveConfiguration?.stt);
return {
api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key),
voice: String(tts?.voice || fallback.voice),
speed: Number(tts?.speed || fallback.speed),
language: String(stt?.language || fallback.language),
};
}
return {
api_key: "",
voice: fallback.voice,
speed: fallback.speed,
language: fallback.language,
};
}
function preferredMode(
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): ModelMode {
if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
return configuration.mode;
}
return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
}
function hasRequiredApiKey(
service: ServiceSegment,
serviceConfiguration: Record<string, unknown>,
defaults: ServiceConfigurationDefaults,
): boolean {
const provider = serviceConfiguration.provider as string | undefined;
if (!provider) return false;
const providerSchema = service === "realtime"
? defaults.realtime?.[provider]
: defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider];
const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false;
if (!requiresApiKey) return true;
const apiKey = serviceConfiguration.api_key;
if (Array.isArray(apiKey)) {
return apiKey.some((key) => typeof key === "string" && key.trim().length > 0);
}
return typeof apiKey === "string" && apiKey.trim().length > 0;
}
function requireByokService(
config: Record<string, unknown>,
service: ServiceSegment,
defaults: ServiceConfigurationDefaults,
): Record<string, unknown> {
const serviceConfiguration = asRecord(config[service]);
if (
!serviceConfiguration
|| !serviceConfiguration.provider
|| serviceConfiguration.provider === "dograh"
|| !hasRequiredApiKey(service, serviceConfiguration, defaults)
) {
throw new Error(`${service} configuration is required`);
}
return serviceConfiguration;
}
function optionalByokService(config: Record<string, unknown>, service: ServiceSegment): Record<string, unknown> | undefined {
const serviceConfiguration = asRecord(config[service]);
if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined;
return serviceConfiguration;
}
export function AIModelConfigurationV2Editor({
defaults,
configuration,
effectiveConfiguration,
onSave,
submitLabel = "Save Configuration",
}: AIModelConfigurationV2EditorProps) {
const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]);
const [mode, setMode] = useState<ModelMode>("dograh");
const [dograh, setDograh] = useState<DograhFormState>(() => ({
api_key: "",
voice: defaults.dograh.defaults.voice,
speed: defaults.dograh.defaults.speed,
language: defaults.dograh.defaults.language,
}));
const [byokInitialConfig, setByokInitialConfig] = useState<Record<string, unknown> | null>(null);
const [isSavingDograh, setIsSavingDograh] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const rawConfiguration = asRecord(configuration);
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
}, [configuration, defaults, effectiveConfiguration]);
const saveDograhConfiguration = async () => {
setIsSavingDograh(true);
setError(null);
try {
await onSave({
version: 2,
mode: "dograh",
dograh: {
api_key: dograh.api_key.trim(),
voice: dograh.voice,
speed: dograh.speed,
language: dograh.language,
},
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setIsSavingDograh(false);
}
};
const saveByokConfiguration = async (config: Record<string, unknown>) => {
setError(null);
const isRealtime = Boolean(config.is_realtime);
const llm = requireByokService(config, "llm", defaultsForByok);
const embeddings = optionalByokService(config, "embeddings");
const body: OrganizationAiModelConfigurationV2 = {
version: 2,
mode: "byok",
byok: isRealtime
? {
mode: "realtime",
realtime: {
realtime: requireByokService(config, "realtime", defaultsForByok) as never,
llm: llm as never,
...(embeddings ? { embeddings: embeddings as never } : {}),
},
}
: {
mode: "pipeline",
pipeline: {
llm: llm as never,
tts: requireByokService(config, "tts", defaultsForByok) as never,
stt: requireByokService(config, "stt", defaultsForByok) as never,
...(embeddings ? { embeddings: embeddings as never } : {}),
},
},
};
await onSave(body);
};
return (
<div className="space-y-6">
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="dograh">Dograh</TabsTrigger>
<TabsTrigger value="byok">BYOK</TabsTrigger>
</TabsList>
<TabsContent value="dograh" className="mt-0">
<div className="rounded-lg border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
</div>
</div>
<div className="space-y-2">
<Label>Voice</Label>
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</div>
</TabsContent>
<TabsContent value="byok" className="mt-0">
<ServiceConfigurationForm
key={JSON.stringify(byokInitialConfig)}
mode="global"
configurationDefaults={defaultsForByok}
initialConfig={byokInitialConfig}
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,274 @@
"use client";
import { ExternalLink, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost,
saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
} from "@/client/sdk.gen";
import type {
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
} from "@/client/types.gen";
import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
export default function ModelConfigurationV2({
docsUrl,
initialAction,
}: {
docsUrl?: string;
initialAction?: string;
}) {
const auth = useAuth();
const { refreshConfig, saveUserConfig } = useUserConfig();
const hasFetched = useRef(false);
const hasAppliedInitialMigrationAction = useRef(false);
const [defaults, setDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [response, setResponse] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [loading, setLoading] = useState(true);
const [migrating, setMigrating] = useState(false);
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => {
setResponse(nextResponse);
};
useEffect(() => {
if (auth.loading || !auth.user || hasFetched.current) return;
hasFetched.current = true;
const load = async () => {
setLoading(true);
setError(null);
const [defaultsResult, configResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
]);
if (defaultsResult.error) {
setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
setLoading(false);
return;
}
if (configResult.error) {
setError(detailFromError(configResult.error, "Failed to load model configuration"));
setLoading(false);
return;
}
const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2;
if (!nextDefaults || !configResult.data) {
setError("Failed to load model configuration");
setLoading(false);
return;
}
setDefaults(nextDefaults);
applyResponse(configResult.data);
setLoading(false);
};
load();
}, [auth.loading, auth.user]);
useEffect(() => {
if (hasAppliedInitialMigrationAction.current) return;
if (initialAction !== "migrate_to_v2") return;
if (loading || response?.source !== "legacy_user_v1") return;
hasAppliedInitialMigrationAction.current = true;
setMigrationDialogOpen(true);
}, [initialAction, loading, response?.source]);
const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => {
if (!defaults) return;
setError(null);
setNotice(null);
const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({
body: configuration,
});
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to save model configuration"));
}
if (!result.data) {
throw new Error("Failed to save model configuration");
}
applyResponse(result.data);
await refreshConfig();
setNotice("Model configuration saved");
};
const migrateConfiguration = async () => {
if (!defaults) return;
setMigrating(true);
setError(null);
setNotice(null);
const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost();
if (result.error) {
setError(detailFromError(result.error, "Failed to migrate model configuration"));
} else if (!result.data) {
setError("Failed to migrate model configuration");
} else {
applyResponse(result.data);
await refreshConfig();
setNotice("Configuration migrated to v2");
setMigrationDialogOpen(false);
}
setMigrating(false);
};
const migrationWarningDialog = (
<AlertDialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Migrate model configuration to v2?</AlertDialogTitle>
<AlertDialogDescription>
Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={migrating}>Cancel</AlertDialogCancel>
<Button type="button" onClick={migrateConfiguration} disabled={migrating}>
{migrating ? "Migrating..." : "Migrate to v2"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
if (loading) {
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-28 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
const source = response?.source || "empty";
if (source !== "organization_v2") {
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
<Badge variant="outline">
{source === "legacy_user_v1" ? "legacy" : "v1"}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Configure your AI model, voice, and transcription services.{" "}
{docsUrl && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
</div>
{source === "legacy_user_v1" && (
<Button type="button" variant="outline" onClick={() => setMigrationDialogOpen(true)} disabled={migrating}>
<RefreshCw className="mr-2 h-4 w-4" />
{migrating ? "Migrating..." : "Migrate to v2"}
</Button>
)}
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
{notice}
</div>
)}
<ServiceConfigurationForm
mode="global"
onSave={async (config) => {
setError(null);
setNotice(null);
await saveUserConfig(config as Parameters<typeof saveUserConfig>[0]);
await refreshConfig();
if (defaults) {
const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
if (configResult.data) {
applyResponse(configResult.data);
}
}
setNotice("Configuration saved");
}}
/>
{migrationWarningDialog}
</div>
);
}
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
<p className="mt-2 text-sm text-muted-foreground">
Organization-scoped model settings.{" "}
{docsUrl && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
{notice}
</div>
)}
{defaults && response && (
<AIModelConfigurationV2Editor
defaults={defaults}
configuration={response.configuration}
effectiveConfiguration={response.effective_configuration}
onSave={saveConfiguration}
/>
)}
{migrationWarningDialog}
</div>
);
}

View file

@ -0,0 +1,221 @@
"use client";
import { Save } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import TimezoneSelect, { type ITimezoneOption } from "react-timezone-select";
import { toast } from "sonner";
import {
getPreferencesApiV1OrganizationsPreferencesGet,
savePreferencesApiV1OrganizationsPreferencesPut,
} from "@/client/sdk.gen";
import type { OrganizationPreferences } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
const emptyPreferences: OrganizationPreferences = {
test_phone_number: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
};
const timezoneSelectStyles = {
control: (base: Record<string, unknown>, state: { isFocused: boolean }) => ({
...base,
minHeight: "36px",
fontSize: "14px",
backgroundColor: "var(--background)",
borderColor: state.isFocused ? "var(--ring)" : "var(--border)",
boxShadow: state.isFocused
? "0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)"
: "none",
"&:hover": { borderColor: "var(--border)" },
}),
menu: (base: Record<string, unknown>) => ({
...base,
zIndex: 9999,
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
boxShadow:
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
}),
menuList: (base: Record<string, unknown>) => ({
...base,
backgroundColor: "var(--popover)",
padding: 0,
}),
option: (
base: Record<string, unknown>,
state: { isFocused: boolean; isSelected: boolean },
) => ({
...base,
backgroundColor: state.isSelected
? "var(--accent)"
: state.isFocused
? "var(--accent)"
: "var(--popover)",
color: "var(--foreground)",
cursor: "pointer",
"&:active": { backgroundColor: "var(--accent)" },
}),
singleValue: (base: Record<string, unknown>) => ({
...base,
color: "var(--foreground)",
}),
input: (base: Record<string, unknown>) => ({
...base,
color: "var(--foreground)",
}),
placeholder: (base: Record<string, unknown>) => ({
...base,
color: "var(--muted-foreground)",
}),
indicatorSeparator: (base: Record<string, unknown>) => ({
...base,
backgroundColor: "var(--border)",
}),
dropdownIndicator: (base: Record<string, unknown>) => ({
...base,
color: "var(--muted-foreground)",
"&:hover": { color: "var(--foreground)" },
}),
};
function getTimezoneValue(tz: ITimezoneOption | string): string {
return typeof tz === "string" ? tz : tz.value;
}
export function OrganizationPreferencesSection() {
const { user, loading: authLoading } = useAuth();
const { refreshConfig } = useUserConfig();
const timezoneSelectId = useId();
const hasFetched = useRef(false);
const [preferences, setPreferences] =
useState<OrganizationPreferences>(emptyPreferences);
const [timezone, setTimezone] = useState<ITimezoneOption | string>(
emptyPreferences.timezone || "UTC",
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (authLoading || !user || hasFetched.current) {
return;
}
hasFetched.current = true;
void fetchPreferences();
}, [authLoading, user]);
async function fetchPreferences() {
setLoading(true);
try {
const result =
await getPreferencesApiV1OrganizationsPreferencesGet();
if (result.error) {
toast.error(
detailFromError(
result.error,
"Failed to load organization preferences",
),
);
return;
}
const nextPreferences = result.data || emptyPreferences;
setPreferences({
test_phone_number: nextPreferences.test_phone_number || "",
timezone: nextPreferences.timezone || emptyPreferences.timezone,
});
setTimezone(
nextPreferences.timezone || emptyPreferences.timezone || "UTC",
);
} catch {
toast.error("Failed to load organization preferences");
} finally {
setLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const result =
await savePreferencesApiV1OrganizationsPreferencesPut(
{
body: {
test_phone_number: preferences.test_phone_number || null,
timezone: getTimezoneValue(timezone),
},
},
);
if (result.error) {
toast.error(detailFromError(result.error, "Failed to save preferences"));
return;
}
if (!result.data) {
toast.error("Failed to save preferences");
return;
}
setPreferences({
test_phone_number: result.data.test_phone_number || "",
timezone: result.data.timezone || emptyPreferences.timezone,
});
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
await refreshConfig();
toast.success("Preferences saved");
} catch {
toast.error("Failed to save preferences");
} finally {
setSaving(false);
}
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
return (
<form onSubmit={handleSave} className="space-y-4">
<p className="text-sm text-muted-foreground">
Set organization-wide defaults used by testing and scheduling flows.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="settings-test-phone-number">Test Phone Number</Label>
<Input
id="settings-test-phone-number"
value={preferences.test_phone_number || ""}
onChange={(event) =>
setPreferences({
...preferences,
test_phone_number: event.target.value,
})
}
placeholder="+15551234567"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneSelect
instanceId={timezoneSelectId}
value={timezone}
onChange={setTimezone}
styles={timezoneSelectStyles}
/>
</div>
</div>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? "Saving..." : "Save"}
</Button>
</form>
);
}

View file

@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
import type { ModelOverrides } from "@/types/workflow-configurations";
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
interface SchemaProperty {
type?: string;
@ -35,7 +35,7 @@ interface SchemaProperty {
docs_url?: string;
}
interface ProviderSchema {
export interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
@ -49,6 +49,15 @@ interface FormValues {
[key: string]: string | number | boolean;
}
export interface ServiceConfigurationDefaults {
llm: Record<string, ProviderSchema>;
tts: Record<string, ProviderSchema>;
stt: Record<string, ProviderSchema>;
embeddings: Record<string, ProviderSchema>;
realtime?: Record<string, ProviderSchema>;
default_providers: Partial<Record<ServiceSegment, string>>;
}
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
@ -90,6 +99,8 @@ export interface ServiceConfigurationFormProps {
onSave: (config: Record<string, unknown>) => Promise<void>;
/** Text for the submit button. Defaults to "Save Configuration". */
submitLabel?: string;
configurationDefaults?: ServiceConfigurationDefaults | null;
initialConfig?: Record<string, unknown> | null;
}
function getProviderDisplayName(
@ -117,6 +128,8 @@ export function ServiceConfigurationForm({
currentOverrides,
onSave,
submitLabel,
configurationDefaults,
initialConfig,
}: ServiceConfigurationFormProps) {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@ -165,15 +178,16 @@ export function ServiceConfigurationForm({
// Build effective config source: overlay overrides onto global config
const configSource = useMemo(() => {
if (mode === 'global' || !currentOverrides) return userConfig;
const baseConfig = initialConfig ?? userConfig;
if (mode === 'global' || !currentOverrides) return baseConfig;
// Merge overrides onto global config for form initialization
const merged = { ...userConfig } as Record<string, unknown>;
const merged = { ...baseConfig } as Record<string, unknown>;
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
for (const svc of overrideServices) {
if (svc === "is_realtime") continue;
const overrideVal = currentOverrides[svc];
if (overrideVal && typeof overrideVal === "object") {
const globalVal = (userConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
const globalVal = (baseConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
merged[svc] = { ...globalVal, ...overrideVal };
}
}
@ -181,24 +195,35 @@ export function ServiceConfigurationForm({
merged.is_realtime = currentOverrides.is_realtime;
}
return merged as typeof userConfig;
}, [mode, userConfig, currentOverrides]);
}, [mode, userConfig, currentOverrides, initialConfig]);
useEffect(() => {
const fetchConfigurations = async () => {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (!response.data) {
console.error("Failed to fetch configurations");
return;
let defaultsData = configurationDefaults;
if (!defaultsData) {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (!response.data) {
console.error("Failed to fetch configurations");
return;
}
defaultsData = response.data as ServiceConfigurationDefaults;
}
const data = response.data as Record<string, unknown>;
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
const realtimeSchemas = (defaultsData.realtime || {}) as Record<string, ProviderSchema>;
const pickDefaultProvider = (
service: ServiceSegment,
schemaMap: Record<string, ProviderSchema>,
) => {
const preferred = defaultsData.default_providers?.[service];
if (preferred && schemaMap[preferred]) return preferred;
return Object.keys(schemaMap)[0] || "";
};
setSchemas({
llm: response.data.llm as Record<string, ProviderSchema>,
tts: response.data.tts as Record<string, ProviderSchema>,
stt: response.data.stt as Record<string, ProviderSchema>,
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
llm: defaultsData.llm,
tts: defaultsData.tts,
stt: defaultsData.stt,
embeddings: defaultsData.embeddings,
realtime: realtimeSchemas,
});
@ -210,10 +235,10 @@ export function ServiceConfigurationForm({
const defaultValues: Record<string, string | number | boolean> = {};
const selectedProviders: Record<ServiceSegment, string> = {
llm: response.data.default_providers.llm,
tts: response.data.default_providers.tts,
stt: response.data.default_providers.stt,
embeddings: response.data.default_providers.embeddings,
llm: pickDefaultProvider("llm", defaultsData.llm),
tts: pickDefaultProvider("tts", defaultsData.tts),
stt: pickDefaultProvider("stt", defaultsData.stt),
embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings),
realtime: "",
};
@ -237,7 +262,7 @@ export function ServiceConfigurationForm({
const schemaSource = service === "realtime"
? realtimeSchemas
: response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
: defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
if (src?.provider) {
Object.entries(src).forEach(([field, value]) => {
@ -296,7 +321,7 @@ export function ServiceConfigurationForm({
// Detect custom inputs
const detectedCustomInput: Record<string, boolean> = {};
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
const provider = selectedProviders[service];
const providerSchema = allSchemas[service]?.[provider];
@ -337,7 +362,7 @@ export function ServiceConfigurationForm({
};
fetchConfigurations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reset, configSource]);
}, [reset, configSource, configurationDefaults]);
// Reset voice when TTS model changes if the provider has model-dependent voice options
const ttsModel = watch("tts_model");