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

@ -1,13 +1,25 @@
import ServiceConfiguration from "@/components/ServiceConfiguration";
import ModelConfigurationV2 from "@/components/ModelConfigurationV2";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
export default function ServiceConfigurationPage() {
interface ServiceConfigurationPageProps {
searchParams?: Promise<{
action?: string | string[];
}>;
}
export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) {
const params = searchParams ? await searchParams : {};
const action = Array.isArray(params.action) ? params.action[0] : params.action;
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<ServiceConfiguration docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides} />
<ModelConfigurationV2
docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides}
initialAction={action}
/>
</div>
</div>
</div>

View file

@ -7,6 +7,7 @@ import { useEffect,useState } from 'react';
import {
getDailyReportApiV1OrganizationsReportsDailyGet,
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
getPreferencesApiV1OrganizationsPreferencesGet,
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
} from '@/client/sdk.gen';
import type { WorkflowRunDetail } from '@/client/types.gen';
@ -16,7 +17,6 @@ import { Card } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { DispositionChart } from './components/DispositionChart';
@ -57,11 +57,9 @@ export default function ReportsPage() {
const [report, setReport] = useState<DailyReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { userConfig } = useUserConfig();
const [timezone, setTimezone] = useState('America/New_York');
const auth = useAuth();
const timezone = userConfig?.timezone || 'America/New_York';
// Fetch workflows on mount
useEffect(() => {
const fetchWorkflows = async () => {
@ -80,6 +78,22 @@ export default function ReportsPage() {
fetchWorkflows();
}, [auth.isAuthenticated]);
useEffect(() => {
const fetchPreferences = async () => {
if (!auth.isAuthenticated) return;
try {
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
if (response.data?.timezone) {
setTimezone(response.data.timezone);
}
} catch (err) {
console.error('Failed to fetch organization preferences:', err);
}
};
fetchPreferences();
}, [auth.isAuthenticated]);
// Fetch report data when date or workflow changes
useEffect(() => {
const fetchReport = async () => {

View file

@ -3,6 +3,7 @@
import { ExternalLink } from "lucide-react";
import { MCPSection } from "@/components/MCPSection";
import { OrganizationPreferencesSection } from "@/components/OrganizationPreferencesSection";
import { TelemetrySection } from "@/components/TelemetrySection";
import {
Card,
@ -23,6 +24,19 @@ export default function SettingsPage() {
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>
Set organization-wide defaults such as the test phone number and
timezone.
</CardDescription>
</CardHeader>
<CardContent>
<OrganizationPreferencesSection />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>MCP Server</CardTitle>

View file

@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { CallTypeCell } from '@/components/CallTypeCell';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
@ -36,7 +36,7 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
const { organizationPricing } = useUserConfig();
const auth = useAuth();
// MPS credits state
@ -74,6 +74,8 @@ export default function UsagePage() {
const localTimezone = getLocalTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>('');
const [savingTimezone, setSavingTimezone] = useState(false);
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
const [preferencesLoading, setPreferencesLoading] = useState(true);
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
// Fetch MPS credits
@ -168,6 +170,23 @@ export default function UsagePage() {
}
}, [auth.isAuthenticated, organizationPricing]);
const fetchPreferences = useCallback(async () => {
if (!auth.isAuthenticated) return;
setPreferencesLoading(true);
try {
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
const nextPreferences = response.data || {};
setPreferences(nextPreferences);
setSelectedTimezone(nextPreferences.timezone || localTimezone);
} catch (error) {
console.error('Failed to fetch organization preferences:', error);
setSelectedTimezone(localTimezone);
} finally {
setPreferencesLoading(false);
}
}, [auth.isAuthenticated, localTimezone]);
// Download a CSV of all runs matching the current filters.
const handleDownloadReport = async () => {
if (!auth.isAuthenticated) return;
@ -203,31 +222,31 @@ export default function UsagePage() {
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
setSelectedTimezone(timezone);
setSavingTimezone(true);
const previousTimezone = preferences.timezone || localTimezone;
try {
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
await saveUserConfig({ timezone: tzValue });
const response = await savePreferencesApiV1OrganizationsPreferencesPut({
body: {
...preferences,
timezone: tzValue,
},
});
if (response.error) {
throw new Error('Failed to save timezone');
}
setPreferences(response.data || { ...preferences, timezone: tzValue });
} catch (error) {
console.error('Failed to save timezone:', error);
// Revert to previous timezone on error
const prevTz = userConfig?.timezone || localTimezone;
setSelectedTimezone(prevTz);
setSelectedTimezone(previousTimezone);
} finally {
setSavingTimezone(false);
}
};
// Update timezone when userConfig loads
// Update timezone when organization preferences load.
useEffect(() => {
if (!userConfigLoading) {
// Config has loaded - set the timezone
if (userConfig?.timezone) {
setSelectedTimezone(userConfig.timezone);
} else {
// No saved timezone, use local
setSelectedTimezone(localTimezone);
}
}
}, [userConfig, userConfigLoading, localTimezone]);
fetchPreferences();
}, [fetchPreferences]);
// Initial load - fetch when auth becomes available
useEffect(() => {
@ -340,8 +359,8 @@ export default function UsagePage() {
instanceId={timezoneSelectId}
value={selectedTimezone}
onChange={handleTimezoneChange}
isDisabled={savingTimezone || userConfigLoading}
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
isDisabled={savingTimezone || preferencesLoading}
placeholder={preferencesLoading ? "Loading..." : "Select timezone"}
styles={{
control: (base, state) => ({
...base,

View file

@ -4,15 +4,21 @@ import 'react-international-phone/style.css';
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getPreferencesApiV1OrganizationsPreferencesGet,
initiateCallApiV1TelephonyInitiateCallPost,
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet,
savePreferencesApiV1OrganizationsPreferencesPut,
} from '@/client/sdk.gen';
import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen';
import type {
OrganizationPreferences,
PhoneNumberResponse,
TelephonyConfigurationListItem,
} from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -33,6 +39,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
interface PhoneCallDialogProps {
open: boolean;
@ -48,21 +55,40 @@ export const PhoneCallDialog = ({
user,
}: PhoneCallDialogProps) => {
const router = useRouter();
const { userConfig, saveUserConfig } = useUserConfig();
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const { refreshConfig } = useUserConfig();
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
const [phoneNumber, setPhoneNumber] = useState("");
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [sipMode, setSipMode] = useState(false);
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
const [fromPhoneNumbers, setFromPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState<string>("");
const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
const fetchPreferences = useCallback(async () => {
const result =
await getPreferencesApiV1OrganizationsPreferencesGet();
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to load phone preferences"));
}
return result.data || {};
}, []);
const applyPreferences = useCallback((nextPreferences: OrganizationPreferences) => {
const saved = nextPreferences.test_phone_number || "";
setPreferences(nextPreferences);
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
}, []);
// Check telephony configuration when dialog opens
useEffect(() => {
const checkConfig = async () => {
@ -97,6 +123,33 @@ export const PhoneCallDialog = ({
checkConfig();
}, [open]);
// Load organization-scoped call preferences when dialog opens.
useEffect(() => {
if (!open) return;
let cancelled = false;
setPreferencesLoaded(false);
const loadPreferences = async () => {
try {
const nextPreferences = await fetchPreferences();
if (cancelled) return;
applyPreferences(nextPreferences);
setPreferencesLoaded(true);
} catch (err) {
if (cancelled) return;
applyPreferences({});
setPreferencesLoaded(false);
setCallError(err instanceof Error ? err.message : "Failed to load phone preferences");
}
};
loadPreferences();
return () => {
cancelled = true;
};
}, [applyPreferences, fetchPreferences, open]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
@ -149,22 +202,9 @@ export const PhoneCallDialog = ({
};
}, [open, selectedConfigId]);
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
const saved = userConfig?.test_phone_number || "";
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [open, userConfig?.test_phone_number]);
const handlePhoneInputChange = (formattedValue: string) => {
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
setPhoneChanged(formattedValue !== (preferences.test_phone_number || ""));
setCallError(null);
setCallSuccessMsg(null);
};
@ -174,17 +214,39 @@ export const PhoneCallDialog = ({
router.push('/telephony-configurations');
};
const savePhoneNumberPreference = async () => {
const currentPreferences = preferencesLoaded ? preferences : await fetchPreferences();
const result =
await savePreferencesApiV1OrganizationsPreferencesPut({
body: {
...currentPreferences,
test_phone_number: phoneNumber || null,
},
});
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to save phone preferences"));
}
if (!result.data) {
throw new Error("Failed to save phone preferences");
}
setPreferences(result.data);
setPreferencesLoaded(true);
setPhoneChanged(false);
await refreshConfig();
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user || !userConfig) return;
if (!user) return;
// Save phone number if it has changed
if (phoneChanged) {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
await savePhoneNumberPreference();
}
const response = await initiateCallApiV1TelephonyInitiateCallPost({

View file

@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import {
downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet,
getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost,
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
getWorkflowApiV1WorkflowFetchWorkflowIdGet,
} from "@/client/sdk.gen";
import type {
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
WorkflowResponse,
} from "@/client/types.gen";
import {
AIModelConfigurationV2Editor,
type ModelConfigurationDefaultsV2,
} from "@/components/AIModelConfigurationV2Editor";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
);
}
// ---------------------------------------------------------------------------
// Section: Model Overrides
// ---------------------------------------------------------------------------
function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations {
const next = { ...configurations };
delete next.model_overrides;
delete next.model_configuration_v2_override;
return next;
}
function WorkflowModelOverridesSection({
workflowConfigurations,
workflowName,
onSave,
modelConfigurationDefaults,
organizationModelConfiguration,
modelConfigurationLoading,
modelConfigurationError,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null;
organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null;
modelConfigurationLoading: boolean;
modelConfigurationError: string | null;
}) {
const savedV2Override = workflowConfigurations.model_configuration_v2_override;
const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides);
const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override));
const [isRemovingOverride, setIsRemovingOverride] = useState(false);
useEffect(() => {
setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override));
}, [workflowConfigurations.model_configuration_v2_override]);
const source = organizationModelConfiguration?.source || "empty";
const isV2 = source === "organization_v2";
const saveLegacyOverrides = async (config: Record<string, unknown>) => {
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined;
if (modelOverrides) {
nextConfigurations.model_overrides = modelOverrides;
}
await onSave(nextConfigurations, workflowName);
};
const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => {
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
nextConfigurations.model_configuration_v2_override = configuration;
await onSave(nextConfigurations, workflowName);
toast.success("Model override saved");
};
const removeV2Override = async () => {
setIsRemovingOverride(true);
try {
await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName);
setOverrideEnabled(false);
toast.success("Using organization model configuration");
} finally {
setIsRemovingOverride(false);
}
};
return (
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
{isV2
? "Override the full organization model configuration for this workflow."
: "Override global model settings for this workflow. Toggle individual services to customize."}{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{modelConfigurationLoading && (
<div className="flex items-center gap-2 rounded-md border p-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading model configuration
</div>
)}
{modelConfigurationError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{modelConfigurationError}
</div>
)}
{!modelConfigurationLoading && !modelConfigurationError && !isV2 && (
<>
{source === "legacy_user_v1" && (
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides.
</p>
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/model-configurations?action=migrate_to_v2">Migrate to v2</Link>
</Button>
</div>
)}
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={saveLegacyOverrides}
/>
</>
)}
{!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && (
<>
<div className="flex items-center justify-between rounded-md border p-4">
<div className="space-y-0.5">
<Label htmlFor="workflow-model-v2-override" className="text-sm font-medium">
Override for this workflow
</Label>
<p className="text-xs text-muted-foreground">
{overrideEnabled
? "This workflow uses its own complete model configuration."
: "This workflow uses the organization model configuration."}
</p>
</div>
<Switch
id="workflow-model-v2-override"
checked={overrideEnabled}
onCheckedChange={setOverrideEnabled}
/>
</div>
{overrideEnabled ? (
<AIModelConfigurationV2Editor
defaults={modelConfigurationDefaults}
configuration={
(savedV2Override as OrganizationAiModelConfigurationV2 | undefined)
|| (organizationModelConfiguration.configuration as OrganizationAiModelConfigurationV2 | null)
}
effectiveConfiguration={
savedV2Override
? null
: organizationModelConfiguration.effective_configuration
}
submitLabel="Save Model Override"
onSave={saveV2Override}
/>
) : (
<div className="rounded-md border bg-muted/20 p-4">
<p className="text-sm text-muted-foreground">
Using organization model configuration.
</p>
{hasSavedModelOverride && (
<Button
type="button"
variant="outline"
className="mt-3"
onClick={removeV2Override}
disabled={isRemovingOverride}
>
{isRemovingOverride ? "Saving..." : "Save Organization Configuration"}
</Button>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState("general");
const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true);
const [modelConfigurationError, setModelConfigurationError] = useState<string | null>(null);
const hasFetchedModelConfiguration = useRef(false);
const workflowId = workflow.id;
@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({
user,
});
useEffect(() => {
if (hasFetchedModelConfiguration.current) return;
hasFetchedModelConfiguration.current = true;
const loadModelConfiguration = async () => {
setModelConfigurationLoading(true);
setModelConfigurationError(null);
const [defaultsResult, configurationResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
]);
if (defaultsResult.error) {
setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
setModelConfigurationLoading(false);
return;
}
if (configurationResult.error) {
setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration"));
setModelConfigurationLoading(false);
return;
}
setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2);
setOrganizationModelConfiguration(configurationResult.data || null);
setModelConfigurationLoading(false);
};
loadModelConfiguration();
}, []);
// Intersection observer for active sidebar link
useEffect(() => {
const ids = NAV_ITEMS.map((n) => n.id);
@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({
onSave={saveWorkflowConfigurations}
/>
{/* Model Overrides */}
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
Override global model settings for this workflow. Toggle individual services to
customize.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={async (config) => {
await saveWorkflowConfigurations(
{
...workflowConfigurations,
model_overrides:
config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
}}
/>
</CardContent>
</Card>
<WorkflowModelOverridesSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
modelConfigurationDefaults={modelConfigurationDefaults}
organizationModelConfiguration={organizationModelConfiguration}
modelConfigurationLoading={modelConfigurationLoading}
modelConfigurationError={modelConfigurationError}
/>
{/* Template Variables */}
<TemplateVariablesSection

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

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");

View file

@ -1,3 +1,5 @@
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
export interface AmbientNoiseConfiguration {
enabled: boolean;
volume: number;
@ -64,6 +66,7 @@ export interface WorkflowConfigurations {
voicemail_detection?: VoicemailDetectionConfiguration;
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
[key: string]: unknown; // Allow additional properties for future configurations
}