Skip to main content

synth_ai_core/api/
graph_evolve.rs

1//! Graph Evolve API client.
2//!
3//! Provides helpers for the Graph Evolve / GraphGen optimization endpoints.
4
5use serde_json::{json, Value};
6
7use crate::http::HttpError;
8use crate::CoreError;
9
10use super::client::SynthClient;
11
12const GRAPH_EVOLVE_ENDPOINT: &str = "/api/graph-evolve/jobs";
13const GRAPHGEN_LEGACY_ENDPOINT: &str = "/api/graphgen/jobs";
14const GRAPHGEN_COMPLETIONS_ENDPOINT: &str = "/api/graphgen/graph/completions";
15const GRAPHGEN_RECORD_ENDPOINT: &str = "/api/graphgen/graph/record";
16
17/// Graph Evolve API client.
18pub struct GraphEvolveClient<'a> {
19    client: &'a SynthClient,
20}
21
22impl<'a> GraphEvolveClient<'a> {
23    pub(crate) fn new(client: &'a SynthClient) -> Self {
24        Self { client }
25    }
26
27    async fn get_with_fallback(
28        &self,
29        primary: &str,
30        legacy: Option<&str>,
31        params: Option<&[(&str, &str)]>,
32    ) -> Result<Value, CoreError> {
33        match self.client.http.get_json(primary, params).await {
34            Ok(value) => Ok(value),
35            Err(err) => {
36                if err.status() == Some(404) {
37                    if let Some(legacy) = legacy {
38                        return self
39                            .client
40                            .http
41                            .get_json(legacy, params)
42                            .await
43                            .map_err(map_http_error);
44                    }
45                }
46                Err(map_http_error(err))
47            }
48        }
49    }
50
51    async fn post_with_fallback(
52        &self,
53        primary: &str,
54        legacy: Option<&str>,
55        payload: &Value,
56    ) -> Result<Value, CoreError> {
57        match self.client.http.post_json(primary, payload).await {
58            Ok(value) => Ok(value),
59            Err(err) => {
60                if err.status() == Some(404) {
61                    if let Some(legacy) = legacy {
62                        return self
63                            .client
64                            .http
65                            .post_json(legacy, payload)
66                            .await
67                            .map_err(map_http_error);
68                    }
69                }
70                Err(map_http_error(err))
71            }
72        }
73    }
74
75    async fn get_bytes_with_fallback(
76        &self,
77        primary: &str,
78        legacy: Option<&str>,
79    ) -> Result<Vec<u8>, CoreError> {
80        match self.client.http.get_bytes(primary, None).await {
81            Ok(bytes) => Ok(bytes),
82            Err(err) => {
83                if err.status() == Some(404) {
84                    if let Some(legacy) = legacy {
85                        return self
86                            .client
87                            .http
88                            .get_bytes(legacy, None)
89                            .await
90                            .map_err(map_http_error);
91                    }
92                }
93                Err(map_http_error(err))
94            }
95        }
96    }
97
98    /// Submit a Graph Evolve job.
99    pub async fn submit_job(&self, payload: Value) -> Result<Value, CoreError> {
100        self.post_with_fallback(
101            GRAPH_EVOLVE_ENDPOINT,
102            Some(GRAPHGEN_LEGACY_ENDPOINT),
103            &payload,
104        )
105        .await
106    }
107
108    /// Get job status.
109    pub async fn get_status(&self, job_id: &str) -> Result<Value, CoreError> {
110        let primary = format!("{}/{}", GRAPH_EVOLVE_ENDPOINT, job_id);
111        let legacy = format!("{}/{}", GRAPHGEN_LEGACY_ENDPOINT, job_id);
112        self.get_with_fallback(&primary, Some(&legacy), None).await
113    }
114
115    /// Start a job.
116    pub async fn start_job(&self, job_id: &str) -> Result<Value, CoreError> {
117        let primary = format!("{}/{}/start", GRAPH_EVOLVE_ENDPOINT, job_id);
118        let legacy = format!("{}/{}/start", GRAPHGEN_LEGACY_ENDPOINT, job_id);
119        self.post_with_fallback(&primary, Some(&legacy), &Value::Null)
120            .await
121    }
122
123    /// Get job events.
124    pub async fn get_events(
125        &self,
126        job_id: &str,
127        since_seq: i64,
128        limit: i64,
129    ) -> Result<Value, CoreError> {
130        let primary = format!("{}/{}/events", GRAPH_EVOLVE_ENDPOINT, job_id);
131        let legacy = format!("{}/{}/events", GRAPHGEN_LEGACY_ENDPOINT, job_id);
132        let since = since_seq.to_string();
133        let limit_s = limit.to_string();
134        let params = [("since_seq", since.as_str()), ("limit", limit_s.as_str())];
135        self.get_with_fallback(&primary, Some(&legacy), Some(&params))
136            .await
137    }
138
139    /// Get job metrics.
140    pub async fn get_metrics(&self, job_id: &str, query_string: &str) -> Result<Value, CoreError> {
141        let query = query_string.trim_start_matches('?');
142        let primary = if query.is_empty() {
143            format!("{}/{}/metrics", GRAPH_EVOLVE_ENDPOINT, job_id)
144        } else {
145            format!("{}/{}/metrics?{}", GRAPH_EVOLVE_ENDPOINT, job_id, query)
146        };
147        let legacy = if query.is_empty() {
148            format!("{}/{}/metrics", GRAPHGEN_LEGACY_ENDPOINT, job_id)
149        } else {
150            format!("{}/{}/metrics?{}", GRAPHGEN_LEGACY_ENDPOINT, job_id, query)
151        };
152        self.get_with_fallback(&primary, Some(&legacy), None).await
153    }
154
155    /// Download best prompt (JSON).
156    pub async fn download_prompt(&self, job_id: &str) -> Result<Value, CoreError> {
157        let primary = format!("{}/{}/download", GRAPH_EVOLVE_ENDPOINT, job_id);
158        let legacy = format!("{}/{}/download", GRAPHGEN_LEGACY_ENDPOINT, job_id);
159        self.get_with_fallback(&primary, Some(&legacy), None).await
160    }
161
162    /// Download redacted graph export (text).
163    pub async fn download_graph_txt(&self, job_id: &str) -> Result<String, CoreError> {
164        let primary = format!("{}/{}/graph.txt", GRAPH_EVOLVE_ENDPOINT, job_id);
165        let legacy = format!("{}/{}/graph.txt", GRAPHGEN_LEGACY_ENDPOINT, job_id);
166        let bytes = self
167            .get_bytes_with_fallback(&primary, Some(&legacy))
168            .await?;
169        Ok(String::from_utf8_lossy(&bytes).to_string())
170    }
171
172    /// Run inference using a graph.
173    pub async fn run_inference(&self, payload: Value) -> Result<Value, CoreError> {
174        self.client
175            .http
176            .post_json(GRAPHGEN_COMPLETIONS_ENDPOINT, &payload)
177            .await
178            .map_err(map_http_error)
179    }
180
181    /// Fetch a graph record snapshot.
182    pub async fn get_graph_record(&self, payload: Value) -> Result<Value, CoreError> {
183        self.client
184            .http
185            .post_json(GRAPHGEN_RECORD_ENDPOINT, &payload)
186            .await
187            .map_err(map_http_error)
188    }
189
190    /// Cancel a job.
191    pub async fn cancel_job(&self, job_id: &str, payload: Value) -> Result<Value, CoreError> {
192        let path = format!("/api/jobs/{}/cancel", job_id);
193        self.client
194            .http
195            .post_json(&path, &payload)
196            .await
197            .map_err(map_http_error)
198    }
199
200    /// Query workflow state directly.
201    pub async fn query_workflow_state(&self, job_id: &str) -> Result<Value, CoreError> {
202        let path = format!("/api/jobs/{}/workflow-state", job_id);
203        match self.client.http.get_json(&path, None).await {
204            Ok(value) => Ok(value),
205            Err(err) => {
206                let status = err
207                    .status()
208                    .map(|s| s.to_string())
209                    .unwrap_or_else(|| "unknown".to_string());
210                Ok(json!({
211                    "job_id": job_id,
212                    "workflow_state": Value::Null,
213                    "error": format!("HTTP {}: {}", status, err),
214                }))
215            }
216        }
217    }
218}
219
220/// Map HTTP errors to CoreError.
221fn map_http_error(e: HttpError) -> CoreError {
222    match e {
223        HttpError::Response(detail) => {
224            if detail.status == 401 || detail.status == 403 {
225                CoreError::Authentication(format!("authentication failed: {}", detail))
226            } else if detail.status == 429 {
227                CoreError::UsageLimit(crate::UsageLimitInfo::from_http_429(
228                    "graph_evolve",
229                    &detail,
230                ))
231            } else {
232                CoreError::HttpResponse(crate::HttpErrorInfo {
233                    status: detail.status,
234                    url: detail.url,
235                    message: detail.message,
236                    body_snippet: detail.body_snippet,
237                })
238            }
239        }
240        HttpError::Request(e) => CoreError::Http(e),
241        _ => CoreError::Internal(format!("{}", e)),
242    }
243}