refactor(service): clean up agent modules, use agent crate types

- service now delegates model/provider/pricing logic to agent crate
- ChatService built at startup with EmbedService (graceful degradation)
- RoomService wired with EmbedService for Qdrant embedding
- Add error types for embedding service
This commit is contained in:
ZhenYi 2026-04-25 20:09:45 +08:00
parent 10c0cc007b
commit 881fbdb6ea
15 changed files with 224 additions and 1010 deletions

View File

@ -38,7 +38,6 @@ session = { workspace = true }
argon2 = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
sea-orm = { workspace = true, features = [] }
async-openai = { version = "0.34.0", features = ["chat-completion"] }
reqwest = { workspace = true, features = ["json", "native-tls"] }
base64 = { workspace = true }
rsa = { workspace = true }

View File

@ -1,218 +1,24 @@
//! AI usage billing — records token costs against a project or workspace balance.
//!
//! Called by service-layer code after each successful AI call. If the project
//! belongs to a workspace, the cost is deducted from the workspace's shared quota
//! (workspace_billing). Otherwise it is deducted from the project's own quota.
//!
//! 1. Queries the most recent active price for `model_id`.
//! 2. Computes `cost = (input/1000)*input_price + (output/1000)*output_price`.
//! 3. Determines whether to bill the project or its workspace.
//! 4. Writes a billing_history entry and decrements the appropriate balance.
//! Billing — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use models::agents::model_pricing;
use models::projects::project;
use models::projects::project_billing;
use models::projects::project_billing_history;
use models::workspaces::workspace_billing;
use models::workspaces::workspace_billing_history;
use rust_decimal::Decimal;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
/// Breakdown of a billing record.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BillingRecord {
/// Total cost in the billing currency.
pub cost: f64,
pub currency: String,
pub input_tokens: i64,
pub output_tokens: i64,
}
impl AppService {
/// Record AI usage for a project.
///
/// If the project belongs to a workspace, the cost is deducted from the
/// workspace's shared quota. Otherwise it is deducted from the project's own
/// billing balance.
///
/// Returns an error if there is insufficient balance.
pub async fn record_ai_usage(
&self,
project_uid: Uuid,
model_id: Uuid,
input_tokens: i64,
output_tokens: i64,
) -> Result<BillingRecord, AppError> {
// 1. Look up the active price for this model.
let pricing = model_pricing::Entity::find()
.filter(model_pricing::Column::ModelVersionId.eq(model_id))
.order_by_desc(model_pricing::Column::EffectiveFrom)
.one(&self.db)
.await?
.ok_or_else(|| {
AppError::InternalServerError(
"No pricing record found for this model. Please configure AI model pricing first."
.into(),
)
})?;
// 2. Compute cost using Decimal arithmetic.
let input_price: Decimal = pricing
.input_price_per_1k_tokens
.parse()
.unwrap_or(Decimal::ZERO);
let output_price: Decimal = pricing
.output_price_per_1k_tokens
.parse()
.unwrap_or(Decimal::ZERO);
let tokens_i = Decimal::from(input_tokens);
let tokens_o = Decimal::from(output_tokens);
let thousand = Decimal::from(1000);
let total_cost: f64 = ((tokens_i / thousand) * input_price
+ (tokens_o / thousand) * output_price)
.to_string()
.parse()
.unwrap_or(0.0);
let currency = pricing.currency.clone();
// 3. Determine whether to bill the project or its workspace.
let proj = project::Entity::find_by_id(project_uid)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound("Project not found".to_string()))?;
if let Some(workspace_id) = proj.workspace_id {
// ── Workspace-shared quota ──────────────────────────────────
let current = workspace_billing::Entity::find_by_id(workspace_id)
.one(&self.db)
.await?
.ok_or_else(|| {
AppError::NotFound("Workspace billing account not found".to_string())
})?;
let current_balance: f64 = current.balance.to_string().parse().unwrap_or(0.0);
if current_balance < total_cost {
return Err(AppError::BadRequest(format!(
"Insufficient workspace billing balance. Required: {:.4} {}, Available: {:.4} {}",
total_cost, currency, current_balance, currency
)));
}
let amount_dec = Decimal::from_f64_retain(-total_cost).unwrap_or(Decimal::ZERO);
let now = chrono::Utc::now();
// Insert workspace billing history.
let _ = workspace_billing_history::ActiveModel {
uid: Set(Uuid::new_v4()),
workspace_id: Set(workspace_id),
user_id: Set(Some(proj.created_by)),
amount: Set(amount_dec),
currency: Set(currency.clone()),
reason: Set(format!("ai_usage:{}", project_uid)),
extra: Set(Some(serde_json::json!({
"project_id": project_uid.to_string(),
"model_id": model_id.to_string(),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}))),
created_at: Set(now),
}
.insert(&self.db)
.await;
// Deduct from workspace balance.
let new_balance =
Decimal::from_f64_retain(current_balance - total_cost).unwrap_or(Decimal::ZERO);
let mut updated: workspace_billing::ActiveModel = current.into();
updated.balance = Set(new_balance);
updated.updated_at = Set(now);
updated.update(&self.db).await?;
tracing::info!(
project_id = %project_uid,
model_id = %model_id,
input_tokens = input_tokens,
output_tokens = output_tokens,
cost = %total_cost,
currency = %currency,
workspace_id = %workspace_id.to_string(),
"ai_usage_recorded"
);
Ok(BillingRecord {
cost: total_cost,
currency,
input_tokens,
output_tokens,
})
} else {
// ── Project-owned quota ─────────────────────────────────────
let amount_dec = Decimal::from_f64_retain(-total_cost).unwrap_or(Decimal::ZERO);
let _ = project_billing_history::ActiveModel {
uid: Set(Uuid::new_v4()),
project: Set(project_uid),
user: Set(None),
amount: Set(amount_dec),
currency: Set(currency.clone()),
reason: Set("ai_usage".to_string()),
extra: Set(Some(serde_json::json!({
"model_id": model_id.to_string(),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}))),
created_at: Set(chrono::Utc::now()),
..Default::default()
}
.insert(&self.db)
.await;
let current = project_billing::Entity::find_by_id(project_uid)
.one(&self.db)
.await?
.ok_or_else(|| {
AppError::NotFound("Project billing account not found".to_string())
})?;
let current_balance: f64 = current.balance.to_string().parse().unwrap_or(0.0);
if current_balance < total_cost {
return Err(AppError::BadRequest(format!(
"Insufficient billing balance. Required: {:.4} {}, Available: {:.4} {}",
total_cost, currency, current_balance, currency
)));
}
let new_balance =
Decimal::from_f64_retain(current_balance - total_cost).unwrap_or(Decimal::ZERO);
let mut updated: project_billing::ActiveModel = current.into();
updated.balance = Set(new_balance);
updated.update(&self.db).await?;
tracing::info!(
project_id = %project_uid,
model_id = %model_id,
input_tokens = input_tokens,
output_tokens = output_tokens,
cost = %total_cost,
currency = %currency,
"ai_usage_recorded"
);
Ok(BillingRecord {
cost: total_cost,
currency,
input_tokens,
output_tokens,
})
}
) -> Result<agent::billing::BillingRecord, AppError> {
Ok(agent::billing::record_ai_usage(
&self.db,
project_uid,
model_id,
input_tokens,
output_tokens,
)
.await?)
}
}

