use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use forge_app::ProviderService; use forge_app::domain::{ AnyProvider, ChatCompletionMessage, Model, ModelId, ProviderId, ResultStream, }; use forge_domain::{ AuthCredential, ChatRepository, Context, MigrationResult, ModelSource, Provider, ProviderRepository, ProviderTemplate, }; use url::Url; /// Service layer wrapper for ProviderRepository that handles template rendering pub struct ForgeProviderService { repository: Arc, } impl ForgeProviderService { /// Creates a new ForgeProviderService instance pub fn new(repository: Arc) -> Self { Self { repository } } /// Renders a URL template with provided parameters. /// /// Params present in the credential are passed through as strings. /// Optional specs that are absent from the credential entirely are /// inserted as JSON `null` so that `{{#if PARAM}}` blocks evaluate /// to false in the Handlebars template (e.g. a port not provided). fn render_url_template( &self, template: &str, params: &HashMap, specs: &[forge_domain::URLParamSpec], ) -> Result { // Start with all stored params as string values. let mut template_data: HashMap<&str, serde_json::Value> = params .iter() .map(|(k, v)| { ( k.as_str(), serde_json::Value::String(v.as_str().to_string()), ) }) .collect(); // For optional specs that are entirely absent from the credential, // inject null so {{#if PARAM}} is falsy instead of erroring. for spec in specs { if spec.optional && !params.contains_key(&spec.name) { template_data.insert(spec.name.as_str(), serde_json::Value::Null); } } let handlebars = forge_app::TemplateEngine::handlebar_instance(); let rendered = handlebars.render_template(template, &template_data)?; Ok(Url::parse(&rendered)?) } /// Renders a provider from template to fully resolved URLs fn render_provider(&self, template_provider: ProviderTemplate) -> Result> { let credential = template_provider .credential .as_ref() .ok_or_else(|| anyhow::anyhow!("Provider has no credential"))?; // Render main URL let url = self.render_url_template( &template_provider.url.template, &credential.url_params, &template_provider.url_params, )?; // Render model source URLs let models = template_provider.models.as_ref().and_then(|m| match m { ModelSource::Url(template) => { let model_url = self .render_url_template( &template.template, &credential.url_params, &template_provider.url_params, ) .ok(); model_url.map(ModelSource::Url) } ModelSource::Hardcoded(list) => Some(ModelSource::Hardcoded(list.clone())), }); Ok(Provider { id: template_provider.id, provider_type: template_provider.provider_type, response: template_provider.response, url, models, auth_methods: template_provider.auth_methods, url_params: template_provider.url_params, credential: template_provider.credential, custom_headers: template_provider.custom_headers, }) } } #[async_trait::async_trait] impl ProviderService for ForgeProviderService { async fn chat( &self, model_id: &ModelId, context: Context, provider: Provider, ) -> ResultStream { // Repository builds client on each call (no caching at repository level) self.repository.chat(model_id, context, provider).await } async fn models(&self, provider: Provider) -> Result> { self.repository.models(provider).await } async fn get_all_providers(&self) -> Result> { let providers = self.repository.get_all_providers().await?; // Render configured providers from Template to Url let rendered_providers = providers .into_iter() .map(|provider| { // If provider is a Template with credentials, render it to Url if let AnyProvider::Template(template_provider) = &provider && template_provider.is_configured() { // Clone and render the provider if let Ok(rendered) = self.render_provider(template_provider.clone()) { return AnyProvider::Url(rendered); } } // Otherwise return as-is provider }) .collect(); Ok(rendered_providers) } async fn get_provider(&self, id: ProviderId) -> Result> { let template_provider = self.repository.get_provider(id).await?; self.render_provider(template_provider) } async fn upsert_credential(&self, credential: AuthCredential) -> Result<()> { self.repository.upsert_credential(credential).await } async fn remove_credential(&self, id: &ProviderId) -> Result<()> { self.repository.remove_credential(id).await } async fn migrate_env_credentials(&self) -> Result> { self.repository.migrate_env_credentials().await } } #[cfg(test)] mod tests { use forge_app::domain::ProviderId; use forge_domain::{ AuthDetails, AuthMethod, InputModality, ModelSource, ProviderType, Template, }; use pretty_assertions::assert_eq; use super::*; // Mock repository for testing struct MockProviderRepository { models: Vec, providers: Vec, } impl MockProviderRepository { fn new(models: Vec) -> Self { Self { models, providers: vec![] } } fn with_providers(mut self, providers: Vec) -> Self { self.providers = providers; self } } #[async_trait::async_trait] impl ChatRepository for MockProviderRepository { async fn chat( &self, _model_id: &ModelId, _context: Context, _provider: Provider, ) -> ResultStream { Ok(Box::pin(tokio_stream::empty())) } async fn models(&self, _provider: Provider) -> Result> { Ok(self.models.clone()) } } #[async_trait::async_trait] impl ProviderRepository for MockProviderRepository { async fn get_all_providers(&self) -> Result> { Ok(self.providers.clone()) } async fn get_provider(&self, _id: ProviderId) -> Result { Ok(test_template_provider()) } async fn get_credential(&self, _id: &ProviderId) -> Result> { Ok(None) } async fn upsert_credential(&self, _credential: AuthCredential) -> Result<()> { Ok(()) } async fn remove_credential(&self, _id: &ProviderId) -> Result<()> { Ok(()) } async fn migrate_env_credentials(&self) -> Result> { Ok(None) } } fn test_provider() -> Provider { Provider { id: ProviderId::OPENAI, provider_type: ProviderType::Llm, response: Some(forge_app::domain::ProviderResponse::OpenAI), url: Url::parse("https://api.openai.com/v1/chat/completions").unwrap(), auth_methods: vec![AuthMethod::ApiKey], url_params: vec![], credential: Some(AuthCredential { id: ProviderId::OPENAI, auth_details: AuthDetails::ApiKey(forge_domain::ApiKey::from( "test-key".to_string(), )), url_params: HashMap::new(), }), models: Some(ModelSource::Url( Url::parse("https://api.openai.com/v1/models").unwrap(), )), custom_headers: None, } } fn test_template_provider() -> ProviderTemplate { Provider { id: ProviderId::OPENAI, provider_type: ProviderType::Llm, response: Some(forge_app::domain::ProviderResponse::OpenAI), url: Template::::new( "https://api.openai.com/v1/chat/completions", ), auth_methods: vec![AuthMethod::ApiKey], url_params: vec![], credential: Some(AuthCredential { id: ProviderId::OPENAI, auth_details: AuthDetails::ApiKey(forge_domain::ApiKey::from( "test-key".to_string(), )), url_params: HashMap::new(), }), models: Some(ModelSource::Url( Template::::new("https://api.openai.com/v1/models"), )), custom_headers: None, } } fn test_model(id: &str) -> Model { Model { id: ModelId::from(id), name: Some(id.to_string()), description: None, context_length: Some(4096), tools_supported: Some(true), supports_parallel_tool_calls: Some(true), supports_reasoning: Some(false), input_modalities: vec![InputModality::Text], } } #[tokio::test] async fn test_models_delegates_to_repository() { let models = vec![test_model("gpt-4"), test_model("gpt-3.5-turbo")]; let repository = Arc::new(MockProviderRepository::new(models.clone())); let service = ForgeProviderService::new(repository); let provider = test_provider(); let actual = service.models(provider).await.unwrap(); assert_eq!(actual, models); } #[tokio::test] async fn test_get_all_providers_renders_configured_providers() { let configured = test_template_provider(); let unconfigured = Provider { credential: None, ..test_template_provider() }; let repository = Arc::new(MockProviderRepository::new(vec![]).with_providers(vec![ AnyProvider::Template(configured), AnyProvider::Template(unconfigured), ])); let service = ForgeProviderService::new(repository); let actual = service.get_all_providers().await.unwrap(); assert_eq!(actual.len(), 2); assert!(matches!(actual[0], AnyProvider::Url(_))); assert!(matches!(actual[1], AnyProvider::Template(_))); if let AnyProvider::Url(provider) = &actual[0] { assert_eq!( provider.url.as_str(), "https://api.openai.com/v1/chat/completions" ); } } #[test] fn test_render_url_template_optional_port_absent() { // VLLM_PORT is absent from the credential (user left it blank). // render_url_template must inject null so {{#if VLLM_PORT}} is falsy. let service = ForgeProviderService::new(Arc::new(MockProviderRepository::new(vec![]))); let template = "{{VLLM_SSL_SCHEME}}://{{VLLM_HOST}}{{#if VLLM_PORT}}:{{VLLM_PORT}}{{/if}}/v1/chat/completions"; let mut params = HashMap::new(); params.insert( forge_domain::URLParam::from("VLLM_SSL_SCHEME".to_string()), forge_domain::URLParamValue::from("https".to_string()), ); params.insert( forge_domain::URLParam::from("VLLM_HOST".to_string()), forge_domain::URLParamValue::from("my.server.url".to_string()), ); // VLLM_PORT intentionally absent — not in the credential map at all. let specs = vec![forge_domain::URLParamSpec::optional( forge_domain::URLParam::from("VLLM_PORT".to_string()), )]; let actual = service .render_url_template(template, ¶ms, &specs) .unwrap(); let expected = "https://my.server.url/v1/chat/completions"; assert_eq!(actual.as_str(), expected); } #[test] fn test_render_url_template_optional_port_with_value() { // When VLLM_PORT has a value, it should appear in the URL. let service = ForgeProviderService::new(Arc::new(MockProviderRepository::new(vec![]))); let template = "{{VLLM_SSL_SCHEME}}://{{VLLM_HOST}}{{#if VLLM_PORT}}:{{VLLM_PORT}}{{/if}}/v1/chat/completions"; let mut params = HashMap::new(); params.insert( forge_domain::URLParam::from("VLLM_SSL_SCHEME".to_string()), forge_domain::URLParamValue::from("https".to_string()), ); params.insert( forge_domain::URLParam::from("VLLM_HOST".to_string()), forge_domain::URLParamValue::from("my.server.url".to_string()), ); params.insert( forge_domain::URLParam::from("VLLM_PORT".to_string()), forge_domain::URLParamValue::from("8000".to_string()), ); let specs = vec![forge_domain::URLParamSpec::optional( forge_domain::URLParam::from("VLLM_PORT".to_string()), )]; let actual = service .render_url_template(template, ¶ms, &specs) .unwrap(); let expected = "https://my.server.url:8000/v1/chat/completions"; assert_eq!(actual.as_str(), expected); } }