From 881fbdb6eafe5144dc17a310d2d2fd489a073cf9 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 25 Apr 2026 20:09:45 +0800 Subject: [PATCH] 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 --- libs/service/Cargo.toml | 1 - libs/service/agent/billing.rs | 214 +----------------- libs/service/agent/code_review.rs | 13 +- libs/service/agent/issue_triage.rs | 10 +- libs/service/agent/model.rs | 187 ++------------- libs/service/agent/model_capability.rs | 118 ++-------- libs/service/agent/model_parameter_profile.rs | 143 ++---------- libs/service/agent/model_pricing.rs | 138 ++--------- libs/service/agent/model_version.rs | 138 ++--------- libs/service/agent/pr_summary.rs | 13 +- libs/service/agent/provider.rs | 116 ++-------- libs/service/agent/sync.rs | 82 +++---- libs/service/error.rs | 12 + libs/service/git_tools/diff.rs | 4 + libs/service/lib.rs | 45 +++- 15 files changed, 224 insertions(+), 1010 deletions(-) diff --git a/libs/service/Cargo.toml b/libs/service/Cargo.toml index 98f4782..d200b37 100644 --- a/libs/service/Cargo.toml +++ b/libs/service/Cargo.toml @@ -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 } diff --git a/libs/service/agent/billing.rs b/libs/service/agent/billing.rs index 413203d..ce55850 100644 --- a/libs/service/agent/billing.rs +++ b/libs/service/agent/billing.rs @@ -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 { - // 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 { + Ok(agent::billing::record_ai_usage( + &self.db, + project_uid, + model_id, + input_tokens, + output_tokens, + ) + .await?) } } diff --git a/libs/service/agent/code_review.rs b/libs/service/agent/code_review.rs index 0342536..aef194b 100644 --- a/libs/service/agent/code_review.rs +++ b/libs/service/agent/code_review.rs @@ -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 diff --git a/libs/service/agent/issue_triage.rs b/libs/service/agent/issue_triage.rs index 2c58f54..fb3c395 100644 --- a/libs/service/agent/issue_triage.rs +++ b/libs/service/agent/issue_triage.rs @@ -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, diff --git a/libs/service/agent/model.rs b/libs/service/agent/model.rs index 5f2cb71..7bde267 100644 --- a/libs/service/agent/model.rs +++ b/libs/service/agent/model.rs @@ -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, - pub training_cutoff: Option>, - #[serde(default)] - pub is_open_source: bool, -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct UpdateModelRequest { - pub display_name: Option, - pub modality: Option, - pub capability: Option, - pub context_length: Option, - pub max_output_tokens: Option, - pub training_cutoff: Option>, - pub is_open_source: Option, - pub status: Option, -} - -#[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, - pub training_cutoff: Option>, - pub is_open_source: bool, - pub status: String, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -impl From 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, _ctx: &Session, - ) -> Result, 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, 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 { - 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 { + 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 { - 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::() - .map_err(|_| AppError::BadRequest("Invalid modality".to_string()))?; - let _ = request - .capability - .parse::() - .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 { + 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 { - 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::() - .map_err(|_| AppError::BadRequest("Invalid modality".to_string()))?; - active.modality = Set(modality); - } - if let Some(capability) = request.capability { - let _ = capability - .parse::() - .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 { + 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?) } } diff --git a/libs/service/agent/model_capability.rs b/libs/service/agent/model_capability.rs index aa00f66..b965877 100644 --- a/libs/service/agent/model_capability.rs +++ b/libs/service/agent/model_capability.rs @@ -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, -} - -#[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, -} - -impl From 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, 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, 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 { - 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 { + 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 { - require_system_caller(ctx)?; - - let _ = request - .capability - .parse::() - .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 { + 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 { - 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 { + 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?) } } diff --git a/libs/service/agent/model_parameter_profile.rs b/libs/service/agent/model_parameter_profile.rs index b0e444a..2f62d32 100644 --- a/libs/service/agent/model_parameter_profile.rs +++ b/libs/service/agent/model_parameter_profile.rs @@ -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, - pub temperature_max: Option, - pub top_p_min: Option, - pub top_p_max: Option, - pub frequency_penalty_supported: Option, - pub presence_penalty_supported: Option, -} - -#[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 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, 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, 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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?) } } diff --git a/libs/service/agent/model_pricing.rs b/libs/service/agent/model_pricing.rs index 6af0f6b..7f4e4ad 100644 --- a/libs/service/agent/model_pricing.rs +++ b/libs/service/agent/model_pricing.rs @@ -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, -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct UpdateModelPricingRequest { - pub input_price_per_1k_tokens: Option, - pub output_price_per_1k_tokens: Option, - pub currency: Option, - pub effective_from: Option>, -} - -#[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, -} - -impl From 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, 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, 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 { - 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 { + 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 { - require_system_caller(ctx)?; - - let _ = request - .currency - .parse::() - .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 { + 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 { - 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::() - .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 { + 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?) } } diff --git a/libs/service/agent/model_version.rs b/libs/service/agent/model_version.rs index 573c1b2..5d07a31 100644 --- a/libs/service/agent/model_version.rs +++ b/libs/service/agent/model_version.rs @@ -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>, - pub change_log: Option, - #[serde(default)] - pub is_default: bool, -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct UpdateModelVersionRequest { - pub version: Option, - pub release_date: Option>, - pub change_log: Option, - pub is_default: Option, - pub status: Option, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ModelVersionResponse { - pub id: Uuid, - pub model_id: Uuid, - pub version: String, - pub release_date: Option>, - pub change_log: Option, - pub is_default: bool, - pub status: String, - pub created_at: chrono::DateTime, -} - -impl From 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, _ctx: &Session, - ) -> Result, 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, 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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?) } } diff --git a/libs/service/agent/pr_summary.rs b/libs/service/agent/pr_summary.rs index 580c153..45c4aac 100644 --- a/libs/service/agent/pr_summary.rs +++ b/libs/service/agent/pr_summary.rs @@ -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 diff --git a/libs/service/agent/provider.rs b/libs/service/agent/provider.rs index f5853bc..5160908 100644 --- a/libs/service/agent/provider.rs +++ b/libs/service/agent/provider.rs @@ -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, -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -pub struct UpdateProviderRequest { - pub display_name: Option, - pub website: Option, - pub status: Option, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ProviderResponse { - pub id: Uuid, - pub name: String, - pub display_name: String, - pub website: Option, - pub status: String, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -impl From 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, 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, AppError> { + Ok(agent::model::provider::list_providers(&self.db).await?) } pub async fn agent_provider_get( &self, id: Uuid, _ctx: &Session, - ) -> Result { - 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 { + 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 { + ) -> Result { 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 { + ) -> Result { 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?) } } diff --git a/libs/service/agent/sync.rs b/libs/service/agent/sync.rs index 6ccf3e5..c662fd3 100644 --- a/libs/service/agent/sync.rs +++ b/libs/service/agent/sync.rs @@ -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, 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, 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, +} + +#[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, 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 { // Step 1: list models the AI client can access. - let ai_client = build_ai_client(&self.config)?; - let available_ids: std::collections::HashSet = 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, ) { // 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 = 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, base_url: Option, -) -> Result, 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)) } diff --git a/libs/service/error.rs b/libs/service/error.rs index a59313b..8c7c765 100644 --- a/libs/service/error.rs +++ b/libs/service/error.rs @@ -298,3 +298,15 @@ impl From for AppError { } } } + +impl From 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()), + } + } +} diff --git a/libs/service/git_tools/diff.rs b/libs/service/git_tools/diff.rs index 663c654..01c6e28 100644 --- a/libs/service/git_tools/diff.rs +++ b/libs/service/git_tools/diff.rs @@ -41,6 +41,10 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + // 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() { diff --git a/libs/service/lib.rs b/libs/service/lib.rs index 1e428b8..49e7093 100644 --- a/libs/service/lib.rs +++ b/libs/service/lib.rs @@ -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> = + 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> = 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