View File

@ -18,7 +18,7 @@ use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::billing::BillingRecord;
use agent::billing::BillingRecord;
const AI_BOT_UUID: Uuid = Uuid::nil();
@ -401,16 +401,7 @@ async fn call_ai_model(
let client_config = agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![
async_openai::types::chat::ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
prompt.to_string(),
),
..Default::default()
},
),
];
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None, None)
.await

View File

@ -109,15 +109,7 @@ async fn call_ai_for_triage(
let client_config =
::agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![async_openai::types::chat::ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content:
async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
prompt.to_string(),
),
..Default::default()
},
)];
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
let response = ::agent::call_with_params(
&messages,

View File

@ -1,197 +1,54 @@
//! Model management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::agents::model;
use models::agents::{
ModelCapability, ModelModality, ModelStatus,
model::{Column as MColumn, Entity as MEntity},
model_provider::Entity as ProviderEntity,
};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::provider::require_system_caller;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateModelRequest {
pub provider_id: Uuid,
pub name: String,
pub modality: String,
pub capability: String,
pub context_length: i64,
pub max_output_tokens: Option<i64>,
pub training_cutoff: Option<chrono::DateTime<Utc>>,
#[serde(default)]
pub is_open_source: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateModelRequest {
pub display_name: Option<String>,
pub modality: Option<String>,
pub capability: Option<String>,
pub context_length: Option<i64>,
pub max_output_tokens: Option<i64>,
pub training_cutoff: Option<chrono::DateTime<Utc>>,
pub is_open_source: Option<bool>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelResponse {
pub id: Uuid,
pub provider_id: Uuid,
pub name: String,
pub modality: String,
pub capability: String,
pub context_length: i64,
pub max_output_tokens: Option<i64>,
pub training_cutoff: Option<chrono::DateTime<Utc>>,
pub is_open_source: bool,
pub status: String,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
impl From<model::Model> for ModelResponse {
fn from(m: model::Model) -> Self {
Self {
id: m.id,
provider_id: m.provider_id,
name: m.name,
modality: m.modality,
capability: m.capability,
context_length: m.context_length,
max_output_tokens: m.max_output_tokens,
training_cutoff: m.training_cutoff,
is_open_source: m.is_open_source,
status: m.status,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
}
pub use agent::model::model_entry::{CreateModelRequest, ModelResponse, UpdateModelRequest};
impl AppService {
pub async fn agent_model_list(
&self,
provider_id: Option<Uuid>,
_ctx: &Session,
) -> Result<Vec<ModelResponse>, AppError> {
let mut query = MEntity::find().order_by_asc(MColumn::Name);
if let Some(pid) = provider_id {
query = query.filter(MColumn::ProviderId.eq(pid));
}
let models = query.all(&self.db).await?;
Ok(models.into_iter().map(ModelResponse::from).collect())
) -> Result<Vec<agent::model::model_entry::ModelResponse>, AppError> {
Ok(agent::model::model_entry::list_models(&self.db, provider_id).await?)
}
pub async fn agent_model_get(
&self,
id: Uuid,
_ctx: &Session,
) -> Result<ModelResponse, AppError> {
let model = MEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Model not found".to_string()))?;
Ok(ModelResponse::from(model))
) -> Result<agent::model::model_entry::ModelResponse, AppError> {
Ok(agent::model::model_entry::get_model(&self.db, id).await?)
}
pub async fn agent_model_create(
&self,
request: CreateModelRequest,
request: agent::model::model_entry::CreateModelRequest,
ctx: &Session,
) -> Result<ModelResponse, AppError> {
require_system_caller(ctx)?;
ProviderEntity::find_by_id(request.provider_id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Provider not found".to_string()))?;
let _ = request
.modality
.parse::<ModelModality>()
.map_err(|_| AppError::BadRequest("Invalid modality".to_string()))?;
let _ = request
.capability
.parse::<ModelCapability>()
.map_err(|_| AppError::BadRequest("Invalid capability".to_string()))?;
let now = Utc::now();
let active = model::ActiveModel {
id: Set(Uuid::now_v7()),
provider_id: Set(request.provider_id),
name: Set(request.name),
modality: Set(request.modality),
capability: Set(request.capability),
context_length: Set(request.context_length),
max_output_tokens: Set(request.max_output_tokens),
training_cutoff: Set(request.training_cutoff),
is_open_source: Set(request.is_open_source),
status: Set(ModelStatus::Active.to_string()),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
let model = active.insert(&self.db).await?;
Ok(ModelResponse::from(model))
) -> Result<agent::model::model_entry::ModelResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::model_entry::create_model(&self.db, request).await?)
}
pub async fn agent_model_update(
&self,
id: Uuid,
request: UpdateModelRequest,
request: agent::model::model_entry::UpdateModelRequest,
ctx: &Session,
) -> Result<ModelResponse, AppError> {
require_system_caller(ctx)?;
let model = MEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Model not found".to_string()))?;
let mut active: model::ActiveModel = model.into();
if let Some(modality) = request.modality {
let _ = modality
.parse::<ModelModality>()
.map_err(|_| AppError::BadRequest("Invalid modality".to_string()))?;
active.modality = Set(modality);
}
if let Some(capability) = request.capability {
let _ = capability
.parse::<ModelCapability>()
.map_err(|_| AppError::BadRequest("Invalid capability".to_string()))?;
active.capability = Set(capability);
}
if let Some(context_length) = request.context_length {
active.context_length = Set(context_length);
}
if let Some(max_output_tokens) = request.max_output_tokens {
active.max_output_tokens = Set(Some(max_output_tokens));
}
if let Some(training_cutoff) = request.training_cutoff {
active.training_cutoff = Set(Some(training_cutoff));
}
if let Some(is_open_source) = request.is_open_source {
active.is_open_source = Set(is_open_source);
}
if let Some(status) = request.status {
active.status = Set(status);
}
active.updated_at = Set(Utc::now());
let model = active.update(&self.db).await?;
Ok(ModelResponse::from(model))
) -> Result<agent::model::model_entry::ModelResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::model_entry::update_model(&self.db, id, request).await?)
}
pub async fn agent_model_delete(&self, id: Uuid, ctx: &Session) -> Result<(), AppError> {
require_system_caller(ctx)?;
MEntity::delete_by_id(id).exec(&self.db).await?;
Ok(())
pub async fn agent_model_delete(
&self,
id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::model_entry::delete_model(&self.db, id).await?)
}
}

View File

@ -1,126 +1,45 @@
//! Capability management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::agents::CapabilityType;
use models::agents::model_capability;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use super::provider::require_system_caller;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateModelCapabilityRequest {
pub model_version_id: i64,
pub capability: String,
#[serde(default)]
pub is_supported: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateModelCapabilityRequest {
pub is_supported: Option<bool>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelCapabilityResponse {
pub id: i64,
pub model_version_id: i64,
pub capability: String,
pub is_supported: bool,
pub created_at: chrono::DateTime<Utc>,
}
impl From<model_capability::Model> for ModelCapabilityResponse {
fn from(mc: model_capability::Model) -> Self {
Self {
id: mc.id,
model_version_id: mc.model_version_id,
capability: mc.capability,
is_supported: mc.is_supported,
created_at: mc.created_at,
}
}
}
pub use agent::model::capability::{CreateModelCapabilityRequest, ModelCapabilityResponse, UpdateModelCapabilityRequest};
impl AppService {
pub async fn agent_model_capability_list(
&self,
model_version_id: i64,
_ctx: &Session,
) -> Result<Vec<ModelCapabilityResponse>, AppError> {
let caps = model_capability::Entity::find()
.filter(model_capability::Column::ModelVersionId.eq(model_version_id))
.order_by_asc(model_capability::Column::Capability)
.all(&self.db)
.await?;
Ok(caps
.into_iter()
.map(ModelCapabilityResponse::from)
.collect())
) -> Result<Vec<agent::model::capability::ModelCapabilityResponse>, AppError> {
Ok(agent::model::capability::list_capabilities(&self.db, model_version_id).await?)
}
pub async fn agent_model_capability_get(
&self,
id: i64,
_ctx: &Session,
) -> Result<ModelCapabilityResponse, AppError> {
let cap = model_capability::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"Capability record not found".to_string(),
))?;
Ok(ModelCapabilityResponse::from(cap))
) -> Result<agent::model::capability::ModelCapabilityResponse, AppError> {
Ok(agent::model::capability::get_capability(&self.db, id).await?)
}
pub async fn agent_model_capability_create(
&self,
request: CreateModelCapabilityRequest,
request: agent::model::capability::CreateModelCapabilityRequest,
ctx: &Session,
) -> Result<ModelCapabilityResponse, AppError> {
require_system_caller(ctx)?;
let _ = request
.capability
.parse::<CapabilityType>()
.map_err(|_| AppError::BadRequest("Invalid capability type".to_string()))?;
let now = Utc::now();
let active = model_capability::ActiveModel {
model_version_id: Set(request.model_version_id),
capability: Set(request.capability),
is_supported: Set(request.is_supported),
created_at: Set(now),
..Default::default()
};
let cap = active.insert(&self.db).await?;
Ok(ModelCapabilityResponse::from(cap))
) -> Result<agent::model::capability::ModelCapabilityResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::capability::create_capability(&self.db, request).await?)
}
pub async fn agent_model_capability_update(
&self,
id: i64,
request: UpdateModelCapabilityRequest,
request: agent::model::capability::UpdateModelCapabilityRequest,
ctx: &Session,
) -> Result<ModelCapabilityResponse, AppError> {
require_system_caller(ctx)?;
let cap = model_capability::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"Capability record not found".to_string(),
))?;
let mut active: model_capability::ActiveModel = cap.into();
if let Some(is_supported) = request.is_supported {
active.is_supported = Set(is_supported);
}
let cap = active.update(&self.db).await?;
Ok(ModelCapabilityResponse::from(cap))
) -> Result<agent::model::capability::ModelCapabilityResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::capability::update_capability(&self.db, id, request).await?)
}
pub async fn agent_model_capability_delete(
@ -128,10 +47,7 @@ impl AppService {
id: i64,
ctx: &Session,
) -> Result<(), AppError> {
require_system_caller(ctx)?;
model_capability::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(())
super::provider::require_system_caller(ctx)?;
Ok(agent::model::capability::delete_capability(&self.db, id).await?)
}
}

