#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
use crate::api::init::state;
#[cfg(feature = "http")]
use crate::domain::openai_embedding::{
EmbeddingData, EmbeddingObject, OpenAIEmbedRequest, OpenAIEmbedResponse, Usage,
};
use crate::domain::{
BatchEmbedRequest, BatchEmbedResponse, EmbedRequest, EmbedResponse, SearchRequest,
SearchResponse, SimilarityRequest, SimilarityResponse,
};
#[cfg(any(feature = "http", feature = "grpc"))]
use crate::domain::{
EmbeddingOutput, FileEmbedRequest, FileEmbedResponse, ModelInfo, ModelListResponse,
ModelMetadata, ModelSwitchRequest, ModelSwitchResponse, UnloadModelRequest,
UnloadModelResponse,
};
use crate::error::VecboostError;
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
use crate::registry::EmbeddingModule;
#[cfg(any(feature = "http", feature = "grpc"))]
use crate::registry::{CacheModule, RateLimitModule, RerankModule};
#[cfg(any(feature = "http", feature = "grpc"))]
use crate::utils::{AggregationMode, PathValidator};
#[cfg(any(feature = "http", feature = "grpc"))]
use std::path::PathBuf;
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
use sdforge::prelude::*;
pub async fn embed(
svc: &crate::service::embedding::EmbeddingService,
req: EmbedRequest,
) -> Result<EmbedResponse, VecboostError> {
svc.process_text(req, None).await
}
pub async fn embed_batch(
svc: &crate::service::embedding::EmbeddingService,
req: BatchEmbedRequest,
) -> Result<BatchEmbedResponse, VecboostError> {
svc.process_batch(req, None).await
}
pub async fn compute_similarity(
svc: &crate::service::embedding::EmbeddingService,
req: SimilarityRequest,
) -> Result<SimilarityResponse, VecboostError> {
svc.process_similarity(req).await
}
pub async fn search(
svc: &crate::service::embedding::EmbeddingService,
req: SearchRequest,
) -> Result<SearchResponse, VecboostError> {
svc.process_search(req).await
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
pub(crate) fn to_api_error(e: VecboostError) -> ApiError {
match e {
VecboostError::InvalidInput(msg) => ApiError::InvalidInput {
message: msg,
field: None,
value: None,
},
VecboostError::ValidationError(msg) => ApiError::InvalidInput {
message: msg,
field: None,
value: None,
},
VecboostError::ModelLoadError(msg) => ApiError::NotFound {
resource: "model".to_string(),
resource_id: Some(msg),
},
VecboostError::NotFound(msg) => ApiError::NotFound {
resource: "resource".to_string(),
resource_id: Some(msg),
},
VecboostError::RateLimitExceeded(msg) => ApiError::ServiceUnavailable {
service: msg,
retry_after: Some(60),
source: None,
},
other => {
ApiError::Internal {
message: other.error_detail().to_string(),
error_id: uuid_like_id(),
source: None,
context: Some(Box::new(sdforge::error::ErrorContext {
file: None,
line: None,
function: None,
extra: [("error_code".to_string(), other.error_code().to_string())]
.into_iter()
.collect(),
})),
}
}
}
}
#[cfg(feature = "http")]
fn openai_error_detail(openai_type: &str, openai_code: &str) -> Option<serde_json::Value> {
Some(serde_json::json!({
"openai_error_type": openai_type,
"openai_code": openai_code,
}))
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
pub(crate) fn uuid_like_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("err-{}", nanos)
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
pub(crate) fn kit_internal_error(e: impl std::fmt::Display) -> ApiError {
ApiError::Internal {
message: e.to_string(),
error_id: uuid_like_id(),
source: None,
context: None,
}
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
fn validate_text_length(texts: &[String], max: usize) -> Result<(), VecboostError> {
for (idx, text) in texts.iter().enumerate() {
if text.len() > max {
return Err(VecboostError::ValidationError(crate::i18n::tr_with_args(
"validate-text-length",
crate::i18n::tr_args(&[
("index", &idx.to_string()),
("max", &max.to_string()),
("got", &text.len().to_string()),
]),
)));
}
}
Ok(())
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
fn validate_batch_size(texts_len: usize, max: usize) -> Result<(), VecboostError> {
if texts_len > max {
return Err(VecboostError::ValidationError(crate::i18n::tr_with_args(
"validate-batch-size",
crate::i18n::tr_args(&[("size", &texts_len.to_string()), ("max", &max.to_string())]),
)));
}
Ok(())
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
fn max_text_length_from_kit(kit: &trait_kit::AsyncKit<trait_kit::AsyncReady>) -> usize {
kit.config::<crate::config::app::EmbeddingConfig>()
.unwrap_or_default()
.max_text_length
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
fn max_batch_size_from_kit(kit: &trait_kit::AsyncKit<trait_kit::AsyncReady>) -> usize {
kit.config::<crate::config::app::EmbeddingConfig>()
.unwrap_or_default()
.max_batch_size
}
#[cfg(any(feature = "http", feature = "grpc"))]
fn build_path_validator() -> Result<PathValidator, ApiError> {
const CONFIG_HINT: &str = "[server] grpc_allowed_roots is required for /embed/file; add explicit allowed roots to config and restart";
let st = state().map_err(to_api_error)?;
let server_cfg = st
.kit
.config::<crate::config::app::ServerConfig>()
.unwrap_or_default();
let Some(roots) = &server_cfg.grpc_allowed_roots else {
return Err(ApiError::InvalidInput {
message: CONFIG_HINT.to_string(),
field: Some("path".to_string()),
value: None,
});
};
if roots.is_empty() {
return Err(ApiError::InvalidInput {
message: CONFIG_HINT.to_string(),
field: Some("path".to_string()),
value: None,
});
}
let mut validator = PathValidator::new();
for root in roots {
validator = validator.add_allowed_root(root);
}
Ok(validator)
}
#[cfg(any(feature = "http", feature = "grpc"))]
const FILE_EMBED_MAX_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(any(feature = "http", feature = "grpc"))]
fn check_file_embed_size(len: u64) -> Result<(), String> {
if len > FILE_EMBED_MAX_BYTES {
Err(crate::i18n::tr_with_args(
"embed-file-too-large",
crate::i18n::tr_args(&[
("max", &(FILE_EMBED_MAX_BYTES / (1024 * 1024)).to_string()),
("got", &len.to_string()),
]),
))
} else {
Ok(())
}
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
async fn embed_handler(req: EmbedRequest) -> Result<EmbedResponse, ApiError> {
let st = state().map_err(to_api_error)?;
validate_text_length(
std::slice::from_ref(&req.text),
max_text_length_from_kit(&st.kit),
)
.map_err(to_api_error)?;
#[cfg(feature = "http")]
{
let pipeline_enabled = st
.kit
.config::<crate::registry::PipelineEnabled>()
.map(|c| c.0)
.unwrap_or(false);
if pipeline_enabled {
let result =
crate::pipeline::handle_pipeline_request(st.clone(), req, "api".to_string())
.await
.map_err(to_api_error)?;
return Ok(result.0);
}
}
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
embed(&guard, req).await.map_err(to_api_error)
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
async fn embed_batch_handler(req: BatchEmbedRequest) -> Result<BatchEmbedResponse, ApiError> {
let st = state().map_err(to_api_error)?;
validate_batch_size(req.texts.len(), max_batch_size_from_kit(&st.kit)).map_err(to_api_error)?;
validate_text_length(&req.texts, max_text_length_from_kit(&st.kit)).map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
embed_batch(&guard, req).await.map_err(to_api_error)
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
async fn compute_similarity_handler(
req: SimilarityRequest,
) -> Result<SimilarityResponse, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
compute_similarity(&guard, req).await.map_err(to_api_error)
}
#[cfg(any(feature = "http", feature = "grpc", feature = "cli"))]
async fn search_handler(req: SearchRequest) -> Result<SearchResponse, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
guard
.process_search_batch(&req.query, &req.texts, req.top_k)
.await
.map_err(to_api_error)
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn unload_model_handler(req: UnloadModelRequest) -> Result<UnloadModelResponse, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let mut guard = svc.write().await;
if !guard.has_model_manager() {
return Err(ApiError::NotFound {
resource: "model manager".to_string(),
resource_id: None,
});
}
guard
.unload_model(&req.model_name)
.await
.map_err(to_api_error)?;
Ok(UnloadModelResponse {
model_name: req.model_name,
unloaded: true,
})
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn embed_file_handler(req: FileEmbedRequest) -> Result<FileEmbedResponse, ApiError> {
let mode = req.mode.unwrap_or(AggregationMode::Document);
let path = PathBuf::from(&req.path);
let validator = build_path_validator()?;
let validated_path = validator
.validate_file(&path)
.map_err(|e| ApiError::InvalidInput {
message: crate::i18n::tr_with_args(
"validate-path-failed",
crate::i18n::tr_args(&[("detail", &e.to_string())]),
),
field: Some("path".to_string()),
value: Some(serde_json::Value::String(req.path.clone())),
})?;
if let Ok(meta) = std::fs::metadata(&validated_path)
&& let Err(msg) = check_file_embed_size(meta.len())
{
return Err(ApiError::InvalidInput {
message: msg,
field: Some("path".to_string()),
value: Some(serde_json::Value::String(req.path.clone())),
});
}
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
let stats = guard
.get_processing_stats(&validated_path)
.map_err(to_api_error)?;
let output = guard
.embed_file(&validated_path, mode)
.await
.map_err(to_api_error)?;
drop(guard);
Ok(match output {
EmbeddingOutput::Single(response) => FileEmbedResponse {
mode,
stats,
embedding: Some(response.embedding),
paragraphs: None,
},
EmbeddingOutput::Paragraphs(paragraphs) => {
let may_preview = requester_may_preview(&st).await;
let paragraphs = if may_preview {
paragraphs
} else {
paragraphs
.into_iter()
.map(|mut p| {
p.text_preview = String::new();
p
})
.collect()
};
FileEmbedResponse {
mode,
stats,
embedding: None,
paragraphs: Some(paragraphs),
}
}
})
}
#[cfg(all(any(feature = "http", feature = "grpc"), feature = "auth"))]
async fn requester_may_preview(st: &crate::VecboostState) -> bool {
let auth_enabled = matches!(st.kit.require::<crate::registry::AuthModule>(), Ok(Some(_)));
if !auth_enabled {
return true;
}
crate::auth::middleware::current_token_is_admin_pub().await
}
#[cfg(all(any(feature = "http", feature = "grpc"), not(feature = "auth")))]
async fn requester_may_preview(st: &crate::VecboostState) -> bool {
let _ = st;
true
}
#[cfg(any(feature = "http", feature = "grpc", feature = "cli"))]
fn model_path_validator(configured_roots: Option<&[String]>) -> PathValidator {
let mut validator = PathValidator::new();
match configured_roots {
Some(roots) if !roots.is_empty() => {
validator = validator.add_allowed_roots(roots);
}
_ => {
validator = validator.add_allowed_root("models");
}
}
validator
}
static ENGINE_PROBE_CACHE: std::sync::OnceLock<
tokio::sync::Mutex<Option<(std::time::Instant, bool)>>,
> = std::sync::OnceLock::new();
const ENGINE_PROBE_CACHE_TTL: std::time::Duration = std::time::Duration::from_millis(500);
async fn engine_probe_ok(st: &crate::VecboostState) -> bool {
let cache = ENGINE_PROBE_CACHE.get_or_init(|| tokio::sync::Mutex::new(None));
let mut guard = cache.lock().await;
if let Some((at, ok)) = *guard
&& at.elapsed() < ENGINE_PROBE_CACHE_TTL
{
return ok;
}
let ok = async {
match st.kit.require::<EmbeddingModule>() {
Ok(svc) => {
let guard = svc.read().await;
guard.count_tokens("healthcheck").is_ok()
}
Err(_) => false,
}
}
.await;
*guard = Some((std::time::Instant::now(), ok));
ok
}
async fn run_deep_health_checks(st: &crate::VecboostState) -> Vec<serde_json::Value> {
let mut failures = Vec::new();
#[cfg(feature = "db")]
{
if let Err(reason) = crate::db::probe_ready().await {
failures.push(serde_json::json!({ "component": "db", "error": reason }));
}
}
if !engine_probe_ok(st).await {
failures.push(serde_json::json!({
"component": "engine",
"error": crate::i18n::tr("health-engine-probe-failed"),
}));
}
if let Ok(limiter) = st.kit.require::<crate::registry::RateLimitModule>()
&& !limiter.check_health().await
{
failures.push(serde_json::json!({
"component": "rate_limit",
"error": crate::i18n::tr("health-limiter-failed"),
}));
}
failures
}
#[cfg(any(feature = "http", feature = "grpc", feature = "cli"))]
async fn model_switch_handler(req: ModelSwitchRequest) -> Result<ModelSwitchResponse, ApiError> {
let st = state().map_err(to_api_error)?;
if req.model_path.is_some() || req.tokenizer_path.is_some() {
let server_cfg = st
.kit
.config::<crate::config::app::ServerConfig>()
.unwrap_or_default();
let validator = model_path_validator(server_cfg.grpc_allowed_roots.as_deref());
for path in req.model_path.iter().chain(req.tokenizer_path.iter()) {
validator
.validate_directory(path)
.map_err(|e| ApiError::InvalidInput {
message: e.to_string(),
field: Some("model_path".to_string()),
value: Some(serde_json::Value::String(path.display().to_string())),
})?;
}
}
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let mut guard = svc.write().await;
guard.switch_model(req).await.map_err(to_api_error)
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn get_current_model_handler() -> Result<ModelInfo, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
guard.get_model_info().ok_or_else(|| ApiError::NotFound {
resource: "model".to_string(),
resource_id: None,
})
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn get_model_info_handler() -> Result<ModelMetadata, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
guard
.get_model_metadata()
.ok_or_else(|| ApiError::NotFound {
resource: "model_metadata".to_string(),
resource_id: None,
})
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn list_models_handler() -> Result<ModelListResponse, ApiError> {
let st = state().map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
Ok(guard.list_available_models())
}
#[cfg(any(feature = "http", feature = "grpc"))]
async fn health_handler(depth: Option<String>) -> Result<serde_json::Value, ApiError> {
let st = state().map_err(to_api_error)?;
let mut unhealthy_modules = Vec::new();
if depth.as_deref() == Some("full") {
let failures = run_deep_health_checks(&st).await;
if !failures.is_empty() {
return Err(ApiError::ServiceUnavailable {
service: serde_json::to_string(&failures).unwrap_or_default(),
retry_after: Some(5),
source: None,
});
}
return Ok(serde_json::json!({
"status": crate::i18n::tr("health-ok"),
"depth": "full",
}));
}
match st.kit.health_check::<EmbeddingModule>() {
Ok(status) if !status.is_healthy() => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[
("module", "embedding"),
("detail", &format!("{:?}", status)),
]),
));
}
Err(e) => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "embedding"), ("detail", &e.to_string())]),
));
}
Ok(_) => {}
}
match st.kit.health_check::<RerankModule>() {
Ok(status) if !status.is_healthy() => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "rerank"), ("detail", &format!("{:?}", status))]),
));
}
Err(e) => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "rerank"), ("detail", &e.to_string())]),
));
}
Ok(_) => {}
}
match st.kit.health_check::<RateLimitModule>() {
Ok(status) if !status.is_healthy() => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[
("module", "rate_limit"),
("detail", &format!("{:?}", status)),
]),
));
}
Err(e) => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "rate_limit"), ("detail", &e.to_string())]),
));
}
Ok(_) => {}
}
match st.kit.health_check::<CacheModule>() {
Ok(status) if !status.is_healthy() => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "cache"), ("detail", &format!("{:?}", status))]),
));
}
Err(e) => {
unhealthy_modules.push(crate::i18n::tr_with_args(
"health-check-failed",
crate::i18n::tr_args(&[("module", "cache"), ("detail", &e.to_string())]),
));
}
Ok(_) => {}
}
if unhealthy_modules.is_empty() {
Ok(serde_json::json!({ "status": crate::i18n::tr("health-ok") }))
} else {
Err(ApiError::ServiceUnavailable {
service: unhealthy_modules.join(", "),
retry_after: Some(5),
source: None,
})
}
}
#[cfg(feature = "http")]
#[forge(
name = "embed",
version = 1,
path = "/embed",
method = "POST",
tool_name = "embed_text",
description = "Generate embedding vector for input text"
)]
pub async fn forge_embed(req: EmbedRequest) -> Result<EmbedResponse, ApiError> {
embed_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "embed_batch",
version = 1,
path = "/embed/batch",
method = "POST",
tool_name = "embed_batch",
description = "Generate embedding vectors for multiple texts in batch"
)]
pub async fn forge_embed_batch(req: BatchEmbedRequest) -> Result<BatchEmbedResponse, ApiError> {
embed_batch_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "compute_similarity",
version = 1,
path = "/similarity",
method = "POST",
tool_name = "compute_similarity",
description = "Compute cosine similarity between two texts"
)]
pub async fn forge_compute_similarity(
req: SimilarityRequest,
) -> Result<SimilarityResponse, ApiError> {
compute_similarity_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "search",
version = 1,
path = "/search",
method = "POST",
tool_name = "search",
description = "1-to-N semantic search: rank candidate texts by similarity to the query"
)]
pub async fn forge_search(req: SearchRequest) -> Result<SearchResponse, ApiError> {
search_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "file_embed",
version = 1,
path = "/embed/file",
method = "POST",
tool_name = "file_embed",
description = "Embed text from a file with path validation"
)]
pub async fn forge_file_embed(req: FileEmbedRequest) -> Result<FileEmbedResponse, ApiError> {
embed_file_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "health",
version = 1,
path = "/health",
method = "GET",
no_prefix = true,
tool_name = "health",
description = "Service health check"
)]
pub async fn forge_health(
#[param(kind = "query")] depth: Option<String>,
) -> Result<serde_json::Value, ApiError> {
health_handler(depth).await
}
#[cfg(feature = "http")]
#[forge(
name = "model_switch",
version = 1,
path = "/model/switch",
method = "POST",
tool_name = "model_switch",
description = "Switch the currently loaded model"
)]
pub async fn forge_model_switch(req: ModelSwitchRequest) -> Result<ModelSwitchResponse, ApiError> {
model_switch_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "get_current_model",
version = 1,
path = "/model/current",
method = "GET",
tool_name = "get_current_model",
description = "Get information about the currently loaded model"
)]
pub async fn forge_get_current_model() -> Result<ModelInfo, ApiError> {
get_current_model_handler().await
}
#[cfg(feature = "http")]
#[forge(
name = "get_model_info",
version = 1,
path = "/model/info",
method = "GET",
tool_name = "get_model_info",
description = "Get metadata about the currently loaded model"
)]
pub async fn forge_get_model_info() -> Result<ModelMetadata, ApiError> {
get_model_info_handler().await
}
#[cfg(feature = "http")]
#[forge(
name = "list_models",
version = 1,
path = "/models",
method = "GET",
tool_name = "list_models",
description = "List all available models"
)]
pub async fn forge_list_models() -> Result<ModelListResponse, ApiError> {
list_models_handler().await
}
#[cfg(feature = "http")]
#[forge(
name = "model_unload",
version = 1,
path = "/model/unload",
method = "POST",
tool_name = "model_unload",
description = "Unload a model from the model manager cache"
)]
pub async fn forge_unload_model(req: UnloadModelRequest) -> Result<UnloadModelResponse, ApiError> {
unload_model_handler(req).await
}
#[cfg(feature = "http")]
#[forge(
name = "openai_embed",
version = 1,
path = "/v1/embeddings",
method = "POST",
no_prefix = true,
tool_name = "openai_embed",
description = "OpenAI-compatible embeddings endpoint"
)]
pub async fn forge_openai_embed(req: OpenAIEmbedRequest) -> Result<OpenAIEmbedResponse, ApiError> {
if req.input.is_empty() {
return Err(ApiError::InvalidInput {
message: crate::i18n::tr("openai-input-empty"),
field: Some("input".to_string()),
value: openai_error_detail("invalid_request_error", "empty_input"),
});
}
if req.input.len() > 2048 {
let effective = max_batch_size_from_kit(&state().map_err(to_api_error)?.kit);
return Err(ApiError::InvalidInput {
message: crate::i18n::tr_with_args(
"openai-input-too-large",
crate::i18n::tr_args(&[("max", "2048"), ("effective", &effective.to_string())]),
),
field: Some("input".to_string()),
value: openai_error_detail("invalid_request_error", "batch_too_large"),
});
}
let st = state().map_err(to_api_error)?;
let available = {
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
guard.list_available_models().models
};
if !available.iter().any(|m| m.name == req.model) {
let available_list = available
.iter()
.map(|m| m.name.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(ApiError::InvalidInput {
message: crate::i18n::tr_with_args(
"openai-model-not-found",
crate::i18n::tr_args(&[("model", &req.model), ("available", &available_list)]),
),
field: Some("model".to_string()),
value: openai_error_detail("invalid_request_error", "model_not_found"),
});
}
validate_batch_size(req.input.len(), max_batch_size_from_kit(&st.kit)).map_err(to_api_error)?;
let svc = st
.kit
.require::<EmbeddingModule>()
.map_err(kit_internal_error)?;
let guard = svc.read().await;
let texts = req.input.to_vec();
validate_text_length(&texts, max_text_length_from_kit(&st.kit)).map_err(to_api_error)?;
let total_chars: usize = texts.iter().map(|s| s.len()).sum();
let real_token_count: usize = texts
.iter()
.filter_map(|t| guard.count_tokens(t).ok())
.sum();
let batch_req = BatchEmbedRequest {
texts,
mode: None,
normalize: Some(true),
};
let batch_response = guard
.process_batch(batch_req, req.dimensions)
.await
.map_err(to_api_error)?;
let as_base64 = req.encoding_format.as_deref() == Some("base64");
let embedding_objects: Vec<EmbeddingObject> = batch_response
.embeddings
.into_iter()
.enumerate()
.map(|(idx, result)| {
let embedding = if as_base64 {
#[cfg(feature = "http")]
{
use base64::Engine as _;
let bytes: Vec<u8> = result
.embedding
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
EmbeddingData::Base64(base64::engine::general_purpose::STANDARD.encode(bytes))
}
#[cfg(not(feature = "http"))]
{
let _ = as_base64;
EmbeddingData::Floats(result.embedding)
}
} else {
EmbeddingData::Floats(result.embedding)
};
EmbeddingObject {
object: "embedding".to_string(),
embedding,
index: idx,
}
})
.collect();
let prompt_tokens = if real_token_count > 0 {
real_token_count as u32
} else {
(total_chars / 4) as u32
};
Ok(OpenAIEmbedResponse {
object: "list".to_string(),
data: embedding_objects,
model: req.model.clone(),
usage: Usage {
prompt_tokens,
total_tokens: prompt_tokens,
},
})
}
#[cfg(feature = "cli")]
#[forge(
name = "embed",
version = 1,
cli = true,
description = "Generate embedding vector for input text"
)]
pub async fn cli_embed(req: EmbedRequest) -> Result<EmbedResponse, ApiError> {
embed_handler(req).await
}
#[cfg(feature = "cli")]
#[forge(
name = "embed_batch",
version = 1,
cli = true,
description = "Generate embedding vectors for multiple texts in batch"
)]
pub async fn cli_embed_batch(req: BatchEmbedRequest) -> Result<BatchEmbedResponse, ApiError> {
embed_batch_handler(req).await
}
#[cfg(feature = "cli")]
#[forge(
name = "compute_similarity",
version = 1,
cli = true,
description = "Compute cosine similarity between two texts"
)]
pub async fn cli_compute_similarity(
req: SimilarityRequest,
) -> Result<SimilarityResponse, ApiError> {
compute_similarity_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_embed",
version = 1,
grpc_method = "vecboost.embed",
description = "Generate embedding vector for input text"
)]
pub async fn grpc_embed(req: EmbedRequest) -> Result<EmbedResponse, ApiError> {
embed_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_embed_batch",
version = 1,
grpc_method = "vecboost.embed_batch",
description = "Generate embedding vectors for multiple texts in batch"
)]
pub async fn grpc_embed_batch(req: BatchEmbedRequest) -> Result<BatchEmbedResponse, ApiError> {
embed_batch_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_compute_similarity",
version = 1,
grpc_method = "vecboost.compute_similarity",
description = "Compute similarity between two texts"
)]
pub async fn grpc_compute_similarity(
req: SimilarityRequest,
) -> Result<SimilarityResponse, ApiError> {
compute_similarity_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_embed_file",
version = 1,
grpc_method = "vecboost.embed_file",
description = "Embed text from a file with path validation"
)]
pub async fn grpc_embed_file(req: FileEmbedRequest) -> Result<FileEmbedResponse, ApiError> {
embed_file_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_model_switch",
version = 1,
grpc_method = "vecboost.model_switch",
description = "Switch the currently loaded model"
)]
pub async fn grpc_model_switch(req: ModelSwitchRequest) -> Result<ModelSwitchResponse, ApiError> {
model_switch_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_get_current_model",
version = 1,
grpc_method = "vecboost.get_current_model",
description = "Get information about the currently loaded model"
)]
pub async fn grpc_get_current_model() -> Result<ModelInfo, ApiError> {
get_current_model_handler().await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_get_model_info",
version = 1,
grpc_method = "vecboost.get_model_info",
description = "Get metadata about the currently loaded model"
)]
pub async fn grpc_get_model_info() -> Result<ModelMetadata, ApiError> {
get_model_info_handler().await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_list_models",
version = 1,
grpc_method = "vecboost.list_models",
description = "List all available models"
)]
pub async fn grpc_list_models() -> Result<ModelListResponse, ApiError> {
list_models_handler().await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_search",
version = 1,
grpc_method = "vecboost.search",
description = "1-to-N semantic search over candidate texts"
)]
pub async fn grpc_search(req: SearchRequest) -> Result<SearchResponse, ApiError> {
search_handler(req).await
}
#[cfg(feature = "cli")]
#[forge(
name = "search",
version = 1,
cli = true,
description = "Rank candidate texts by similarity to the query (1-to-N search)"
)]
pub async fn cli_search(req: SearchRequest) -> Result<SearchResponse, ApiError> {
search_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_model_unload",
version = 1,
grpc_method = "vecboost.model_unload",
description = "Unload a model from the model manager cache"
)]
pub async fn grpc_unload_model(req: UnloadModelRequest) -> Result<UnloadModelResponse, ApiError> {
unload_model_handler(req).await
}
#[cfg(feature = "grpc")]
#[forge(
name = "vecboost_health_check",
version = 1,
grpc_method = "vecboost.health_check",
description = "Service health check"
)]
pub async fn grpc_health_check() -> Result<serde_json::Value, ApiError> {
health_handler(None).await
}
#[cfg(test)]
mod tests {
#[test]
fn to_api_error_carries_fluent_error_code() {
let api = to_api_error(VecboostError::InternalError("boom".into()));
let svc = api.to_service_error();
let wire = serde_json::to_value(&svc).unwrap();
assert_eq!(wire["code"], "INTERNAL_ERROR");
assert_eq!(
wire["details"]["context"]["extra"]["error_code"],
"error-internal"
);
}
#[cfg(feature = "http")]
#[test]
fn openai_error_detail_carries_type_and_code() {
let d = openai_error_detail("invalid_request_error", "model_not_found").unwrap();
assert_eq!(d["openai_error_type"], "invalid_request_error");
assert_eq!(d["openai_code"], "model_not_found");
}
#[test]
fn check_file_embed_size_enforces_limit() {
crate::i18n::init();
assert!(check_file_embed_size(0).is_ok());
assert!(check_file_embed_size(10 * 1024 * 1024).is_ok());
let err = check_file_embed_size(10 * 1024 * 1024 + 1).unwrap_err();
assert!(err.contains("10 MiB"));
}
#[test]
fn model_path_validator_enforces_allowed_roots() {
let base = std::env::temp_dir().join(format!("vb_model_paths_{}", std::process::id()));
let inside = base.join("my-model");
std::fs::create_dir_all(&inside).expect("create dirs");
let roots = vec![base.to_string_lossy().to_string()];
let validator = model_path_validator(Some(&roots));
assert!(validator.validate_directory(&inside).is_ok());
let outside = std::env::temp_dir().join(format!("vb_outside_{}", std::process::id()));
std::fs::create_dir_all(&outside).expect("create dirs");
assert!(validator.validate_directory(&outside).is_err());
let default_validator = model_path_validator(None);
let default_root = std::path::Path::new("models").canonicalize().ok();
if let Some(root) = default_root {
assert!(default_validator.validate_directory(&root).is_ok());
}
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&outside);
}
use super::*;
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_text_length_under_limit_passes() {
let texts = vec!["short".to_string(), "also short".to_string()];
assert!(validate_text_length(&texts, 100).is_ok());
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_text_length_at_boundary_passes() {
let text = "a".repeat(8192);
let texts = vec![text];
assert!(validate_text_length(&texts, 8192).is_ok());
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_text_length_exceeds_limit_returns_error() {
crate::i18n::init();
let texts = vec!["ok".to_string(), "x".repeat(101)];
let err = validate_text_length(&texts, 100).unwrap_err();
match err {
VecboostError::ValidationError(msg) => {
assert!(msg.contains("1"), "error should mention index 1: {msg}");
assert!(msg.contains("100"), "error should mention limit: {msg}");
assert!(
msg.contains("101"),
"error should mention actual length: {msg}"
);
}
other => panic!("expected ValidationError, got {other:?}"),
}
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_text_length_empty_slice_passes() {
let texts: Vec<String> = vec![];
assert!(validate_text_length(&texts, 100).is_ok());
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_text_length_first_offending_text_reported() {
crate::i18n::init();
let texts = vec!["x".repeat(51), "x".repeat(52)];
let err = validate_text_length(&texts, 50).unwrap_err();
match err {
VecboostError::ValidationError(msg) => {
assert!(
msg.contains("0"),
"should report first offending index: {msg}"
);
}
other => panic!("expected ValidationError, got {other:?}"),
}
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_batch_size_under_limit_passes() {
assert!(validate_batch_size(10, 64).is_ok());
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_batch_size_exceeds_limit_returns_error() {
crate::i18n::init();
let err = validate_batch_size(100, 64).unwrap_err();
match err {
VecboostError::ValidationError(msg) => {
assert!(
msg.contains("100"),
"error should mention actual size: {msg}"
);
assert!(msg.contains("64"), "error should mention limit: {msg}");
}
other => panic!("expected ValidationError, got {other:?}"),
}
}
#[cfg(any(feature = "http", feature = "cli", feature = "grpc"))]
#[test]
fn test_validate_batch_size_at_boundary_passes() {
assert!(validate_batch_size(64, 64).is_ok());
}
}