Skip to main content

greentic_aw_runtime/
http_provider.rs

1//! A [`ConfigProvider`] that pulls a full [`AgentConfig`] from the
2//! greentic-designer-admin agent registry over HTTP, authed with a tenant
3//! `gtc_live_*` bearer token. Tenant is implied by the token; `tenant` arg is
4//! accepted for the trait but not used for the request.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::config::AgentConfig;
10use crate::config_provider::ConfigProvider;
11use crate::error::ConfigError;
12use crate::tenant::TenantContext;
13
14/// Pulls `AgentConfig` from `{base}/api/v1/designer/agents/{agent_id}`.
15pub struct HttpConfigProvider {
16    base_url: String,
17    token: String,
18    client: reqwest::Client,
19}
20
21impl HttpConfigProvider {
22    /// `base_url` is the admin origin (no trailing slash needed); `token` is a
23    /// tenant `gtc_live_*` key.
24    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
25        // A per-request timeout matches the crate idiom (see llm_openai.rs) and
26        // bounds a hung registry: without it the calling step() would block with
27        // no upper bound, and the LayeredConfigProvider fallback cannot fire
28        // until this future resolves.
29        let client = reqwest::Client::builder()
30            .timeout(std::time::Duration::from_secs(10))
31            .build()
32            .unwrap_or_default();
33        Self {
34            base_url: base_url.into().trim_end_matches('/').to_string(),
35            token: token.into(),
36            client,
37        }
38    }
39}
40
41impl ConfigProvider for HttpConfigProvider {
42    fn agent_config<'a>(
43        &'a self,
44        _tenant: &'a TenantContext,
45        agent_id: &'a str,
46    ) -> Pin<Box<dyn Future<Output = Result<AgentConfig, ConfigError>> + Send + 'a>> {
47        Box::pin(async move {
48            let url = format!("{}/api/v1/designer/agents/{agent_id}", self.base_url);
49            let resp = self
50                .client
51                .get(&url)
52                .bearer_auth(&self.token)
53                .send()
54                .await
55                .map_err(|e| {
56                    ConfigError::Internal(format!("agent registry request failed: {e}"))
57                })?;
58
59            match resp.status().as_u16() {
60                200 => resp
61                    .json::<AgentConfig>()
62                    .await
63                    .map_err(|e| ConfigError::Misconfigured(format!("agent config decode: {e}"))),
64                404 => Err(ConfigError::AgentNotFound(agent_id.to_string())),
65                // Auth failures are operator-actionable misconfig, not a
66                // transient fault — surface them (Misconfigured is NOT swallowed
67                // by the LayeredConfigProvider fallback) rather than masking a
68                // bad token behind a local fallback.
69                401 | 403 => Err(ConfigError::Misconfigured(format!(
70                    "agent registry auth rejected (status {})",
71                    resp.status().as_u16()
72                ))),
73                other => Err(ConfigError::Internal(format!(
74                    "agent registry returned status {other}"
75                ))),
76            }
77        })
78    }
79}
80
81#[cfg(test)]
82#[allow(clippy::unwrap_used, clippy::expect_used)]
83mod tests {
84    use super::*;
85    use wiremock::matchers::{header, method, path};
86    use wiremock::{Mock, MockServer, ResponseTemplate};
87
88    fn agent_config_json() -> serde_json::Value {
89        serde_json::json!({
90            "agent_id": "bot",
91            "system_prompt": "be helpful",
92            "tools": [{ "extension_id": "greentic.tavily", "tool_name": "web_search" }],
93            "llm": { "provider": "openai", "model": "gpt-4o-mini" },
94            "limits": {}
95        })
96    }
97
98    #[tokio::test]
99    async fn fetches_and_parses_agent_config() {
100        let server = MockServer::start().await;
101        Mock::given(method("GET"))
102            .and(path("/api/v1/designer/agents/bot"))
103            .and(header("authorization", "Bearer gtc_live_x"))
104            .respond_with(ResponseTemplate::new(200).set_body_json(agent_config_json()))
105            .mount(&server)
106            .await;
107
108        let provider = HttpConfigProvider::new(server.uri(), "gtc_live_x");
109        let tenant = TenantContext::new("t", "e");
110        let cfg = provider.agent_config(&tenant, "bot").await.unwrap();
111        assert_eq!(cfg.agent_id, "bot");
112        assert_eq!(cfg.llm.model, "gpt-4o-mini");
113        assert_eq!(cfg.tools.len(), 1);
114    }
115
116    #[tokio::test]
117    async fn maps_404_to_agent_not_found() {
118        let server = MockServer::start().await;
119        Mock::given(method("GET"))
120            .respond_with(ResponseTemplate::new(404))
121            .mount(&server)
122            .await;
123        let provider = HttpConfigProvider::new(server.uri(), "gtc_live_x");
124        let tenant = TenantContext::new("t", "e");
125        let result = provider.agent_config(&tenant, "ghost").await;
126        assert!(matches!(result, Err(ConfigError::AgentNotFound(_))));
127    }
128
129    #[tokio::test]
130    async fn maps_5xx_to_internal() {
131        let server = MockServer::start().await;
132        Mock::given(method("GET"))
133            .respond_with(ResponseTemplate::new(503))
134            .mount(&server)
135            .await;
136        let provider = HttpConfigProvider::new(server.uri(), "gtc_live_x");
137        let tenant = TenantContext::new("t", "e");
138        let result = provider.agent_config(&tenant, "bot").await;
139        assert!(matches!(result, Err(ConfigError::Internal(_))));
140    }
141
142    #[tokio::test]
143    async fn maps_malformed_body_to_misconfigured() {
144        let server = MockServer::start().await;
145        Mock::given(method("GET"))
146            .respond_with(ResponseTemplate::new(200).set_body_string("{not json"))
147            .mount(&server)
148            .await;
149        let provider = HttpConfigProvider::new(server.uri(), "gtc_live_x");
150        let tenant = TenantContext::new("t", "e");
151        let result = provider.agent_config(&tenant, "bot").await;
152        assert!(matches!(result, Err(ConfigError::Misconfigured(_))));
153    }
154
155    #[tokio::test]
156    async fn maps_auth_rejection_to_misconfigured() {
157        let server = MockServer::start().await;
158        Mock::given(method("GET"))
159            .respond_with(ResponseTemplate::new(401))
160            .mount(&server)
161            .await;
162        let provider = HttpConfigProvider::new(server.uri(), "gtc_live_bad");
163        let tenant = TenantContext::new("t", "e");
164        let result = provider.agent_config(&tenant, "bot").await;
165        assert!(matches!(result, Err(ConfigError::Misconfigured(_))));
166    }
167}