Skip to main content

everruns_integrations_parallel/
payments.rs

1//! Parallel machine-payment capability.
2//!
3//! Decision: expose only Parallel-specific tools. Payment mechanics stay inside
4//! `PaymentAuthority`, so the model cannot initiate arbitrary paid HTTP calls.
5//! Decision: this lives in the Parallel integration crate, not core. Core owns the
6//! payment trust boundary (`PaymentAuthority`, payment DTOs, `ToolContext`); the
7//! vendor-specific paid adapter is a plugin gated by the `machine_payments`
8//! internal feature flag.
9
10use async_trait::async_trait;
11use everruns_core::ToolHints;
12use everruns_core::capabilities::{
13    Capability, CapabilityLocalization, CapabilityStatus, RiskLevel,
14};
15use everruns_core::payment::{MachinePaymentRequest, PaymentMethod, PaymentRail};
16use everruns_core::tools::{Tool, ToolExecutionResult};
17use everruns_core::traits::ToolContext;
18use serde::Deserialize;
19use serde_json::{Value, json};
20
21const PARALLEL_BASE_URL: &str = "https://parallelmpp.dev";
22
23pub struct ParallelPaymentsCapability;
24
25#[async_trait]
26impl Capability for ParallelPaymentsCapability {
27    fn id(&self) -> &str {
28        "parallel"
29    }
30
31    fn name(&self) -> &str {
32        "Parallel (Machine Payments)"
33    }
34
35    fn description(&self) -> &str {
36        "Paid Parallel search, extract, and async task tools backed by Everruns machine payments."
37    }
38
39    fn status(&self) -> CapabilityStatus {
40        CapabilityStatus::Available
41    }
42
43    fn risk_level(&self) -> RiskLevel {
44        RiskLevel::High
45    }
46
47    fn icon(&self) -> Option<&str> {
48        Some("wallet")
49    }
50
51    fn category(&self) -> Option<&str> {
52        Some("Machine Payments")
53    }
54
55    fn system_prompt_addition(&self) -> Option<&str> {
56        Some(
57            "When web research is needed and Parallel tools are available, prefer the Parallel tools for structured paid search/extract/task work. Use `parallel_task_status` to poll async task run IDs until complete.",
58        )
59    }
60
61    fn tools(&self) -> Vec<Box<dyn Tool>> {
62        vec![
63            Box::new(ParallelSearchTool),
64            Box::new(ParallelExtractTool),
65            Box::new(ParallelTaskTool),
66            Box::new(ParallelTaskStatusTool),
67        ]
68    }
69
70    fn features(&self) -> Vec<&'static str> {
71        vec!["machine_payments"]
72    }
73
74    fn localizations(&self) -> Vec<CapabilityLocalization> {
75        vec![CapabilityLocalization::text(
76            "uk",
77            "Parallel (машинні платежі)",
78            "Платні інструменти Parallel для пошуку, вилучення даних та асинхронних завдань \
79             на основі машинних платежів Everruns.",
80        )]
81    }
82}
83
84#[derive(Debug, Deserialize)]
85struct SearchArgs {
86    query: String,
87    #[serde(default = "default_search_mode")]
88    mode: String,
89}
90
91fn default_search_mode() -> String {
92    "one-shot".to_string()
93}
94
95pub struct ParallelSearchTool;
96
97#[async_trait]
98impl Tool for ParallelSearchTool {
99    fn name(&self) -> &str {
100        "parallel_search"
101    }
102
103    fn display_name(&self) -> Option<&str> {
104        Some("Parallel Search")
105    }
106
107    fn description(&self) -> &str {
108        "Search the web through Parallel's paid API. Costs up to $0.01 per call."
109    }
110
111    fn parameters_schema(&self) -> Value {
112        json!({
113            "type": "object",
114            "properties": {
115                "query": { "type": "string", "description": "Search query." },
116                "mode": {
117                    "type": "string",
118                    "enum": ["one-shot", "fast"],
119                    "description": "Use one-shot for comprehensive results or fast for lower latency.",
120                    "default": "one-shot"
121                }
122            },
123            "required": ["query"],
124            "additionalProperties": false
125        })
126    }
127
128    fn requires_context(&self) -> bool {
129        true
130    }
131
132    fn hints(&self) -> ToolHints {
133        ToolHints::default()
134            .with_readonly(true)
135            .with_open_world(true)
136            .with_requires_secrets(true)
137    }
138
139    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
140        missing_payment_authority()
141    }
142
143    async fn execute_with_context(
144        &self,
145        arguments: Value,
146        context: &ToolContext,
147    ) -> ToolExecutionResult {
148        let args: SearchArgs = match serde_json::from_value(arguments) {
149            Ok(args) => args,
150            Err(error) => return invalid_args(error),
151        };
152        if !matches!(args.mode.as_str(), "one-shot" | "fast") {
153            return ToolExecutionResult::tool_error("mode must be one of: one-shot, fast");
154        }
155        execute_paid_parallel(
156            context,
157            "search",
158            "/api/search",
159            json!({ "query": args.query, "mode": args.mode }),
160            0.01,
161        )
162        .await
163    }
164}
165
166#[derive(Debug, Deserialize)]
167struct ExtractArgs {
168    urls: Vec<String>,
169    objective: String,
170}
171
172pub struct ParallelExtractTool;
173
174#[async_trait]
175impl Tool for ParallelExtractTool {
176    fn name(&self) -> &str {
177        "parallel_extract"
178    }
179
180    fn display_name(&self) -> Option<&str> {
181        Some("Parallel Extract")
182    }
183
184    fn description(&self) -> &str {
185        "Extract structured facts from URLs through Parallel's paid API. Costs up to $0.01 per URL, minimum $0.01."
186    }
187
188    fn parameters_schema(&self) -> Value {
189        json!({
190            "type": "object",
191            "properties": {
192                "urls": {
193                    "type": "array",
194                    "items": { "type": "string" },
195                    "minItems": 1,
196                    "description": "URLs to extract from."
197                },
198                "objective": { "type": "string", "description": "What facts to extract." }
199            },
200            "required": ["urls", "objective"],
201            "additionalProperties": false
202        })
203    }
204
205    fn requires_context(&self) -> bool {
206        true
207    }
208
209    fn hints(&self) -> ToolHints {
210        ToolHints::default()
211            .with_readonly(true)
212            .with_open_world(true)
213            .with_requires_secrets(true)
214    }
215
216    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
217        missing_payment_authority()
218    }
219
220    async fn execute_with_context(
221        &self,
222        arguments: Value,
223        context: &ToolContext,
224    ) -> ToolExecutionResult {
225        let args: ExtractArgs = match serde_json::from_value(arguments) {
226            Ok(args) => args,
227            Err(error) => return invalid_args(error),
228        };
229        if args.urls.is_empty() {
230            return ToolExecutionResult::tool_error("urls must contain at least one URL");
231        }
232        let max_amount_usd = (args.urls.len() as f64 * 0.01).max(0.01);
233        execute_paid_parallel(
234            context,
235            "extract",
236            "/api/extract",
237            json!({ "urls": args.urls, "objective": args.objective }),
238            max_amount_usd,
239        )
240        .await
241    }
242}
243
244#[derive(Debug, Deserialize)]
245struct TaskArgs {
246    input: String,
247    #[serde(default = "default_processor")]
248    processor: String,
249}
250
251fn default_processor() -> String {
252    "ultra".to_string()
253}
254
255pub struct ParallelTaskTool;
256
257#[async_trait]
258impl Tool for ParallelTaskTool {
259    fn name(&self) -> &str {
260        "parallel_task"
261    }
262
263    fn display_name(&self) -> Option<&str> {
264        Some("Parallel Task")
265    }
266
267    fn description(&self) -> &str {
268        "Start a deep async Parallel task. Costs up to $0.10 for pro or $0.30 for ultra; poll with parallel_task_status."
269    }
270
271    fn parameters_schema(&self) -> Value {
272        json!({
273            "type": "object",
274            "properties": {
275                "input": { "type": "string", "description": "Task input." },
276                "processor": {
277                    "type": "string",
278                    "enum": ["pro", "ultra"],
279                    "default": "ultra"
280                }
281            },
282            "required": ["input"],
283            "additionalProperties": false
284        })
285    }
286
287    fn requires_context(&self) -> bool {
288        true
289    }
290
291    fn hints(&self) -> ToolHints {
292        ToolHints::default()
293            .with_readonly(true)
294            .with_open_world(true)
295            .with_requires_secrets(true)
296            .with_long_running(true)
297    }
298
299    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
300        missing_payment_authority()
301    }
302
303    async fn execute_with_context(
304        &self,
305        arguments: Value,
306        context: &ToolContext,
307    ) -> ToolExecutionResult {
308        let args: TaskArgs = match serde_json::from_value(arguments) {
309            Ok(args) => args,
310            Err(error) => return invalid_args(error),
311        };
312        let max_amount_usd = match args.processor.as_str() {
313            "pro" => 0.10,
314            "ultra" => 0.30,
315            _ => return ToolExecutionResult::tool_error("processor must be one of: pro, ultra"),
316        };
317        execute_paid_parallel(
318            context,
319            "task",
320            "/api/task",
321            json!({ "input": args.input, "processor": args.processor }),
322            max_amount_usd,
323        )
324        .await
325    }
326}
327
328#[derive(Debug, Deserialize)]
329struct TaskStatusArgs {
330    run_id: String,
331}
332
333pub struct ParallelTaskStatusTool;
334
335#[async_trait]
336impl Tool for ParallelTaskStatusTool {
337    fn name(&self) -> &str {
338        "parallel_task_status"
339    }
340
341    fn display_name(&self) -> Option<&str> {
342        Some("Parallel Task Status")
343    }
344
345    fn description(&self) -> &str {
346        "Poll a Parallel task run. This endpoint is free and does not require payment."
347    }
348
349    fn parameters_schema(&self) -> Value {
350        json!({
351            "type": "object",
352            "properties": {
353                "run_id": { "type": "string", "description": "Run ID returned by parallel_task." }
354            },
355            "required": ["run_id"],
356            "additionalProperties": false
357        })
358    }
359
360    fn hints(&self) -> ToolHints {
361        ToolHints::default()
362            .with_readonly(true)
363            .with_idempotent(true)
364            .with_open_world(true)
365    }
366
367    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
368        let args: TaskStatusArgs = match serde_json::from_value(arguments) {
369            Ok(args) => args,
370            Err(error) => return invalid_args(error),
371        };
372        let run_id = args.run_id.trim();
373        if run_id.is_empty() || run_id.contains('/') {
374            return ToolExecutionResult::tool_error("run_id is invalid");
375        }
376        let url = format!("{PARALLEL_BASE_URL}/api/task/{run_id}");
377        match reqwest::Client::new().get(url).send().await {
378            Ok(response) => match response.json::<Value>().await {
379                Ok(value) => ToolExecutionResult::success(value),
380                Err(error) => ToolExecutionResult::tool_error(format!(
381                    "Parallel task status response was not valid JSON: {error}"
382                )),
383            },
384            Err(error) => ToolExecutionResult::tool_error(format!(
385                "Failed to poll Parallel task status: {error}"
386            )),
387        }
388    }
389}
390
391async fn execute_paid_parallel(
392    context: &ToolContext,
393    operation: &str,
394    path: &str,
395    body: Value,
396    max_amount_usd: f64,
397) -> ToolExecutionResult {
398    let Some(authority) = context.payment_authority.as_ref() else {
399        return missing_payment_authority();
400    };
401
402    let request = MachinePaymentRequest {
403        capability: "parallel".to_string(),
404        operation: operation.to_string(),
405        method: PaymentMethod::Post,
406        url: format!("{PARALLEL_BASE_URL}{path}"),
407        body: Some(body),
408        max_amount_usd,
409        rail_preference: vec![PaymentRail::X402Base],
410        metadata: json!({
411            "provider": "parallel",
412            "host": "parallelmpp.dev",
413            "path": path,
414        }),
415    };
416
417    match authority
418        .execute_machine_payment(context.session_id, request)
419        .await
420    {
421        Ok(response) => ToolExecutionResult::success(json!({
422            "result": response.response,
423            "payment": {
424                "attempt_id": response.attempt_id.map(|id| id.to_string()),
425                "amount_usd": response.amount_usd,
426                "rail": response.rail.map(|rail| rail.to_string()),
427                "receipt": response.receipt,
428            }
429        })),
430        Err(error) => ToolExecutionResult::tool_error(format!("Machine payment failed: {error}")),
431    }
432}
433
434fn missing_payment_authority() -> ToolExecutionResult {
435    ToolExecutionResult::tool_error(
436        "Machine payments are not configured for this session. Configure a payment wallet and policy before using Parallel paid tools.",
437    )
438}
439
440fn invalid_args(error: serde_json::Error) -> ToolExecutionResult {
441    ToolExecutionResult::tool_error(format!("Invalid arguments: {error}"))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn metadata() {
450        let cap = ParallelPaymentsCapability;
451        assert_eq!(cap.id(), "parallel");
452        assert_eq!(cap.category(), Some("Machine Payments"));
453        assert_eq!(cap.risk_level(), RiskLevel::High);
454        assert_eq!(cap.tools().len(), 4);
455        assert_eq!(cap.features(), vec!["machine_payments"]);
456    }
457
458    #[tokio::test]
459    async fn paid_tools_fail_closed_without_authority() {
460        // No payment authority wired -> the paid tools must refuse, not spend.
461        let result = ParallelSearchTool
462            .execute(json!({ "query": "hello" }))
463            .await;
464        assert!(result.is_error());
465    }
466}