View File

@ -1,152 +1,46 @@
//! Parameter profile management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use models::agents::model_parameter_profile;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::provider::require_system_caller;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateModelParameterProfileRequest {
pub model_version_id: Uuid,
pub temperature_min: f64,
pub temperature_max: f64,
pub top_p_min: f64,
pub top_p_max: f64,
#[serde(default)]
pub frequency_penalty_supported: bool,
#[serde(default)]
pub presence_penalty_supported: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateModelParameterProfileRequest {
pub temperature_min: Option<f64>,
pub temperature_max: Option<f64>,
pub top_p_min: Option<f64>,
pub top_p_max: Option<f64>,
pub frequency_penalty_supported: Option<bool>,
pub presence_penalty_supported: Option<bool>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelParameterProfileResponse {
pub id: i64,
pub model_version_id: Uuid,
pub temperature_min: f64,
pub temperature_max: f64,
pub top_p_min: f64,
pub top_p_max: f64,
pub frequency_penalty_supported: bool,
pub presence_penalty_supported: bool,
}
impl From<model_parameter_profile::Model> for ModelParameterProfileResponse {
fn from(p: model_parameter_profile::Model) -> Self {
Self {
id: p.id,
model_version_id: p.model_version_id,
temperature_min: p.temperature_min,
temperature_max: p.temperature_max,
top_p_min: p.top_p_min,
top_p_max: p.top_p_max,
frequency_penalty_supported: p.frequency_penalty_supported,
presence_penalty_supported: p.presence_penalty_supported,
}
}
}
pub use agent::model::parameter_profile::{CreateModelParameterProfileRequest, ModelParameterProfileResponse, UpdateModelParameterProfileRequest};
impl AppService {
pub async fn agent_model_parameter_profile_list(
&self,
model_version_id: Uuid,
_ctx: &Session,
) -> Result<Vec<ModelParameterProfileResponse>, AppError> {
let profiles = model_parameter_profile::Entity::find()
.filter(model_parameter_profile::Column::ModelVersionId.eq(model_version_id))
.all(&self.db)
.await?;
Ok(profiles
.into_iter()
.map(ModelParameterProfileResponse::from)
.collect())
) -> Result<Vec<agent::model::parameter_profile::ModelParameterProfileResponse>, AppError> {
Ok(agent::model::parameter_profile::list_parameter_profiles(&self.db, model_version_id).await?)
}
pub async fn agent_model_parameter_profile_get(
&self,
id: i64,
_ctx: &Session,
) -> Result<ModelParameterProfileResponse, AppError> {
let profile = model_parameter_profile::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"Parameter profile not found".to_string(),
))?;
Ok(ModelParameterProfileResponse::from(profile))
) -> Result<agent::model::parameter_profile::ModelParameterProfileResponse, AppError> {
Ok(agent::model::parameter_profile::get_parameter_profile(&self.db, id).await?)
}
pub async fn agent_model_parameter_profile_create(
&self,
request: CreateModelParameterProfileRequest,
request: agent::model::parameter_profile::CreateModelParameterProfileRequest,
ctx: &Session,
) -> Result<ModelParameterProfileResponse, AppError> {
require_system_caller(ctx)?;
let active = model_parameter_profile::ActiveModel {
model_version_id: Set(request.model_version_id),
temperature_min: Set(request.temperature_min),
temperature_max: Set(request.temperature_max),
top_p_min: Set(request.top_p_min),
top_p_max: Set(request.top_p_max),
frequency_penalty_supported: Set(request.frequency_penalty_supported),
presence_penalty_supported: Set(request.presence_penalty_supported),
..Default::default()
};
let profile = active.insert(&self.db).await?;
Ok(ModelParameterProfileResponse::from(profile))
) -> Result<agent::model::parameter_profile::ModelParameterProfileResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::parameter_profile::create_parameter_profile(&self.db, request).await?)
}
pub async fn agent_model_parameter_profile_update(
&self,
id: i64,
request: UpdateModelParameterProfileRequest,
request: agent::model::parameter_profile::UpdateModelParameterProfileRequest,
ctx: &Session,
) -> Result<ModelParameterProfileResponse, AppError> {
require_system_caller(ctx)?;
let profile = model_parameter_profile::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"Parameter profile not found".to_string(),
))?;
let mut active: model_parameter_profile::ActiveModel = profile.into();
if let Some(v) = request.temperature_min {
active.temperature_min = Set(v);
}
if let Some(v) = request.temperature_max {
active.temperature_max = Set(v);
}
if let Some(v) = request.top_p_min {
active.top_p_min = Set(v);
}
if let Some(v) = request.top_p_max {
active.top_p_max = Set(v);
}
if let Some(v) = request.frequency_penalty_supported {
active.frequency_penalty_supported = Set(v);
}
if let Some(v) = request.presence_penalty_supported {
active.presence_penalty_supported = Set(v);
}
let profile = active.update(&self.db).await?;
Ok(ModelParameterProfileResponse::from(profile))
) -> Result<agent::model::parameter_profile::ModelParameterProfileResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::parameter_profile::update_parameter_profile(&self.db, id, request).await?)
}
pub async fn agent_model_parameter_profile_delete(
@ -154,10 +48,7 @@ impl AppService {
id: i64,
ctx: &Session,
) -> Result<(), AppError> {
require_system_caller(ctx)?;
model_parameter_profile::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(())
super::provider::require_system_caller(ctx)?;
Ok(agent::model::parameter_profile::delete_parameter_profile(&self.db, id).await?)
}
}

