AI Services
Loquent routes all AI calls — background services, text agents, and the instruction builder — through OpenRouter. A single API key accesses models from OpenAI, Google, and Anthropic. Admins can override the model used for any AI area from the admin panel without code changes.
Configuration
Section titled “Configuration”The openrouter_api_key lives in the core_conf database table. Load it via the standard config pattern:
use crate::bases::conf::get_core_conf;use crate::mods::ai::OpenRouterCoreConf;
let conf: OpenRouterCoreConf = get_core_conf().await?;Set the key during setup by adding OPENROUTER_API_KEY to seed.env. The migration seeds it into core_conf automatically.
Model Registry
Section titled “Model Registry”Each background AI task maps to an AiArea — a named slot that resolves to an OpenRouter model ID at runtime. The AiModels struct holds the hardcoded defaults, and the ai_model_config table stores admin overrides.
Default Models
Section titled “Default Models”| Area | Default Model | Tier |
|---|---|---|
| Auto Tag Contact | deepseek/deepseek-v3.2 | Simple |
| Identify Speakers | deepseek/deepseek-v3.2 | Simple |
| Knowledge Query | google/gemini-3.1-flash-lite-preview | Simple |
| Summarize Call | google/gemini-3.1-pro-preview | Medium |
| Update System Note | google/gemini-3.1-pro-preview | Medium |
| Enrich Contact | deepseek/deepseek-v3.2 | Medium |
| Enrich Contact from Messages | google/gemini-3.1-flash-lite-preview | Medium |
| Extract Todos | google/gemini-3.1-pro-preview | Medium |
| Analyze Call | google/gemini-3.1-pro-preview | Medium |
| Execute Todo | anthropic/claude-sonnet-4.6 | Complex |
| Generate Instructions | anthropic/claude-sonnet-4.6 | Complex |
| Edit Instructions | anthropic/claude-sonnet-4.6 | Complex |
| Custom Edit Instructions | anthropic/claude-sonnet-4.6 | Complex |
| Execute Plan | anthropic/claude-sonnet-4.6 | Complex |
| Execute Plan (Fallback) | google/gemini-3.1-pro-preview | Complex |
| Create Plan from Call | anthropic/claude-opus-4.6 | Plan Creator |
| Create Plan from SMS | anthropic/claude-opus-4.6 | Plan Creator |
| Create Plan from Template | anthropic/claude-opus-4.6 | Plan Creator |
| Text Agent Suggestions | google/gemini-3.1-pro-preview | Text Agent |
| Platform Assistant (Vernis) | google/gemini-3.1-flash-lite-preview | Assistant |
AiArea Enum
Section titled “AiArea Enum”The AiArea enum identifies each configurable AI slot. Every variant has a stable string key (stored in the database), a human-readable label, a default model, and a complexity tier.
pub enum AiArea { AutoTagContact, // key: "auto_tag_contact" IdentifySpeakers, // key: "identify_speakers" KnowledgeQuery, // key: "knowledge_query" SummarizeCall, // key: "summarize_call" // ... 16 more variants}
impl AiArea { pub fn key(&self) -> &'static str { /* ... */ } pub fn label(&self) -> &'static str { /* ... */ } pub fn default_model(&self) -> &'static str { /* ... */ } pub fn tier(&self) -> &'static str { /* ... */ } pub fn from_key(key: &str) -> Option<Self> { /* ... */ }}Model Resolution
Section titled “Model Resolution”resolve_model() is the single entry point every AI service uses to determine which model to call. It checks the ai_model_config table for an admin override; if none exists, it returns the hardcoded default.
pub async fn resolve_model(area: AiArea) -> Result<String, AppError>Behavior:
- Queries
ai_model_configfor a row matchingarea.key() - If found → returns the stored
model_id - If not found → returns
area.default_model() - If the table doesn’t exist yet (pre-migration) → warns and returns the default
Services use it like this:
use crate::mods::ai::{AiArea, resolve_model};
let model_id = resolve_model(AiArea::SummarizeCall).await?;
let model = Openrouter::<DynamicModel>::builder() .api_key(conf.openrouter_api_key) .model_name(&model_id) .build()?;Admin Configuration
Section titled “Admin Configuration”Super admins can change the model for any AI area from Admin → AI Models.
API Endpoints
Section titled “API Endpoints”List all model configs
Section titled “List all model configs”GET /api/admin/ai-model-configsReturns all 20 AI areas with their current model, default model, override status, and who last changed it. Requires super admin.
Response:
struct AiModelConfigData { entries: Vec<AiModelConfigEntry>,}
struct AiModelConfigEntry { area: String, // "summarize_call" label: String, // "Summarize Call" tier: String, // "Medium" model_id: String, // Current active model default_model_id: String, // Hardcoded default is_default: bool, // true if no override exists updated_by: Option<String>,// Name of admin who last changed it updated_at: Option<String>,// When it was last changed}Update a model
Section titled “Update a model”PUT /api/admin/ai-model-configSets the model for one AI area. If the new model matches the default, the override row is deleted (reset to default). Requires super admin.
Request:
struct UpdateAiModelConfig { area: String, // "summarize_call" model_id: String, // "openai/gpt-4.1"}Both endpoints are audit-logged via record_admin_audit_entry with action types config.ai_model.update and config.ai_model.reset.
Database Schema
Section titled “Database Schema”The ai_model_config table stores only overrides — areas using the default have no row.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
area | TEXT | Unique — matches AiArea::key() |
model_id | TEXT | OpenRouter model identifier |
updated_by | UUID | FK to user.id, cascade delete |
updated_at | Timestamptz | Auto-set on upsert |
The upsert uses INSERT ... ON CONFLICT (area) DO UPDATE.
Building a Request
Section titled “Building a Request”Every AI service follows the same pattern — resolve the model, load the config, send the request:
use aisdk::core::{DynamicModel, LanguageModelRequest};use aisdk::providers::openrouter::Openrouter;use crate::mods::ai::{AiArea, OpenRouterCoreConf, resolve_model};
let conf: OpenRouterCoreConf = get_core_conf().await?;let model_id = resolve_model(AiArea::EnrichContact).await?;
let model = Openrouter::<DynamicModel>::builder() .api_key(conf.openrouter_api_key) .model_name(&model_id) .build() .map_err(|e| AppError::Internal(e.to_string()))?;
let response = LanguageModelRequest::builder() .model(model) .system("Your system prompt") .prompt("User input") .build() .generate_text() .await .map_err(|e| AppError::Internal(e.to_string()))?;Structured Output
Section titled “Structured Output”For type-safe responses, define a schema struct and pass it to .schema::<T>():
#[derive(schemars::JsonSchema, serde::Deserialize, Debug)]#[schemars(deny_unknown_fields)]struct CallerInfo { first_name: String, last_name: String, email: Option<String>,}
let response = LanguageModelRequest::builder() .model(model) .system("Extract caller information from the transcription.") .prompt(transcription) .schema::<CallerInfo>() .build() .generate_text() .await?;
let caller: CallerInfo = response.into_schema()?;Text Agent Models
Section titled “Text Agent Models”Text agents let users pick their model from the UI. The TextAgentModel enum in src/mods/text_agent/types/text_agent_model_type.rs defines the available options:
| Model | OpenRouter ID | Provider |
|---|---|---|
| GPT-4.1 | openai/gpt-4.1 | OpenAI |
| GPT-5.3 Chat | openai/gpt-5.3-chat | OpenAI |
| Gemini 3 Flash | google/gemini-3-flash-preview | |
| Gemini 3.1 Pro | google/gemini-3.1-pro-preview | |
| Gemini 3.1 Flash Lite | google/gemini-3.1-flash-lite-preview | |
| Claude Sonnet 4.6 | anthropic/claude-sonnet-4.6 | Anthropic |
At runtime, model.openrouter_id() converts the enum to the OpenRouter model string. Old model keys (from before the migration) are mapped to their closest equivalents via FromStr.
Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
src/mods/ai/types/openrouter_core_conf_type.rs | Config struct for API key |
src/mods/ai/types/ai_models_type.rs | AiModels defaults + AiArea enum |
src/mods/ai/services/resolve_model_service.rs | Runtime model resolution (DB override → default) |
src/mods/admin/api/get_ai_model_configs_api.rs | List all model configs (super admin) |
src/mods/admin/api/update_ai_model_config_api.rs | Update model for an area (super admin) |
src/mods/admin/services/admin_ai_model_config_service.rs | Model config service (get/upsert/reset) |
src/mods/admin/components/admin_ai_models_tab_component.rs | Admin UI for model selection |
migration/src/m20260320_130000_create_ai_model_config_table.rs | ai_model_config table migration |
src/mods/text_agent/types/text_agent_model_type.rs | User-facing model enum |