Skip to main content

axonflow_sdk_rust/
client.rs

1use crate::config::{AxonFlowConfig, Mode};
2use crate::error::AxonFlowError;
3use crate::heartbeat::maybe_send_heartbeat;
4use crate::types::agent::{ClientRequest, ClientResponse};
5use crate::PATH_SEGMENT;
6use base64::engine::general_purpose::STANDARD as BASE64_STD;
7use base64::Engine as _;
8use moka::future::Cache;
9use percent_encoding::utf8_percent_encode;
10use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14use tracing::{debug, warn};
15
16const LICENSE_KEY_HEADER: &str = "X-License-Key";
17
18#[derive(Clone)]
19pub struct AxonFlowClient {
20    config: AxonFlowConfig,
21    http_client: reqwest::Client,
22    map_http_client: reqwest::Client,
23    cache: Option<Arc<Cache<String, ClientResponse>>>,
24}
25
26impl AxonFlowClient {
27    pub fn new(mut config: AxonFlowConfig) -> Result<Self, AxonFlowError> {
28        if config.retry.max_attempts == 0 {
29            return Err(AxonFlowError::ConfigError(
30                "retry.max_attempts must be at least 1".to_string(),
31            ));
32        }
33
34        if std::env::var("AXONFLOW_TRY").unwrap_or_default() == "1" {
35            config.endpoint = "https://try.getaxonflow.com".to_string();
36            if config.client_id.is_none() {
37                return Err(AxonFlowError::ConfigError(
38                    "ClientID is required in try mode (AXONFLOW_TRY=1).".to_string(),
39                ));
40            }
41        }
42
43        if config.client_secret.is_some() && config.client_id.is_none() {
44            warn!("ClientID is required when ClientSecret is set.");
45        }
46
47        let mut headers = HeaderMap::new();
48        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
49        headers.insert(
50            "User-Agent",
51            HeaderValue::from_static(concat!("axonflow-sdk-rust/", env!("CARGO_PKG_VERSION"))),
52        );
53        // ADR-050 §4: every governed request to the agent carries
54        // X-Axonflow-Client so the agent can derive request scope (sdk)
55        // and validate against the token's aud.scope via HasScope().
56        // Sourced from CARGO_PKG_VERSION; no env override (the consumer
57        // doesn't get to spoof its own client identity to the agent).
58        headers.insert(
59            "X-Axonflow-Client",
60            HeaderValue::from_static(concat!("sdk-rust/", env!("CARGO_PKG_VERSION"))),
61        );
62
63        // HTTP Basic auth: "Basic base64(client_id:client_secret)".
64        // When neither is configured, default to the community tenant —
65        // matches the cross-SDK contract (see axonflow-sdk-go selfhosted_auth_headers_test.go).
66        let basic_id = config.client_id.as_deref().unwrap_or("community");
67        let basic_secret = config.client_secret.as_deref().unwrap_or("");
68        let basic_credentials = BASE64_STD.encode(format!("{basic_id}:{basic_secret}"));
69        let basic_value = format!("Basic {}", basic_credentials);
70        if let Ok(val) = HeaderValue::from_str(&basic_value) {
71            headers.insert(AUTHORIZATION, val);
72        }
73
74        // X-Client-ID (v9): server-side identity decisions don't have to
75        // re-decode Basic auth. The agent's apiAuthMiddleware overwrites
76        // the header with its auth-derived value, so caller-supplied
77        // values are harmless (no spoofing surface).
78        if let Ok(val) = HeaderValue::from_str(basic_id) {
79            headers.insert("X-Client-ID", val);
80        }
81
82        // Enterprise license key — sent only when configured.
83        if let Some(license_key) = &config.license_key {
84            if let Ok(mut val) = HeaderValue::from_str(license_key) {
85                val.set_sensitive(true);
86                headers.insert(LICENSE_KEY_HEADER, val);
87            }
88        }
89
90        let accept_invalid = config.insecure_skip_tls_verify
91            || std::env::var("AXONFLOW_INSECURE_TLS").unwrap_or_default() == "1";
92
93        if accept_invalid {
94            warn!("TLS certificate verification is disabled.");
95        }
96
97        let http_client = reqwest::Client::builder()
98            .timeout(config.timeout)
99            .default_headers(headers.clone())
100            .danger_accept_invalid_certs(accept_invalid)
101            .pool_max_idle_per_host(5)
102            .tcp_keepalive(Duration::from_secs(30))
103            .build()
104            .map_err(AxonFlowError::HttpError)?;
105
106        let map_http_client = reqwest::Client::builder()
107            .timeout(config.map_timeout)
108            .default_headers(headers)
109            .danger_accept_invalid_certs(accept_invalid)
110            .pool_max_idle_per_host(5)
111            .tcp_keepalive(Duration::from_secs(30))
112            .build()
113            .map_err(AxonFlowError::HttpError)?;
114
115        let cache = if config.cache.enabled {
116            Some(Arc::new(
117                Cache::builder()
118                    .time_to_live(config.cache.ttl)
119                    .max_capacity(config.cache.max_capacity)
120                    .build(),
121            ))
122        } else {
123            None
124        };
125
126        maybe_send_heartbeat(&config.endpoint, &config.mode);
127
128        Ok(Self {
129            config,
130            http_client,
131            map_http_client,
132            cache,
133        })
134    }
135
136    #[tracing::instrument(skip(self, context))]
137    pub async fn proxy_llm_call(
138        &self,
139        user_token: &str,
140        query: &str,
141        request_type: &str,
142        context: HashMap<String, serde_json::Value>,
143    ) -> Result<ClientResponse, AxonFlowError> {
144        let user_token = if user_token.is_empty() {
145            "anonymous"
146        } else {
147            user_token
148        };
149
150        let is_mutation = matches!(
151            request_type,
152            "execute-plan" | "generate-plan" | "cancel-plan" | "update-plan"
153        );
154
155        if !is_mutation {
156            if let Some(cache) = &self.cache {
157                let cache_key = self.build_cache_key(request_type, query, user_token, &context);
158                if let Some(cached) = cache.get(&cache_key).await {
159                    debug!("Cache hit for query");
160                    return Ok(cached);
161                }
162            }
163        }
164
165        let req = ClientRequest {
166            query: query.to_string(),
167            user_token: user_token.to_string(),
168            client_id: self.config.client_id.clone(),
169            request_type: request_type.to_string(),
170            context,
171            media: None,
172        };
173
174        let resp = if self.config.retry.enabled && !is_mutation {
175            self.execute_with_retry(&req).await
176        } else {
177            self.execute_request(&req).await
178        };
179
180        match resp {
181            Ok(response) => {
182                if response.success && !is_mutation {
183                    if let Some(cache) = &self.cache {
184                        let cache_key =
185                            self.build_cache_key(request_type, query, user_token, &req.context);
186                        cache.insert(cache_key, response.clone()).await;
187                    }
188                }
189                Ok(response)
190            }
191            Err(e) => {
192                if self.config.mode == Mode::Production && e.is_fail_open_eligible() {
193                    debug!("AxonFlow unavailable, failing open: {}", e);
194                    Ok(ClientResponse::fail_open(e))
195                } else {
196                    Err(e)
197                }
198            }
199        }
200    }
201
202    // ============================================================================
203    // MCP Connector Management
204    // ============================================================================
205
206    pub async fn list_connectors(
207        &self,
208    ) -> Result<Vec<crate::types::agent::ConnectorMetadata>, AxonFlowError> {
209        let url = format!("{}/api/v1/connectors", self.config.endpoint);
210        let resp = self.checked_get(&url).await?;
211
212        let body: serde_json::Value = resp.json().await?;
213        let connectors = body["connectors"]
214            .as_array()
215            .ok_or_else(|| AxonFlowError::ApiError {
216                status: 200,
217                message: "response missing 'connectors' field".to_string(),
218            })?;
219
220        let result = serde_json::from_value(serde_json::Value::Array(connectors.clone()))?;
221        Ok(result)
222    }
223
224    pub async fn get_connector(
225        &self,
226        connector_id: &str,
227    ) -> Result<crate::types::agent::ConnectorMetadata, AxonFlowError> {
228        let encoded_id = utf8_percent_encode(connector_id, PATH_SEGMENT);
229        let url = format!("{}/api/v1/connectors/{}", self.config.endpoint, encoded_id);
230        let resp = self.checked_get(&url).await?;
231        Ok(resp.json().await?)
232    }
233
234    pub async fn get_connector_health(
235        &self,
236        connector_id: &str,
237    ) -> Result<crate::types::agent::ConnectorHealthStatus, AxonFlowError> {
238        let encoded_id = utf8_percent_encode(connector_id, PATH_SEGMENT);
239        let url = format!(
240            "{}/api/v1/connectors/{}/health",
241            self.config.endpoint, encoded_id
242        );
243        let resp = self.checked_get(&url).await?;
244        Ok(resp.json().await?)
245    }
246
247    pub async fn install_connector(
248        &self,
249        req: crate::types::agent::ConnectorInstallRequest,
250    ) -> Result<(), AxonFlowError> {
251        let encoded_id = utf8_percent_encode(&req.connector_id, PATH_SEGMENT);
252        let url = format!(
253            "{}/api/v1/connectors/{}/install",
254            self.config.endpoint, encoded_id
255        );
256        let resp = self.http_client.post(&url).json(&req).send().await?;
257        Self::check_status(resp).await?;
258        Ok(())
259    }
260
261    pub async fn query_connector(
262        &self,
263        user_token: &str,
264        connector_name: &str,
265        query: &str,
266        params: HashMap<String, serde_json::Value>,
267    ) -> Result<crate::types::agent::ConnectorResponse, AxonFlowError> {
268        // Connector queries are dispatched through the agent's proxy endpoint
269        // with request_type=mcp-query — there is no standalone /api/v1/query.
270        // Mirror the Go SDK's QueryConnector contract.
271        let mut context = HashMap::new();
272        context.insert("connector".to_string(), serde_json::json!(connector_name));
273        context.insert("params".to_string(), serde_json::json!(params));
274
275        let resp = self
276            .proxy_llm_call(user_token, query, "mcp-query", context)
277            .await?;
278
279        Ok(crate::types::agent::ConnectorResponse {
280            success: resp.success,
281            data: resp.data.unwrap_or(serde_json::Value::Null),
282            error: resp.error,
283            meta: resp.metadata,
284            redacted: false,
285            redacted_fields: Vec::new(),
286            policy_info: None,
287        })
288    }
289
290    // ============================================================================
291    // Multi-Agent Planning (MAP)
292    // ============================================================================
293
294    #[tracing::instrument(skip(self))]
295    pub async fn generate_plan(
296        &self,
297        query: &str,
298        domain: &str,
299        user_token: Option<&str>,
300    ) -> Result<crate::types::agent::PlanResponse, AxonFlowError> {
301        let mut context = HashMap::new();
302        context.insert("domain".to_string(), serde_json::json!(domain));
303        let user_token = user_token.unwrap_or("anonymous");
304
305        let resp = self
306            .proxy_llm_call(user_token, query, "generate-plan", context)
307            .await?;
308
309        if let Some(data) = resp.data {
310            let plan: crate::types::agent::PlanResponse = serde_json::from_value(data)?;
311            Ok(plan)
312        } else {
313            Err(AxonFlowError::ApiError {
314                status: 500,
315                message: "empty plan data".to_string(),
316            })
317        }
318    }
319
320    pub async fn execute_plan(
321        &self,
322        plan_id: &str,
323        user_token: Option<&str>,
324    ) -> Result<crate::types::agent::PlanExecutionResponse, AxonFlowError> {
325        let mut context = HashMap::new();
326        context.insert("plan_id".to_string(), serde_json::json!(plan_id));
327        let user_token = user_token.unwrap_or("anonymous");
328
329        let resp = self
330            .proxy_llm_call(user_token, "", "execute-plan", context)
331            .await?;
332
333        if let Some(data) = resp.data {
334            let mut exec: crate::types::agent::PlanExecutionResponse =
335                serde_json::from_value(data)?;
336            // The execute-plan wire payload carries no `status` field (only
337            // metadata/plan_id), so default it from the envelope verdict —
338            // gated on `success`: a policy-blocked or failed execution must
339            // never read as "completed" (enterprise#2861 sweep, R3).
340            if exec.status.is_empty() {
341                exec.status = if resp.success && !resp.blocked {
342                    "completed".to_string()
343                } else {
344                    "failed".to_string()
345                };
346            }
347            if exec.error.is_none() {
348                exec.error = resp.error;
349            }
350            Ok(exec)
351        } else {
352            Err(AxonFlowError::ApiError {
353                status: 500,
354                message: "empty execution data".to_string(),
355            })
356        }
357    }
358
359    pub async fn get_plan_status(
360        &self,
361        plan_id: &str,
362    ) -> Result<crate::types::agent::PlanExecutionResponse, AxonFlowError> {
363        let encoded_id = utf8_percent_encode(plan_id, PATH_SEGMENT);
364        let url = format!("{}/api/v1/plan/{}", self.config.endpoint, encoded_id);
365        let resp = self.checked_map_get(&url).await?;
366        Ok(resp.json().await?)
367    }
368
369    pub async fn cancel_plan(
370        &self,
371        plan_id: &str,
372        reason: Option<&str>,
373    ) -> Result<crate::types::agent::CancelPlanResponse, AxonFlowError> {
374        let req_body = serde_json::json!({
375            "reason": reason.unwrap_or("user_cancelled"),
376        });
377
378        let encoded_id = utf8_percent_encode(plan_id, PATH_SEGMENT);
379        let url = format!("{}/api/v1/plan/{}/cancel", self.config.endpoint, encoded_id);
380        let resp = self
381            .map_http_client
382            .post(&url)
383            .json(&req_body)
384            .send()
385            .await?;
386        let resp = Self::check_status(resp).await?;
387        Ok(resp.json().await?)
388    }
389
390    pub async fn audit_llm_call(
391        &self,
392        req: &crate::types::agent::AuditRequest,
393    ) -> Result<crate::types::agent::AuditResult, AxonFlowError> {
394        let client_id = self.get_effective_client_id();
395
396        let mut req_body = serde_json::to_value(req)?;
397        req_body["client_id"] = serde_json::json!(client_id);
398        // Platform expects "metadata": {} when absent, not null.
399        if req_body.get("metadata").map_or(true, |v| v.is_null()) {
400            req_body["metadata"] = serde_json::json!({});
401        }
402
403        let url = format!("{}/api/audit/llm-call", self.config.endpoint);
404        let resp = self.http_client.post(&url).json(&req_body).send().await?;
405
406        let status = resp.status();
407        let body = resp.text().await?;
408
409        if status.is_success() {
410            let audit_resp: crate::types::agent::AuditResult = serde_json::from_str(&body)?;
411            Ok(audit_resp)
412        } else {
413            Err(AxonFlowError::ApiError {
414                status: status.as_u16(),
415                message: body,
416            })
417        }
418    }
419
420    // ============================================================================
421    // Private helpers
422    // ============================================================================
423
424    fn get_effective_client_id(&self) -> String {
425        self.config
426            .client_id
427            .clone()
428            .unwrap_or_else(|| "community".to_string())
429    }
430
431    fn build_cache_key(
432        &self,
433        request_type: &str,
434        query: &str,
435        user_token: &str,
436        context: &HashMap<String, serde_json::Value>,
437    ) -> String {
438        use std::hash::{Hash, Hasher};
439        let mut hasher = std::collections::hash_map::DefaultHasher::new();
440        request_type.hash(&mut hasher);
441        query.hash(&mut hasher);
442        user_token.hash(&mut hasher);
443        if !context.is_empty() {
444            let sorted: std::collections::BTreeMap<_, _> = context.iter().collect();
445            serde_json::to_string(&sorted)
446                .unwrap_or_default()
447                .hash(&mut hasher);
448        }
449        format!("{:x}", hasher.finish())
450    }
451
452    /// Endpoint URL the client is configured against.
453    /// Crate-internal accessor for sibling modules (e.g. `decisions.rs`)
454    /// that need to build absolute URLs without exposing `config`.
455    pub(crate) fn endpoint(&self) -> &str {
456        &self.config.endpoint
457    }
458
459    pub(crate) async fn checked_get(&self, url: &str) -> Result<reqwest::Response, AxonFlowError> {
460        let resp = self.http_client.get(url).send().await?;
461        Self::check_status(resp).await
462    }
463
464    /// Crate-internal POST that serializes `body` as JSON and translates
465    /// non-2xx into [`AxonFlowError::ApiError`] — the symmetric helper to
466    /// [`checked_get`](Self::checked_get). Used by sibling modules
467    /// (e.g. `hitl`) that POST a typed payload and don't need to branch
468    /// on specific status codes before falling back to the generic error
469    /// path.
470    pub(crate) async fn checked_post_json<T: serde::Serialize + ?Sized>(
471        &self,
472        url: &str,
473        body: &T,
474    ) -> Result<reqwest::Response, AxonFlowError> {
475        let resp = self.http_client.post(url).json(body).send().await?;
476        Self::check_status(resp).await
477    }
478
479    /// Crate-internal GET that returns the raw response without translating
480    /// non-2xx into [`AxonFlowError::ApiError`]. Lets sibling modules branch
481    /// on specific status codes (e.g. parse a 429 V1 upgrade envelope into
482    /// [`AxonFlowError::RateLimited`]) before falling back to the generic
483    /// error path.
484    pub(crate) async fn raw_get(&self, url: &str) -> Result<reqwest::Response, AxonFlowError> {
485        Ok(self.http_client.get(url).send().await?)
486    }
487
488    async fn checked_map_get(&self, url: &str) -> Result<reqwest::Response, AxonFlowError> {
489        let resp = self.map_http_client.get(url).send().await?;
490        Self::check_status(resp).await
491    }
492
493    async fn check_status(resp: reqwest::Response) -> Result<reqwest::Response, AxonFlowError> {
494        if resp.status().is_success() {
495            Ok(resp)
496        } else {
497            let status = resp.status().as_u16();
498            let message = resp.text().await?;
499            Err(AxonFlowError::ApiError { status, message })
500        }
501    }
502
503    /// Retry the request with exponential backoff, honoring the
504    /// SDK-wide retry contract.
505    ///
506    /// **Retried status codes:**
507    /// - 5xx — server-side failures (treated as transient).
508    /// - 429 — rate-limit responses (transient by definition).
509    /// - Transport-level errors (connection refused, DNS, TLS) —
510    ///   surfaced as non-`ApiError` variants of [`AxonFlowError`];
511    ///   the `if let AxonFlowError::ApiError { .. }` guard doesn't
512    ///   match them, so they fall through to `last_err = Some(e)` and
513    ///   retry on the next iteration.
514    ///
515    /// **Terminal status codes (early `return Err(e)`):**
516    /// - 401 — auth failure. Retrying with the same invalid
517    ///   credential just compounds the storm on the agent. See
518    ///   issue [#2275](https://github.com/getaxonflow/axonflow-enterprise/issues/2275)
519    ///   for the customer-observed retry loop that motivated the
520    ///   regression-locking test `test_401_not_retried_issue_2275`.
521    /// - 400, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415,
522    ///   416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 431, 451 —
523    ///   every other 4xx that isn't in the `{429, 402, 403}` allowlist.
524    ///
525    /// **Caveat on 402/403:** `execute_request` returns 402 + 403 as
526    /// `Ok(client_resp)` because those are SUCCESS responses carrying
527    /// policy/quota envelope data — not errors. They never reach this
528    /// function as `Err`, so the `*status != 402` and `*status != 403`
529    /// clauses below are functionally dead in current code. They're
530    /// kept as intent-preserving belt-and-suspenders for any future
531    /// refactor that converts 402/403 back to `Err`.
532    ///
533    /// See `CHANGELOG.md` for the contract's history.
534    async fn execute_with_retry(
535        &self,
536        req: &ClientRequest,
537    ) -> Result<ClientResponse, AxonFlowError> {
538        let mut last_err = None;
539
540        for attempt in 0..self.config.retry.max_attempts {
541            if attempt > 0 {
542                let delay =
543                    self.config.retry.initial_delay.as_secs_f64() * 2f64.powi((attempt - 1) as i32);
544                tokio::time::sleep(Duration::from_secs_f64(delay)).await;
545            }
546
547            match self.execute_request(req).await {
548                Ok(resp) => return Ok(resp),
549                Err(e) => {
550                    if let AxonFlowError::ApiError { status, .. } = &e {
551                        // Retry allowlist: any 4xx NOT in {429, 402, 403} is
552                        // terminal. 5xx always retries (falls through to the
553                        // `last_err = Some(e)` path below).
554                        //
555                        // 402/403 NEVER reach this branch as `Err`: see
556                        // `execute_request` at line 586 — those statuses
557                        // return as `Ok(client_resp)` because they carry
558                        // policy/quota envelope data. The `*status != 402`
559                        // and `*status != 403` clauses are intentional
560                        // belt-and-suspenders for a hypothetical future
561                        // refactor that errors on those statuses.
562                        if *status >= 400
563                            && *status < 500
564                            && *status != 429
565                            && *status != 402
566                            && *status != 403
567                        {
568                            return Err(e);
569                        }
570                    }
571                    last_err = Some(e);
572                }
573            }
574        }
575
576        Err(last_err.unwrap_or_else(|| {
577            AxonFlowError::ConfigError("retry loop completed with no attempts".to_string())
578        }))
579    }
580
581    async fn execute_request(&self, req: &ClientRequest) -> Result<ClientResponse, AxonFlowError> {
582        let url = format!("{}/api/request", self.config.endpoint);
583        let resp = self.http_client.post(&url).json(req).send().await?;
584
585        let status = resp.status();
586        let body = resp.text().await?;
587
588        if status.is_success() || status.as_u16() == 402 || status.as_u16() == 403 {
589            match serde_json::from_str::<ClientResponse>(&body) {
590                Ok(r) => Ok(r),
591                Err(_) => Err(AxonFlowError::ApiError {
592                    status: status.as_u16(),
593                    message: body,
594                }),
595            }
596        } else {
597            Err(AxonFlowError::ApiError {
598                status: status.as_u16(),
599                message: body,
600            })
601        }
602    }
603}