Caller model routing — cycle, style-matched, mid-show override

- Three strategies: single model, cycle through pool, style-matched
- 18 communication styles mapped to 7 models (Grok, Sonnet, Mistral, Qwen, DeepSeek, Gemini, Llama)
- Per-caller model locked for entire call, overridable mid-show
- Model badges on caller buttons and info panel
- Settings UI for strategy, pool, style mapping, fallback
- Fallback to Sonnet on model failure
- 6 new models added to pricing and dropdown
- Checkpoint persistence for all model state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 01:58:03 -06:00
parent e0fb3cac68
commit 314d5f9452
6 changed files with 487 additions and 4 deletions
+145
View File
@@ -6237,6 +6237,39 @@ class Session:
self.relationship_context: dict[str, str] = {} # caller_key → relationship prompt injection
self.intern_monitoring: bool = True # Devon monitors conversations by default
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
# Caller model routing
self.caller_model_strategy: str = "single" # "single" | "cycle" | "style_matched"
self.caller_model_pool: list[str] = [
"x-ai/grok-4",
"anthropic/claude-sonnet-4-5",
"mistralai/mistral-medium-3",
"qwen/qwen3-235b-a22b",
"deepseek/deepseek-chat-v3-0324",
"google/gemini-2.5-pro",
"meta-llama/llama-4-maverick",
]
self.caller_model_map: dict[str, str] = {
"high_energy": "x-ai/grok-4",
"confrontational": "x-ai/grok-4",
"angry_venting": "x-ai/grok-4",
"bragger": "x-ai/grok-4",
"comedian": "x-ai/grok-4",
"quiet_nervous": "anthropic/claude-sonnet-4-5",
"sweet_earnest": "anthropic/claude-sonnet-4-5",
"emotional": "anthropic/claude-sonnet-4-5",
"deadpan": "mistralai/mistral-medium-3",
"mysterious": "mistralai/mistral-medium-3",
"world_weary": "mistralai/mistral-medium-3",
"storyteller": "qwen/qwen3-235b-a22b",
"rambling": "qwen/qwen3-235b-a22b",
"oversharer": "deepseek/deepseek-chat-v3-0324",
"conspiracy": "deepseek/deepseek-chat-v3-0324",
"know_it_all": "google/gemini-2.5-pro",
"first_time": "meta-llama/llama-4-maverick",
}
self.caller_model_fallback: str = "anthropic/claude-sonnet-4-5"
self.caller_models: dict[str, str] = {} # caller_key → assigned model
self._caller_model_cycle_idx: int = 0
def start_call(self, caller_key: str):
self.current_caller_key = caller_key
@@ -6253,6 +6286,35 @@ class Session:
def add_message(self, role: str, content: str):
self.conversation.append({"role": role, "content": content, "timestamp": time.time()})
def get_caller_model(self, caller_key: str) -> str | None:
"""Get the assigned model for a caller, or assign one based on strategy.
Returns None to use default category routing."""
if self.caller_model_strategy == "single":
return None # use default category_models["caller_dialog"]
# Already assigned — keep consistent for the whole call
if caller_key in self.caller_models:
return self.caller_models[caller_key]
model = None
if self.caller_model_strategy == "cycle":
if self.caller_model_pool:
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
self._caller_model_cycle_idx += 1
elif self.caller_model_strategy == "style_matched":
raw_style = self.caller_styles.get(caller_key, "")
style_key = _normalize_style_key(raw_style) if raw_style else ""
model = self.caller_model_map.get(style_key)
if not model and self.caller_model_pool:
model = self.caller_model_pool[0]
if model:
self.caller_models[caller_key] = model
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
print(f"[CallerModel] Assigned {model} to {caller_name} (strategy={self.caller_model_strategy})")
return model
def get_caller_background(self, caller_key: str) -> str:
"""Get or generate background for a caller in this session.
Returns the natural_description string for prompt injection."""
@@ -6607,6 +6669,12 @@ def _save_checkpoint():
"caller_queue": session.caller_queue,
"relationship_context": session.relationship_context,
"intern_monitoring": session.intern_monitoring,
"caller_model_strategy": session.caller_model_strategy,
"caller_model_pool": session.caller_model_pool,
"caller_model_map": session.caller_model_map,
"caller_model_fallback": session.caller_model_fallback,
"caller_models": session.caller_models,
"caller_model_cycle_idx": session._caller_model_cycle_idx,
"costs": cost_tracker.get_live_summary(),
"cost_records": {
"llm": [asdict(r) for r in cost_tracker.llm_records],
@@ -6653,6 +6721,12 @@ def _load_checkpoint() -> bool:
session.caller_queue = data.get("caller_queue", [])
session.relationship_context = data.get("relationship_context", {})
session.intern_monitoring = data.get("intern_monitoring", True)
session.caller_model_strategy = data.get("caller_model_strategy", "single")
session.caller_model_pool = data.get("caller_model_pool", ["anthropic/claude-sonnet-4-5"])
session.caller_model_map = data.get("caller_model_map", {})
session.caller_model_fallback = data.get("caller_model_fallback", "anthropic/claude-sonnet-4-5")
session.caller_models = data.get("caller_models", {})
session._caller_model_cycle_idx = data.get("caller_model_cycle_idx", 0)
for key, snapshot in data.get("caller_bases", {}).items():
if key in CALLER_BASES:
CALLER_BASES[key]["name"] = snapshot["name"]
@@ -8563,6 +8637,7 @@ async def chat(request: ChatRequest):
max_tokens=max_tokens,
category="caller_dialog",
caller_name=session.caller.get("name", "") if session.caller else "",
model_override=session.get_caller_model(session.current_caller_key) if session.current_caller_key else None,
)
# Discard if call changed while we were generating
@@ -8953,6 +9028,74 @@ async def set_show_theme(data: dict):
return {"theme": session.show_theme}
# --- Caller Model Routing ---
@app.get("/api/caller-models")
async def get_caller_models():
"""Get current caller model routing config and per-caller assignments."""
assignments = {}
for key in CALLER_BASES:
name = CALLER_BASES[key].get("name", key)
model = session.caller_models.get(key)
assignments[key] = {"name": name, "model": model or "(default)"}
return {
"strategy": session.caller_model_strategy,
"pool": session.caller_model_pool,
"map": session.caller_model_map,
"fallback": session.caller_model_fallback,
"assignments": assignments,
}
@app.post("/api/caller-models")
async def set_caller_models(data: dict):
"""Update caller model routing strategy, pool, map, or fallback."""
if "strategy" in data:
strategy = data["strategy"]
if strategy not in ("single", "cycle", "style_matched"):
raise HTTPException(400, f"Invalid strategy: {strategy}")
session.caller_model_strategy = strategy
print(f"[CallerModel] Strategy set to: {strategy}")
if "pool" in data:
pool = data["pool"]
if not isinstance(pool, list) or not pool:
raise HTTPException(400, "pool must be a non-empty list of model IDs")
session.caller_model_pool = pool
print(f"[CallerModel] Pool set to: {pool}")
if "map" in data:
session.caller_model_map = data["map"]
print(f"[CallerModel] Style map set: {len(data['map'])} entries")
if "fallback" in data:
session.caller_model_fallback = data["fallback"]
print(f"[CallerModel] Fallback set to: {data['fallback']}")
# Clear existing assignments so new strategy takes effect
if "strategy" in data or "pool" in data or "map" in data:
session.caller_models.clear()
session._caller_model_cycle_idx = 0
print(f"[CallerModel] Cleared caller assignments (new config)")
_save_checkpoint()
return await get_caller_models()
@app.post("/api/caller-models/{caller_key}")
async def set_caller_model_override(caller_key: str, data: dict):
"""Override the model for a specific caller mid-show."""
if caller_key not in CALLER_BASES:
raise HTTPException(404, f"Unknown caller key: {caller_key}")
model = data.get("model", "").strip()
if not model:
# Clear override
session.caller_models.pop(caller_key, None)
name = CALLER_BASES[caller_key].get("name", caller_key)
print(f"[CallerModel] Cleared override for {name}")
else:
session.caller_models[caller_key] = model
name = CALLER_BASES[caller_key].get("name", caller_key)
print(f"[CallerModel] Override {name}{model}")
_save_checkpoint()
return {"caller_key": caller_key, "model": session.caller_models.get(caller_key, "(default)")}
# --- Cost Tracking Endpoints ---
@app.get("/api/costs")
@@ -9442,6 +9585,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
max_tokens=max_tokens,
category="caller_dialog",
caller_name=session.caller.get("name", "") if session.caller else "",
model_override=session.get_caller_model(session.current_caller_key) if session.current_caller_key else None,
)
# Discard if call changed during generation
@@ -9543,6 +9687,7 @@ async def ai_respond():
max_tokens=max_tokens,
category="caller_dialog",
caller_name=session.caller.get("name", "") if session.caller else "",
model_override=session.get_caller_model(session.current_caller_key) if session.current_caller_key else None,
)
if _session_epoch != epoch:
+6
View File
@@ -45,6 +45,12 @@ OPENROUTER_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"openai/gpt-4o": {"prompt": 2.50, "completion": 10.00},
"meta-llama/llama-3.1-8b-instruct": {"prompt": 0.06, "completion": 0.06},
"deepseek/deepseek-chat-v3-0324": {"prompt": 0.27, "completion": 1.10},
"moonshotai/kimi-k2": {"prompt": 0.60, "completion": 2.00},
"mistralai/mistral-medium-3": {"prompt": 0.40, "completion": 2.00},
"meta-llama/llama-4-maverick": {"prompt": 0.20, "completion": 0.60},
"qwen/qwen3-235b-a22b": {"prompt": 0.20, "completion": 0.60},
"google/gemini-2.5-pro": {"prompt": 1.25, "completion": 10.00},
}
# TTS pricing per character
+12 -4
View File
@@ -23,6 +23,13 @@ OPENROUTER_MODELS = [
"google/gemini-2.5-flash",
"openai/gpt-4o-mini",
"openai/gpt-4o",
# New dialog models
"deepseek/deepseek-chat-v3-0324",
"moonshotai/kimi-k2",
"mistralai/mistral-medium-3",
"meta-llama/llama-4-maverick",
"qwen/qwen3-235b-a22b",
"google/gemini-2.5-pro",
# Legacy
"anthropic/claude-3-haiku",
"google/gemini-flash-1.5",
@@ -125,12 +132,13 @@ class LLMService:
response_format: Optional[dict] = None,
category: str = "unknown",
caller_name: str = "",
model_override: Optional[str] = None,
) -> str:
if system_prompt:
messages = [{"role": "system", "content": system_prompt}] + messages
if self.provider == "openrouter":
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name, model_override=model_override)
else:
return await self._call_ollama(messages, max_tokens=max_tokens)
@@ -295,11 +303,11 @@ class LLMService:
"""Get the best model for a given category based on config routing."""
return settings.category_models.get(category, self.openrouter_model)
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str:
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "", model_override: Optional[str] = None) -> str:
"""Try category-specific model, then fallback models. Always returns a response."""
# Use category-specific model if configured, otherwise primary
model = self._get_model_for_category(category)
# Use explicit override if provided, else category routing, else primary
model = model_override or self._get_model_for_category(category)
result = await self._call_openrouter_once(messages, model, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
if result is not None:
return result
+78
View File
@@ -463,6 +463,84 @@ section h2 {
line-height: 1.3;
}
/* Caller model indicator */
.info-badge.model {
background: rgba(100, 140, 220, 0.2);
color: #7ab0e8;
font-size: 0.7rem;
cursor: pointer;
}
.caller-model-override {
font-size: 0.7rem;
padding: 2px 4px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(100, 140, 220, 0.3);
border-radius: 4px;
max-width: 140px;
}
/* Caller button model badge */
.model-tag {
font-size: 0.55rem;
color: #7ab0e8;
background: rgba(100, 140, 220, 0.15);
padding: 0 3px;
border-radius: 2px;
font-weight: 700;
letter-spacing: 0.3px;
flex-shrink: 0;
}
/* Caller Models settings section */
.caller-model-row {
margin-bottom: 8px;
}
.caller-model-row label {
margin-bottom: 0;
}
.cm-pool-input {
font-size: 0.8rem;
}
.cm-style-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
max-height: 200px;
overflow-y: auto;
}
.cm-style-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 3px 6px;
}
.cm-style-name {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.cm-style-select {
font-size: 0.7rem;
padding: 2px 3px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: 4px;
max-width: 110px;
}
.caller-background-full {
margin-top: 8px;
font-size: 0.75rem;
+32
View File
@@ -75,6 +75,8 @@
<span id="caller-shape-badge" class="info-badge shape"></span>
<span id="caller-energy-badge" class="info-badge energy"></span>
<span id="caller-emotion" class="info-badge emotion"></span>
<span id="caller-model-badge" class="info-badge model"></span>
<select id="caller-model-override" class="caller-model-override hidden"></select>
</div>
<div id="caller-signature" class="caller-signature"></div>
<div id="caller-situation" class="caller-situation"></div>
@@ -285,6 +287,36 @@
</div>
</div>
<!-- Caller Model Routing -->
<div class="settings-group">
<h3>Caller Models</h3>
<div class="caller-model-row">
<label>
Strategy
<select id="cm-strategy">
<option value="single">Single Model</option>
<option value="cycle">Cycle Models</option>
<option value="style_matched">Style-Matched</option>
</select>
</label>
</div>
<div id="cm-pool-section" class="hidden">
<label>
Model Pool
<input type="text" id="cm-pool" class="cm-pool-input" placeholder="x-ai/grok-4, deepseek/deepseek-v3.2, ...">
</label>
</div>
<div id="cm-style-map" class="hidden">
<div class="cm-style-grid" id="cm-style-grid"></div>
</div>
<div class="caller-model-row">
<label>
Fallback Model
<select id="cm-fallback" class="model-select"></select>
</label>
</div>
</div>
<!-- TTS Settings -->
<div class="settings-group">
<h3>TTS Provider</h3>
+214
View File
@@ -131,6 +131,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initEventListeners();
initClock();
loadShowTheme();
loadCallerModels();
loadVoicemails();
setInterval(loadVoicemails, 30000);
loadEmails();
@@ -356,6 +357,27 @@ function initEventListeners() {
else if (e.key === 'Escape') e.target.blur();
});
// Caller Models
document.getElementById('cm-strategy')?.addEventListener('change', () => {
callerModelSettings.strategy = document.getElementById('cm-strategy').value;
updateCallerModelUI();
});
document.getElementById('caller-model-badge')?.addEventListener('click', () => {
const sel = document.getElementById('caller-model-override');
if (!sel || !currentCaller) return;
sel.classList.toggle('hidden');
if (!sel.classList.contains('hidden')) {
const current = callerModelAssignments[currentCaller.key];
if (current) sel.value = current;
}
});
document.getElementById('caller-model-override')?.addEventListener('change', (e) => {
if (currentCaller && e.target.value) {
overrideCallerModel(currentCaller.key, e.target.value);
e.target.classList.add('hidden');
}
});
// Settings
document.getElementById('settings-btn')?.addEventListener('click', async () => {
document.getElementById('settings-modal')?.classList.remove('hidden');
@@ -637,6 +659,7 @@ async function loadCallers() {
}
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id);
updateCallerModelBadges();
} catch (err) {
console.error('loadCallers error:', err);
}
@@ -701,6 +724,8 @@ async function startCall(key, name) {
if (situation) situation.textContent = ci.situation_summary || '';
infoPanel.classList.remove('hidden');
}
showCallerModelBadge(callerModelAssignments[key] || data.model);
document.getElementById('caller-model-override')?.classList.add('hidden');
const bgEl = document.getElementById('caller-background');
if (bgEl && data.background) bgEl.textContent = data.background;
@@ -731,6 +756,7 @@ async function newSession() {
// Reload callers to get new session ID
await loadCallers();
await loadShowTheme();
await loadCallerModels();
log('New session started - all callers have fresh backgrounds');
}
@@ -760,6 +786,8 @@ async function hangup() {
document.getElementById('caller-info-panel')?.classList.add('hidden');
const bgDetails2 = document.getElementById('caller-background-details');
if (bgDetails2) bgDetails2.classList.add('hidden');
showCallerModelBadge(null);
document.getElementById('caller-model-override')?.classList.add('hidden');
// Hide AI caller indicator
document.getElementById('ai-caller-info')?.classList.add('hidden');
@@ -1302,6 +1330,187 @@ async function clearShowTheme() {
}
// --- Caller Model Routing ---
const MODEL_ABBREVS = {
'claude-sonnet-4-5': 'Son', 'claude-haiku-4.5': 'Hai', 'claude-3-haiku': 'H3',
'grok-4': 'Grk', 'grok-4-fast': 'GrF',
'minimax-m2-her': 'MnM', 'mistral-small-creative': 'Mis',
'deepseek-v3.2': 'DSk', 'gemini-2.5-flash': 'Gem', 'gemini-flash-1.5': 'Gm1',
'gpt-4o-mini': '4oM', 'gpt-4o': '4o', 'llama-3.1-8b-instruct': 'Lla',
};
const CALLER_STYLES = [
'quiet_nervous', 'storyteller', 'deadpan', 'high_energy', 'confrontational',
'oversharer', 'philosopher', 'bragger', 'first_time', 'emotional',
'world_weary', 'conspiracy', 'comedian', 'angry_venting', 'sweet_earnest',
'mysterious', 'know_it_all', 'rambling',
];
let callerModelSettings = { strategy: 'single', pool: [], fallback: '', style_map: {} };
let callerModelAssignments = {}; // key -> model_id
function modelAbbrev(modelId) {
const name = (modelId || '').split('/').pop();
return MODEL_ABBREVS[name] || name.substring(0, 3).toUpperCase();
}
async function loadCallerModels() {
try {
const res = await fetch('/api/caller-models');
if (!res.ok) return;
const data = await res.json();
callerModelSettings = {
strategy: data.strategy || 'single',
pool: data.pool || [],
fallback: data.fallback || '',
style_map: data.style_map || {},
};
callerModelAssignments = data.assignments || {};
updateCallerModelUI();
updateCallerModelBadges();
} catch (e) {
console.error('Failed to load caller models:', e);
}
}
function updateCallerModelUI() {
const strategyEl = document.getElementById('cm-strategy');
if (strategyEl) strategyEl.value = callerModelSettings.strategy;
const poolSection = document.getElementById('cm-pool-section');
const styleMap = document.getElementById('cm-style-map');
if (poolSection) poolSection.classList.toggle('hidden', callerModelSettings.strategy === 'single');
if (styleMap) styleMap.classList.toggle('hidden', callerModelSettings.strategy !== 'style_matched');
const poolInput = document.getElementById('cm-pool');
if (poolInput) poolInput.value = callerModelSettings.pool.join(', ');
// Populate style map grid
const grid = document.getElementById('cm-style-grid');
if (grid && callerModelSettings.strategy === 'style_matched') {
grid.innerHTML = '';
for (const style of CALLER_STYLES) {
const item = document.createElement('div');
item.className = 'cm-style-item';
const label = style.replace(/_/g, ' ');
item.innerHTML = `<span class="cm-style-name">${label}</span>`;
const sel = document.createElement('select');
sel.className = 'cm-style-select';
sel.dataset.style = style;
for (const m of callerModelSettings.pool) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.style_map[style]) opt.selected = true;
sel.appendChild(opt);
}
item.appendChild(sel);
grid.appendChild(item);
}
}
// Fallback dropdown
const fallbackEl = document.getElementById('cm-fallback');
if (fallbackEl) {
const currentVal = fallbackEl.value;
fallbackEl.innerHTML = '';
const models = callerModelSettings.pool.length > 0
? callerModelSettings.pool
: (window._openrouterModels || []);
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.fallback) opt.selected = true;
fallbackEl.appendChild(opt);
}
if (!fallbackEl.value && currentVal) fallbackEl.value = currentVal;
}
}
function updateCallerModelBadges() {
document.querySelectorAll('.caller-btn').forEach(btn => {
const key = btn.dataset.key;
const model = callerModelAssignments[key];
let tag = btn.querySelector('.model-tag');
if (model) {
if (!tag) {
tag = document.createElement('span');
tag.className = 'model-tag';
btn.appendChild(tag);
}
tag.textContent = modelAbbrev(model);
tag.title = model;
} else if (tag) {
tag.remove();
}
});
}
function showCallerModelBadge(model) {
const badge = document.getElementById('caller-model-badge');
if (badge) {
badge.textContent = model ? `via ${modelAbbrev(model)}` : '';
badge.title = model || '';
badge.classList.toggle('hidden', !model);
}
}
function populateCallerModelOverride() {
const sel = document.getElementById('caller-model-override');
if (!sel) return;
sel.innerHTML = '';
const models = window._openrouterModels || [];
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
sel.appendChild(opt);
}
}
async function overrideCallerModel(callerKey, modelId) {
try {
const res = await fetch(`/api/caller-models/${callerKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelId })
});
if (!res.ok) throw new Error(res.status);
callerModelAssignments[callerKey] = modelId;
showCallerModelBadge(modelId);
updateCallerModelBadges();
log(`Model override: ${currentCaller?.name || callerKey}${modelAbbrev(modelId)}`);
} catch (err) {
log('Model override failed: ' + err.message);
}
}
async function saveCallerModels() {
const strategy = document.getElementById('cm-strategy')?.value || 'single';
const poolRaw = document.getElementById('cm-pool')?.value || '';
const pool = poolRaw.split(',').map(s => s.trim()).filter(Boolean);
const fallback = document.getElementById('cm-fallback')?.value || '';
const style_map = {};
document.querySelectorAll('.cm-style-select').forEach(sel => {
if (sel.value) style_map[sel.dataset.style] = sel.value;
});
try {
const res = await fetch('/api/caller-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy, pool, fallback, style_map })
});
if (!res.ok) throw new Error(res.status);
callerModelSettings = { strategy, pool, fallback, style_map };
} catch (err) {
log('Caller model save failed: ' + err.message);
}
}
// --- Settings ---
async function loadSettings() {
try {
@@ -1357,6 +1566,8 @@ async function loadSettings() {
// Category model routing
const models = data.available_openrouter_models || [];
window._openrouterModels = models;
populateCallerModelOverride();
const categoryModels = data.category_models || {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
for (const cat of categories) {
@@ -1390,6 +1601,9 @@ async function saveSettings() {
// Save audio devices
await saveAudioDevices();
// Save caller model routing
await saveCallerModels();
// Collect category model routing
const categoryModels = {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];