View File

@ -1,148 +1,54 @@
//! Pricing management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::agents::PricingCurrency;
use models::agents::model_pricing;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::provider::require_system_caller;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateModelPricingRequest {
pub model_version_id: Uuid,
pub input_price_per_1k_tokens: String,
pub output_price_per_1k_tokens: String,
pub currency: String,
pub effective_from: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateModelPricingRequest {
pub input_price_per_1k_tokens: Option<String>,
pub output_price_per_1k_tokens: Option<String>,
pub currency: Option<String>,
pub effective_from: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelPricingResponse {
pub id: i64,
pub model_version_id: Uuid,
pub input_price_per_1k_tokens: String,
pub output_price_per_1k_tokens: String,
pub currency: String,
pub effective_from: chrono::DateTime<Utc>,
}
impl From<model_pricing::Model> for ModelPricingResponse {
fn from(p: model_pricing::Model) -> Self {
Self {
id: p.id,
model_version_id: p.model_version_id,
input_price_per_1k_tokens: p.input_price_per_1k_tokens,
output_price_per_1k_tokens: p.output_price_per_1k_tokens,
currency: p.currency,
effective_from: p.effective_from,
}
}
}
pub use agent::model::pricing::{CreateModelPricingRequest, ModelPricingResponse, UpdateModelPricingRequest};
impl AppService {
pub async fn agent_model_pricing_list(
&self,
model_version_id: Uuid,
_ctx: &Session,
) -> Result<Vec<ModelPricingResponse>, AppError> {
let records = model_pricing::Entity::find()
.filter(model_pricing::Column::ModelVersionId.eq(model_version_id))
.order_by_desc(model_pricing::Column::EffectiveFrom)
.all(&self.db)
.await?;
Ok(records
.into_iter()
.map(ModelPricingResponse::from)
.collect())
) -> Result<Vec<agent::model::pricing::ModelPricingResponse>, AppError> {
Ok(agent::model::pricing::list_pricing(&self.db, model_version_id).await?)
}
pub async fn agent_model_pricing_get(
&self,
id: i64,
_ctx: &Session,
) -> Result<ModelPricingResponse, AppError> {
let record = model_pricing::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pricing record not found".to_string()))?;
Ok(ModelPricingResponse::from(record))
) -> Result<agent::model::pricing::ModelPricingResponse, AppError> {
Ok(agent::model::pricing::get_pricing(&self.db, id).await?)
}
pub async fn agent_model_pricing_create(
&self,
request: CreateModelPricingRequest,
request: agent::model::pricing::CreateModelPricingRequest,
ctx: &Session,
) -> Result<ModelPricingResponse, AppError> {
require_system_caller(ctx)?;
let _ = request
.currency
.parse::<PricingCurrency>()
.map_err(|_| AppError::BadRequest("Invalid pricing currency".to_string()))?;
let active = model_pricing::ActiveModel {
model_version_id: Set(request.model_version_id),
input_price_per_1k_tokens: Set(request.input_price_per_1k_tokens),
output_price_per_1k_tokens: Set(request.output_price_per_1k_tokens),
currency: Set(request.currency),
effective_from: Set(request.effective_from),
..Default::default()
};
let record = active.insert(&self.db).await?;
Ok(ModelPricingResponse::from(record))
) -> Result<agent::model::pricing::ModelPricingResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::pricing::create_pricing(&self.db, request).await?)
}
pub async fn agent_model_pricing_update(
&self,
id: i64,
request: UpdateModelPricingRequest,
request: agent::model::pricing::UpdateModelPricingRequest,
ctx: &Session,
) -> Result<ModelPricingResponse, AppError> {
require_system_caller(ctx)?;
let record = model_pricing::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Pricing record not found".to_string()))?;
let mut active: model_pricing::ActiveModel = record.into();
if let Some(v) = request.input_price_per_1k_tokens {
active.input_price_per_1k_tokens = Set(v);
}
if let Some(v) = request.output_price_per_1k_tokens {
active.output_price_per_1k_tokens = Set(v);
}
if let Some(v) = request.currency {
let _ = v
.parse::<PricingCurrency>()
.map_err(|_| AppError::BadRequest("Invalid pricing currency".to_string()))?;
active.currency = Set(v);
}
if let Some(v) = request.effective_from {
active.effective_from = Set(v);
}
let record = active.update(&self.db).await?;
Ok(ModelPricingResponse::from(record))
) -> Result<agent::model::pricing::ModelPricingResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::pricing::update_pricing(&self.db, id, request).await?)
}
pub async fn agent_model_pricing_delete(&self, id: i64, ctx: &Session) -> Result<(), AppError> {
require_system_caller(ctx)?;
model_pricing::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
Ok(())
pub async fn agent_model_pricing_delete(
&self,
id: i64,
ctx: &Session,
) -> Result<(), AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::pricing::delete_pricing(&self.db, id).await?)
}
}

