use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_OPENAI_EMBEDDING_MODEL: &str = "text-embedding-3-small";
pub const DEFAULT_GOOGLE_EMBEDDING_MODEL: &str = "gemini-embedding-001";
pub const DEFAULT_LOCAL_EMBEDDING_MODEL: &str = "all-MiniLM-L6-v2";
pub const DEFAULT_OPENAI_COMPAT_EMBEDDING_MODEL: &str = DEFAULT_OPENAI_EMBEDDING_MODEL;
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const GEMINI_API_KEY_ENV_VAR: &str = "GEMINI_API_KEY";
pub const OPENAI_COMPAT_API_KEY_ENV_VAR: &str = "FRIGG_OPENAI_COMPAT_API_KEY";
pub const OPENAI_COMPAT_ENDPOINT_ENV_VAR: &str = "FRIGG_SEMANTIC_RUNTIME_OPENAI_COMPAT_ENDPOINT";
pub const SEMANTIC_RUNTIME_INVALID_PARAMS_CODE: &str = "invalid_params";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticRuntimeProvider {
OpenAi,
OpenAiCompat,
Google,
Local,
}
impl SemanticRuntimeProvider {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::OpenAiCompat => "openai_compat",
Self::Google => "google",
Self::Local => "local",
}
}
pub fn api_key_env_var(self) -> Option<&'static str> {
match self {
Self::OpenAi => Some(OPENAI_API_KEY_ENV_VAR),
Self::OpenAiCompat => Some(OPENAI_COMPAT_API_KEY_ENV_VAR),
Self::Google => Some(GEMINI_API_KEY_ENV_VAR),
Self::Local => None,
}
}
pub fn default_model(self) -> &'static str {
match self {
Self::OpenAi | Self::OpenAiCompat => DEFAULT_OPENAI_EMBEDDING_MODEL,
Self::Google => DEFAULT_GOOGLE_EMBEDDING_MODEL,
Self::Local => DEFAULT_LOCAL_EMBEDDING_MODEL,
}
}
pub fn requires_endpoint(self) -> bool {
matches!(self, Self::OpenAiCompat)
}
}
impl std::fmt::Display for SemanticRuntimeProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SemanticRuntimeProvider {
type Err = String;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"openai" => Ok(Self::OpenAi),
"openai_compat" | "openai-compat" | "openai_compatible" => Ok(Self::OpenAiCompat),
"google" => Ok(Self::Google),
"local" => Ok(Self::Local),
_ => Err(format!(
"semantic runtime provider must be one of: openai, openai_compat, google, local (received: {normalized})"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SemanticRuntimeConfig {
pub enabled: bool,
pub provider: Option<SemanticRuntimeProvider>,
pub model: Option<String>,
pub strict_mode: bool,
pub openai_compat_endpoint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SemanticRuntimeCredentials {
pub openai_api_key: Option<String>,
pub openai_compat_api_key: Option<String>,
pub gemini_api_key: Option<String>,
}
impl SemanticRuntimeCredentials {
pub fn from_process_env() -> Self {
Self {
openai_api_key: std::env::var(OPENAI_API_KEY_ENV_VAR).ok(),
openai_compat_api_key: std::env::var(OPENAI_COMPAT_API_KEY_ENV_VAR).ok(),
gemini_api_key: std::env::var(GEMINI_API_KEY_ENV_VAR).ok(),
}
}
pub fn api_key_for(&self, provider: SemanticRuntimeProvider) -> Option<&str> {
match provider {
SemanticRuntimeProvider::OpenAi => self.openai_api_key.as_deref(),
SemanticRuntimeProvider::OpenAiCompat => self
.openai_compat_api_key
.as_deref()
.or(self.openai_api_key.as_deref()),
SemanticRuntimeProvider::Google => self.gemini_api_key.as_deref(),
SemanticRuntimeProvider::Local => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SemanticRuntimeConfigError {
#[error("semantic_runtime.provider is required when semantic_runtime.enabled=true")]
MissingProvider,
#[error("semantic_runtime.model must not be blank when semantic_runtime.enabled=true")]
BlankModel,
#[error(
"semantic_runtime.openai_compat_endpoint is required when provider=openai_compat (set --semantic-runtime-openai-compat-endpoint or {OPENAI_COMPAT_ENDPOINT_ENV_VAR})"
)]
MissingOpenAiCompatEndpoint,
#[error(
"semantic_runtime.openai_compat_endpoint must not be blank when provider=openai_compat"
)]
BlankOpenAiCompatEndpoint,
#[error(
"semantic_runtime.openai_compat_endpoint must be an absolute http(s) URL (received: {endpoint})"
)]
InvalidOpenAiCompatEndpoint { endpoint: String },
}
impl SemanticRuntimeConfigError {
pub fn code(&self) -> &'static str {
SEMANTIC_RUNTIME_INVALID_PARAMS_CODE
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SemanticRuntimeCredentialError {
#[error("semantic runtime provider={provider} requires {env_var} to be set")]
MissingApiKey {
provider: SemanticRuntimeProvider,
env_var: &'static str,
},
#[error("semantic runtime provider={provider} requires {env_var} to be non-empty")]
BlankApiKey {
provider: SemanticRuntimeProvider,
env_var: &'static str,
},
}
impl SemanticRuntimeCredentialError {
pub fn code(&self) -> &'static str {
SEMANTIC_RUNTIME_INVALID_PARAMS_CODE
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SemanticRuntimeStartupError {
#[error("{0}")]
Config(#[from] SemanticRuntimeConfigError),
#[error("{0}")]
Credentials(#[from] SemanticRuntimeCredentialError),
}
impl SemanticRuntimeStartupError {
pub fn code(&self) -> &'static str {
SEMANTIC_RUNTIME_INVALID_PARAMS_CODE
}
}
impl SemanticRuntimeConfig {
pub fn validate(&self) -> Result<(), SemanticRuntimeConfigError> {
if !self.enabled {
return Ok(());
}
let provider = self
.provider
.ok_or(SemanticRuntimeConfigError::MissingProvider)?;
if self
.model
.as_deref()
.is_some_and(|model| model.trim().is_empty())
{
return Err(SemanticRuntimeConfigError::BlankModel);
}
if provider.requires_endpoint() {
self.validate_openai_compat_endpoint()?;
}
Ok(())
}
pub fn normalized_model(&self) -> Option<&str> {
match self.model.as_deref() {
Some(model) => {
let normalized = model.trim();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
None => self.provider.map(SemanticRuntimeProvider::default_model),
}
}
pub fn normalized_openai_compat_endpoint(&self) -> Option<&str> {
self.openai_compat_endpoint
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn validate_openai_compat_endpoint(&self) -> Result<(), SemanticRuntimeConfigError> {
let Some(raw) = self.openai_compat_endpoint.as_deref() else {
return Err(SemanticRuntimeConfigError::MissingOpenAiCompatEndpoint);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(SemanticRuntimeConfigError::BlankOpenAiCompatEndpoint);
}
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return Err(SemanticRuntimeConfigError::InvalidOpenAiCompatEndpoint {
endpoint: trimmed.to_owned(),
});
}
Ok(())
}
pub fn validate_startup(
&self,
credentials: &SemanticRuntimeCredentials,
) -> Result<(), SemanticRuntimeStartupError> {
self.validate()?;
if !self.enabled {
return Ok(());
}
let provider = self
.provider
.ok_or(SemanticRuntimeConfigError::MissingProvider)?;
if let Some(env_var) = provider.api_key_env_var() {
let Some(api_key) = credentials.api_key_for(provider) else {
return Err(
SemanticRuntimeCredentialError::MissingApiKey { provider, env_var }.into(),
);
};
if api_key.trim().is_empty() {
return Err(
SemanticRuntimeCredentialError::BlankApiKey { provider, env_var }.into(),
);
}
}
Ok(())
}
}