everruns_integrations_parallel/
payments.rs1use 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 let result = ParallelSearchTool
462 .execute(json!({ "query": "hello" }))
463 .await;
464 assert!(result.is_error());
465 }
466}