View File

@ -1,149 +1,46 @@
//! Model version management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::agents::model_version;
use models::agents::{
ModelStatus,
model_version::{Column as MVColumn, Entity as MVEntity, Model as ModelVersionModel},
};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::provider::require_system_caller;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateModelVersionRequest {
pub model_id: Uuid,
pub version: String,
pub release_date: Option<chrono::DateTime<Utc>>,
pub change_log: Option<String>,
#[serde(default)]
pub is_default: bool,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateModelVersionRequest {
pub version: Option<String>,
pub release_date: Option<chrono::DateTime<Utc>>,
pub change_log: Option<String>,
pub is_default: Option<bool>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ModelVersionResponse {
pub id: Uuid,
pub model_id: Uuid,
pub version: String,
pub release_date: Option<chrono::DateTime<Utc>>,
pub change_log: Option<String>,
pub is_default: bool,
pub status: String,
pub created_at: chrono::DateTime<Utc>,
}
impl From<ModelVersionModel> for ModelVersionResponse {
fn from(mv: ModelVersionModel) -> Self {
Self {
id: mv.id,
model_id: mv.model_id,
version: mv.version,
release_date: mv.release_date,
change_log: mv.change_log,
is_default: mv.is_default,
status: mv.status,
created_at: mv.created_at,
}
}
}
pub use agent::model::version::{CreateModelVersionRequest, ModelVersionResponse, UpdateModelVersionRequest};
impl AppService {
pub async fn agent_model_version_list(
&self,
model_id: Option<Uuid>,
_ctx: &Session,
) -> Result<Vec<ModelVersionResponse>, AppError> {
let mut query = MVEntity::find().order_by_asc(MVColumn::Version);
if let Some(mid) = model_id {
query = query.filter(MVColumn::ModelId.eq(mid));
}
let versions = query.all(&self.db).await?;
Ok(versions
.into_iter()
.map(ModelVersionResponse::from)
.collect())
) -> Result<Vec<agent::model::version::ModelVersionResponse>, AppError> {
Ok(agent::model::version::list_versions(&self.db, model_id).await?)
}
pub async fn agent_model_version_get(
&self,
id: Uuid,
_ctx: &Session,
) -> Result<ModelVersionResponse, AppError> {
let version = MVEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Model version not found".to_string()))?;
Ok(ModelVersionResponse::from(version))
) -> Result<agent::model::version::ModelVersionResponse, AppError> {
Ok(agent::model::version::get_version(&self.db, id).await?)
}
pub async fn agent_model_version_create(
&self,
request: CreateModelVersionRequest,
request: agent::model::version::CreateModelVersionRequest,
ctx: &Session,
) -> Result<ModelVersionResponse, AppError> {
require_system_caller(ctx)?;
let now = Utc::now();
let active = model_version::ActiveModel {
id: Set(Uuid::now_v7()),
model_id: Set(request.model_id),
version: Set(request.version),
release_date: Set(request.release_date),
change_log: Set(request.change_log),
is_default: Set(request.is_default),
status: Set(ModelStatus::Active.to_string()),
created_at: Set(now),
..Default::default()
};
let version = active.insert(&self.db).await?;
Ok(ModelVersionResponse::from(version))
) -> Result<agent::model::version::ModelVersionResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::version::create_version(&self.db, request).await?)
}
pub async fn agent_model_version_update(
&self,
id: Uuid,
request: UpdateModelVersionRequest,
request: agent::model::version::UpdateModelVersionRequest,
ctx: &Session,
) -> Result<ModelVersionResponse, AppError> {
require_system_caller(ctx)?;
let version = MVEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Model version not found".to_string()))?;
let mut active: model_version::ActiveModel = version.into();
if let Some(version) = request.version {
active.version = Set(version);
}
if let Some(release_date) = request.release_date {
active.release_date = Set(Some(release_date));
}
if let Some(change_log) = request.change_log {
active.change_log = Set(Some(change_log));
}
if let Some(is_default) = request.is_default {
active.is_default = Set(is_default);
}
if let Some(status) = request.status {
active.status = Set(status);
}
let version = active.update(&self.db).await?;
Ok(ModelVersionResponse::from(version))
) -> Result<agent::model::version::ModelVersionResponse, AppError> {
super::provider::require_system_caller(ctx)?;
Ok(agent::model::version::update_version(&self.db, id, request).await?)
}
pub async fn agent_model_version_delete(
@ -151,8 +48,7 @@ impl AppService {
id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
require_system_caller(ctx)?;
MVEntity::delete_by_id(id).exec(&self.db).await?;
Ok(())
super::provider::require_system_caller(ctx)?;
Ok(agent::model::version::delete_version(&self.db, id).await?)
}
}

View File

@ -16,7 +16,7 @@ use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
use super::billing::BillingRecord;
use agent::billing::BillingRecord;
/// Structured PR description generated by AI.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@ -136,16 +136,7 @@ async fn call_ai_model_for_description(
let client_config = agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![
async_openai::types::chat::ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
prompt.to_string(),
),
..Default::default()
},
),
];
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None, None)
.await

View File

@ -1,52 +1,11 @@
//! Provider management — delegates to agent crate.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::agents::model_provider;
use models::agents::{ModelStatus, model_provider::Entity as ProviderEntity};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateProviderRequest {
pub name: String,
pub display_name: String,
pub website: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateProviderRequest {
pub display_name: Option<String>,
pub website: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ProviderResponse {
pub id: Uuid,
pub name: String,
pub display_name: String,
pub website: Option<String>,
pub status: String,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
impl From<model_provider::Model> for ProviderResponse {
fn from(p: model_provider::Model) -> Self {
Self {
id: p.id,
name: p.name,
display_name: p.display_name,
website: p.website,
status: p.status,
created_at: p.created_at,
updated_at: p.updated_at,
}
}
}
pub use agent::model::provider::{CreateProviderRequest, ProviderResponse, UpdateProviderRequest};
pub(crate) fn require_system_caller(ctx: &Session) -> Result<(), AppError> {
if ctx.user() != Some(Uuid::nil()) {
@ -59,80 +18,43 @@ impl AppService {
pub async fn agent_provider_list(
&self,
_ctx: &Session,
) -> Result<Vec<ProviderResponse>, AppError> {
let providers = ProviderEntity::find()
.order_by_asc(model_provider::Column::DisplayName)
.all(&self.db)
.await?;
Ok(providers.into_iter().map(ProviderResponse::from).collect())
) -> Result<Vec<agent::model::provider::ProviderResponse>, AppError> {
Ok(agent::model::provider::list_providers(&self.db).await?)
}
pub async fn agent_provider_get(
&self,
id: Uuid,
_ctx: &Session,
) -> Result<ProviderResponse, AppError> {
let provider = ProviderEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Provider not found".to_string()))?;
Ok(ProviderResponse::from(provider))
) -> Result<agent::model::provider::ProviderResponse, AppError> {
Ok(agent::model::provider::get_provider(&self.db, id).await?)
}
pub async fn agent_provider_create(
&self,
request: CreateProviderRequest,
request: agent::model::provider::CreateProviderRequest,
ctx: &Session,
) -> Result<ProviderResponse, AppError> {
) -> Result<agent::model::provider::ProviderResponse, AppError> {
require_system_caller(ctx)?;
let now = Utc::now();
let active = model_provider::ActiveModel {
id: Set(Uuid::now_v7()),
name: Set(request.name),
display_name: Set(request.display_name),
website: Set(request.website),
status: Set(ModelStatus::Active.to_string()),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
let model = active.insert(&self.db).await?;
Ok(ProviderResponse::from(model))
Ok(agent::model::provider::create_provider(&self.db, request).await?)
}
pub async fn agent_provider_update(
&self,
id: Uuid,
request: UpdateProviderRequest,
request: agent::model::provider::UpdateProviderRequest,
ctx: &Session,
) -> Result<ProviderResponse, AppError> {
) -> Result<agent::model::provider::ProviderResponse, AppError> {
require_system_caller(ctx)?;
let provider = ProviderEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Provider not found".to_string()))?;
let mut active: model_provider::ActiveModel = provider.into();
if let Some(display_name) = request.display_name {
active.display_name = Set(display_name);
}
if let Some(website) = request.website {
active.website = Set(Some(website));
}
if let Some(status) = request.status {
active.status = Set(status);
}
active.updated_at = Set(Utc::now());
let model = active.update(&self.db).await?;
Ok(ProviderResponse::from(model))
Ok(agent::model::provider::update_provider(&self.db, id, request).await?)
}
pub async fn agent_provider_delete(&self, id: Uuid, ctx: &Session) -> Result<(), AppError> {
pub async fn agent_provider_delete(
&self,
id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
require_system_caller(ctx)?;
ProviderEntity::delete_by_id(id).exec(&self.db).await?;
Ok(())
Ok(agent::model::provider::delete_provider(&self.db, id).await?)
}
}

View File

@ -1,8 +1,7 @@
//! Synchronizes AI model metadata from OpenRouter into the local database.
//!
//! Flow:
//! 1. Use the configured `async_openai` client (with the real API key) to call
//! `GET /models` — this returns only the models the current key can access.
//! 1. Call `GET /models` with the real API key to list accessible model IDs.
//! 2. Fetch full metadata (pricing, context_length, capabilities) for those
//! model IDs from OpenRouter's public `/api/v1/models` endpoint (no auth).
//! 3. Upsert provider / model / version / pricing / capability / profile
@ -12,9 +11,6 @@
//! immediately and then every 10 minutes. On app startup, run it once
//! eagerly before accepting traffic.
use async_openai::Client;
use async_openai::config::OpenAIConfig;
use async_openai::types::models::Model as OpenAiModel;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::interval;
@ -690,8 +686,8 @@ async fn fetch_openrouter_models(
}
}
/// Build an async_openai Client from the AI config.
fn build_ai_client(config: &config::AppConfig) -> Result<Client<OpenAIConfig>, AppError> {
/// Build reqwest client and config from the AI config.
fn build_ai_client(config: &config::AppConfig) -> Result<(reqwest::Client, String, String), AppError> {
let api_key = config
.ai_api_key()
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
@ -700,11 +696,40 @@ fn build_ai_client(config: &config::AppConfig) -> Result<Client<OpenAIConfig>, A
.ai_basic_url()
.unwrap_or_else(|_| "https://api.openai.com".into());
let cfg = OpenAIConfig::new()
.with_api_key(&api_key)
.with_api_base(&base_url);
Ok((reqwest::Client::new(), base_url, api_key))
}
Ok(Client::with_config(cfg))
/// Response from `GET /v1/models`.
#[derive(Debug, Deserialize)]
struct ModelsListResponse {
data: Vec<ModelEntry>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
id: String,
}
/// List accessible model IDs from the AI endpoint.
async fn list_accessible_models(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
) -> Result<std::collections::HashSet<String>, AppError> {
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key))
.send()
.await
.map_err(|e| AppError::InternalServerError(format!("failed to list models: {}", e)))?;
let body: ModelsListResponse = resp
.json()
.await
.map_err(|e| AppError::InternalServerError(format!("failed to parse models response: {}", e)))?;
Ok(body.data.into_iter().map(|m| m.id).collect())
}
impl AppService {
@ -719,21 +744,8 @@ impl AppService {
_ctx: &Session,
) -> Result<SyncModelsResponse, AppError> {
// Step 1: list models the AI client can access.
let ai_client = build_ai_client(&self.config)?;
let available_ids: std::collections::HashSet<String> = ai_client
.models()
.list()
.await
.map_err(|e| {
AppError::InternalServerError(format!(
"failed to list available models from AI endpoint: {}",
e
))
})?
.data
.into_iter()
.map(|m: OpenAiModel| m.id)
.collect();
let (http_client, base_url, api_key) = build_ai_client(&self.config)?;
let available_ids = list_accessible_models(&http_client, &base_url, &api_key).await?;
tracing::info!(
model_count = available_ids.len(),
@ -896,7 +908,7 @@ impl AppService {
ai_base_url: Option<String>,
) {
// Build AI client to list accessible models.
let ai_client = match build_ai_client_from_parts(ai_api_key, ai_base_url) {
let (http_client, base_url, api_key) = match build_ai_client_from_parts(ai_api_key, ai_base_url) {
Ok(c) => c,
Err(msg) => {
tracing::warn!(error = %msg, "OpenRouter model sync");
@ -904,8 +916,8 @@ impl AppService {
}
};
let available_ids: std::collections::HashSet<String> = match ai_client.models().list().await {
Ok(resp) => resp.data.into_iter().map(|m: OpenAiModel| m.id).collect(),
let available_ids = match list_accessible_models(&http_client, &base_url, &api_key).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(error = ?e, "OpenRouter model sync: failed to list available models");
return;
@ -1031,18 +1043,12 @@ impl AppService {
}
}
/// Build an async_openai Client from raw API key and base URL (for background task).
/// Build a reqwest client and config parts for background sync task.
fn build_ai_client_from_parts(
api_key: Option<String>,
base_url: Option<String>,
) -> Result<Client<OpenAIConfig>, String> {
) -> Result<(reqwest::Client, String, String), String> {
let api_key = api_key.ok_or_else(|| "AI API key not configured".to_string())?;
let base_url = base_url.unwrap_or_else(|| "https://api.openai.com".into());
let cfg = OpenAIConfig::new()
.with_api_key(&api_key)
.with_api_base(&base_url);
Ok(Client::with_config(cfg))
Ok((reqwest::Client::new(), base_url, api_key))
}

View File

@ -298,3 +298,15 @@ impl From<room::RoomError> for AppError {
}
}
}
impl From<agent::AgentError> for AppError {
fn from(e: agent::AgentError) -> Self {
match e {
agent::AgentError::NotFound(s) => AppError::NotFound(s),
agent::AgentError::InvalidInput { field, reason } => {
AppError::BadRequest(format!("invalid {}: {}", field, reason))
}
_ => AppError::InternalServerError(e.to_string()),
}
}
}

View File

@ -41,6 +41,10 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let result = match (&base_oid, &head_oid) {
(None, None) => {
// Check if repo has any commits before attempting to diff
if domain.repo().head().is_err() {
return Err("No commits found in repository".into());
}
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?;
// Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() {

View File

@ -4,7 +4,7 @@ use ::agent::chat::ChatService;
use ::agent::client::AiClientConfig;
use ::agent::task::service::TaskService;
use ::agent::tool::ToolRegistry;
use async_openai::config::OpenAIConfig;
use ::agent::{EmbedService, new_embed_client};
use avatar::AppAvatar;
use config::AppConfig;
use db::cache::AppCache;
@ -163,25 +163,49 @@ impl AppService {
.and_then(|urls| urls.first().cloned())
.unwrap_or_else(|| "redis://127.0.0.1:6379".to_string());
// Build EmbedService if Qdrant and embedding model are configured (graceful degradation)
let embed_service: Option<Arc<EmbedService>> =
match new_embed_client(&config).await {
Ok(client) => {
let model_name = config
.get_embed_model_name()
.unwrap_or_else(|_| "text-embedding-3-small".into());
let dimensions = config
.get_embed_model_dimensions()
.unwrap_or(1536);
let svc = EmbedService::new(
client,
db.writer().clone(),
model_name,
dimensions,
);
let _ = svc.ensure_collections().await;
tracing::info!("EmbedService initialized (Qdrant + embeddings)");
Some(Arc::new(svc))
}
Err(e) => {
tracing::warn!(error = %e, "EmbedService not available — vector search disabled");
None
}
};
// Build ChatService if AI is configured; otherwise AI chat is disabled (graceful degradation)
let chat_service: Option<Arc<ChatService>> =
match (config.ai_api_key(), config.ai_basic_url()) {
(Ok(api_key), Ok(base_url)) => {
tracing::info!(url = %base_url, "AI chat enabled");
let cfg = OpenAIConfig::new()
.with_api_key(&api_key)
.with_api_base(&base_url);
let client = async_openai::Client::with_config(cfg);
let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url);
let mut registry = ToolRegistry::new();
git_tools::register_all(&mut registry);
file_tools::register_all(&mut registry);
project_tools::register_all(&mut registry);
Some(Arc::new(
ChatService::new(client)
.with_ai_client_config(ai_client_config)
.with_tool_registry(registry),
))
let mut chat_svc = ChatService::new()
.with_ai_client_config(ai_client_config)
.with_tool_registry(registry);
if let Some(ref es) = embed_service {
chat_svc = chat_svc.with_embed_service((**es).clone());
}
Some(Arc::new(chat_svc))
}
(Err(e), _) => {
tracing::warn!(error = %e, "AI chat disabled");
@ -243,6 +267,7 @@ impl AppService {
Some(task_service.clone()),
None,
push_fn,
embed_service,
);
// Build WsTokenService