diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..dfdca5a Binary files /dev/null and b/.DS_Store differ diff --git a/hyperliquid_api_example/.DS_Store b/hyperliquid_api_example/.DS_Store new file mode 100644 index 0000000..b793863 Binary files /dev/null and b/hyperliquid_api_example/.DS_Store differ diff --git a/hyperliquid_api_example/Cargo.toml b/hyperliquid_api_example/Cargo.toml new file mode 100644 index 0000000..a6cd805 --- /dev/null +++ b/hyperliquid_api_example/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "firstproject" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.95" +chrono = "0.4.39" +dotenv = "0.15.0" +reqwest = { version = "0.12.12", features = ["json"] } +rig-core = "0.6.0" +serde = "1.0.217" +serde_json = "1.0.134" +thiserror = "2.0.9" +tokio = { version = "1.42.0", features = ["full"] } diff --git a/hyperliquid_api_example/src/api_tool_template.rs b/hyperliquid_api_example/src/api_tool_template.rs new file mode 100644 index 0000000..4d48d3e --- /dev/null +++ b/hyperliquid_api_example/src/api_tool_template.rs @@ -0,0 +1,172 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use reqwest; +use rig::completion::ToolDefinition; +use rig::tool::Tool; + +/// Arguments required for the API call +/// Add all the fields your API endpoint needs +#[derive(Debug, Serialize, Deserialize)] +pub struct TemplateArgs { + // Required fields should not have Option + required_field: String, + // Optional fields should use Option + #[serde(skip_serializing_if = "Option::is_none")] + optional_field: Option, +} + +/// Response structure matching your API's JSON response +/// Use serde attributes to handle field naming differences +#[derive(Debug, Serialize, Deserialize)] +struct ApiResponse { + // Example of renaming a field from snake_case to camelCase + #[serde(rename = "someField")] + some_field: String, + + // Example of an optional field + #[serde(default)] + optional_data: Option, + + // Example of a nested structure + #[serde(rename = "nestedData")] + nested: NestedData, +} + +/// Example of a nested data structure in the response +#[derive(Debug, Serialize, Deserialize)] +struct NestedData { + // Example of a numeric field + #[serde(rename = "numericValue")] + numeric_value: f64, + + // Example of an array + #[serde(rename = "arrayField")] + array_field: Vec, +} + +/// Error types specific to your API +#[derive(Debug, thiserror::Error)] +pub enum TemplateError { + #[error("HTTP request failed: {0}")] + HttpRequestFailed(String), + + #[error("API error: {0}")] + ApiError(String), + + #[error("Invalid response structure")] + InvalidResponse, + + #[error("Resource not found: {0}")] + NotFound(String), + + // Add more error types as needed +} + +/// The main tool struct +pub struct TemplateApiTool; + +impl Tool for TemplateApiTool { + // Define the tool's name - this should be unique + const NAME: &'static str = "template_api_search"; + + // Link the argument, output, and error types + type Args = TemplateArgs; + type Output = String; + type Error = TemplateError; + + /// Define the tool's interface for the AI + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Description of what this tool does and when to use it".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "required_field": { + "type": "string", + "description": "Description of what this field is for" + }, + "optional_field": { + "type": "string", + "description": "Description of this optional field" + } + }, + "required": ["required_field"] + }), + } + } + + /// Implement the actual API call and response handling + async fn call(&self, args: Self::Args) -> Result { + // Create an HTTP client + let client = reqwest::Client::new(); + + // Build the API request + let url = "https://api.example.com/endpoint"; + + // Example of a POST request with JSON body + let response = client + .post(url) + .header("Content-Type", "application/json") + // Add any required headers + // .header("Authorization", "Bearer YOUR_TOKEN") + .json(&json!({ + "field": args.required_field, + "optionalField": args.optional_field + })) + .send() + .await + .map_err(|e| TemplateError::HttpRequestFailed(e.to_string()))?; + + // Handle non-200 responses + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(TemplateError::ApiError(format!( + "API returned status: {} - {}", + status, + error_text + ))); + } + + // Parse the response + let api_response: ApiResponse = response + .json() + .await + .map_err(|_| TemplateError::InvalidResponse)?; + + // Format the output + let mut output = String::new(); + output.push_str(&format!("Field: {}\n", api_response.some_field)); + + if let Some(optional) = api_response.optional_data { + output.push_str(&format!("Optional: {}\n", optional)); + } + + output.push_str(&format!("Numeric Value: {}\n", api_response.nested.numeric_value)); + output.push_str("Array Values:\n"); + for item in api_response.nested.array_field { + output.push_str(&format!("- {}\n", item)); + } + + Ok(output) + } +} + +// Optional: Add tests for your tool +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_api_call() { + let tool = TemplateApiTool; + let args = TemplateArgs { + required_field: "test".to_string(), + optional_field: None, + }; + + let result = tool.call(args).await; + assert!(result.is_ok()); + } +} \ No newline at end of file diff --git a/hyperliquid_api_example/src/art_search_tool.rs b/hyperliquid_api_example/src/art_search_tool.rs new file mode 100644 index 0000000..c2bfb59 --- /dev/null +++ b/hyperliquid_api_example/src/art_search_tool.rs @@ -0,0 +1,174 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::env; + +// 1. First, let's define our input arguments structure +#[derive(Deserialize)] +pub struct ArtSearchArgs { + // Required + query: String, + // Optional parameters + limit: Option, + page: Option, + fields: Option, + sort: Option, +} + +// 2. Define the artwork response structure +#[derive(Deserialize, Serialize)] +pub struct Artwork { + id: String, + title: String, + artist_display: Option, + date_display: Option, + medium_display: Option, + dimensions: Option, + image_id: Option, + thumbnail: Option, + description: Option, +} + +// 3. Define possible errors +#[derive(Debug, thiserror::Error)] +pub enum ArtSearchError { + #[error("HTTP request failed: {0}")] + HttpRequestFailed(String), + #[error("Invalid response structure")] + InvalidResponse, + #[error("API error: {0}")] + ApiError(String), +} + +pub struct ArtSearchTool; + +impl Tool for ArtSearchTool { + const NAME: &'static str = "search_art"; + type Args = ArtSearchArgs; + type Output = String; + type Error = ArtSearchError; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "search_art".to_string(), + description: "Search for artworks in the Art Institute of Chicago collection".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search term for artwork" + }, + "limit": { + "type": "integer", + "description": "Number of results to return (default: 10)" + }, + "page": { + "type": "integer", + "description": "Page number for pagination" + }, + "fields": { + "type": "string", + "description": "Comma-separated list of fields to return" + }, + "sort": { + "type": "string", + "description": "Sort order (e.g., '_score', 'title')" + } + }, + "required": ["query"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let client = reqwest::Client::new(); + + // Use the correct endpoint for searching artworks + let mut url = format!( + "https://api.artic.edu/api/v1/artworks?fields=id,title,artist_display,date_display,description&q={}", + args.query + ); + + // Add optional parameters + if let Some(limit) = args.limit { + url.push_str(&format!("&limit={}", limit)); + } + if let Some(page) = args.page { + url.push_str(&format!("&page={}", page)); + } + if let Some(sort) = args.sort { + url.push_str(&format!("&sort={}", sort)); + } + + println!("Requesting URL: {}", url); // Debug print + + // Make the API request + let response = client + .get(&url) + .header("Accept", "application/json") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + .header("Origin", "https://api.artic.edu") + .header("Referer", "https://api.artic.edu/") + .send() + .await + .map_err(|e| ArtSearchError::HttpRequestFailed(e.to_string()))?; + + // Debug print the response status + println!("Response status: {}", response.status()); + + // Check if the request was successful + if !response.status().is_success() { + let status = response.status(); // Get status first + let error_text = response.text().await.unwrap_or_default(); + return Err(ArtSearchError::ApiError(format!( + "API returned status: {} - {}", + status, + error_text + ))); + } + + // Parse the response + let data: serde_json::Value = response + .json() + .await + .map_err(|e| ArtSearchError::InvalidResponse)?; + + // Format the results + let mut output = String::new(); + + if let Some(data) = data.get("data") { + if let Some(artworks) = data.as_array() { + output.push_str("Found artworks:\n\n"); + + for (i, artwork) in artworks.iter().enumerate() { + let title = artwork.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled"); + let artist = artwork + .get("artist_display") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Artist"); + + output.push_str(&format!("{}. **{}**\n", i + 1, title)); + output.push_str(&format!(" Artist: {}\n", artist)); + + if let Some(date) = artwork.get("date_display").and_then(|v| v.as_str()) { + output.push_str(&format!(" Date: {}\n", date)); + } + + if let Some(desc) = artwork.get("description").and_then(|v| v.as_str()) { + output.push_str(&format!(" Description: {}\n", desc)); + } + + output.push_str("\n"); + } + } + } + + if output.is_empty() { + output = "No artworks found.".to_string(); + } + + Ok(output) + } +} \ No newline at end of file diff --git a/hyperliquid_api_example/src/flight_search_tool.rs b/hyperliquid_api_example/src/flight_search_tool.rs new file mode 100644 index 0000000..4c95326 --- /dev/null +++ b/hyperliquid_api_example/src/flight_search_tool.rs @@ -0,0 +1,334 @@ +use chrono::Utc; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::env; + +#[derive(Deserialize)] +pub struct FlightSearchArgs { + source: String, + destination: String, + date: Option, + sort: Option, + service: Option, + itinerary_type: Option, + adults: Option, + seniors: Option, + currency: Option, + nearby: Option, + nonstop: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum FlightSearchError { + #[error("HTTP request failed: {0}")] + HttpRequestFailed(String), + #[error("Invalid response structure")] + InvalidResponse, + #[error("API error: {0}")] + ApiError(String), + #[error("Missing API key")] + MissingApiKey, +} + +#[derive(Serialize)] +pub struct FlightOption { + airline: String, + flight_number: String, + departure: String, + arrival: String, + duration: String, + stops: usize, + price: f64, + currency: String, + booking_url: String, +} + +pub struct FlightSearchTool; + +impl Tool for FlightSearchTool { + const NAME: &'static str = "search_flights"; + + type Args = FlightSearchArgs; + type Output = String; + type Error = FlightSearchError; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "search_flights".to_string(), + description: "Search for flights between two airports".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "source": { "type": "string", "description": "Source airport code (e.g., 'BOM')" }, + "destination": { "type": "string", "description": "Destination airport code (e.g., 'DEL')" }, + "date": { "type": "string", "description": "Flight date in 'YYYY-MM-DD' format" }, + "sort": { "type": "string", "description": "Sort order for results", "enum": ["ML_BEST_VALUE", "PRICE", "DURATION", "EARLIEST_OUTBOUND_DEPARTURE", "EARLIEST_OUTBOUND_ARRIVAL", "LATEST_OUTBOUND_DEPARTURE", "LATEST_OUTBOUND_ARRIVAL"] }, + "service": { "type": "string", "description": "Class of service", "enum": ["ECONOMY", "PREMIUM_ECONOMY", "BUSINESS", "FIRST"] }, + "itinerary_type": { "type": "string", "description": "Itinerary type", "enum": ["ONE_WAY", "ROUND_TRIP"] }, + "adults": { "type": "integer", "description": "Number of adults" }, + "seniors": { "type": "integer", "description": "Number of seniors" }, + "currency": { "type": "string", "description": "Currency code (e.g., 'USD')" }, + "nearby": { "type": "string", "description": "Include nearby airports", "enum": ["yes", "no"] }, + "nonstop": { "type": "string", "description": "Show only nonstop flights", "enum": ["yes", "no"] }, + }, + "required": ["source", "destination"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + // Use the RapidAPI key from an environment variable + let api_key = env::var("RAPIDAPI_KEY").map_err(|_| FlightSearchError::MissingApiKey)?; + + // Set default values if not provided + let date = args.date.unwrap_or_else(|| { + let date = chrono::Utc::now() + chrono::Duration::days(30); + date.format("%Y-%m-%d").to_string() + }); + + let sort = args.sort.unwrap_or_else(|| "ML_BEST_VALUE".to_string()); + let service = args.service.unwrap_or_else(|| "ECONOMY".to_string()); + let itinerary_type = args.itinerary_type.unwrap_or_else(|| "ONE_WAY".to_string()); + let adults = args.adults.unwrap_or(1); + let seniors = args.seniors.unwrap_or(0); + let currency = args.currency.unwrap_or_else(|| "USD".to_string()); + let nearby = args.nearby.unwrap_or_else(|| "no".to_string()); + let nonstop = args.nonstop.unwrap_or_else(|| "no".to_string()); + + // Build the query parameters + let mut query_params = HashMap::new(); + query_params.insert("sourceAirportCode", args.source); + query_params.insert("destinationAirportCode", args.destination); + query_params.insert("date", date); + query_params.insert("itineraryType", itinerary_type); + query_params.insert("sortOrder", sort); + query_params.insert("numAdults", adults.to_string()); + query_params.insert("numSeniors", seniors.to_string()); + query_params.insert("classOfService", service); + query_params.insert("pageNumber", "1".to_string()); + query_params.insert("currencyCode", currency.clone()); + query_params.insert("nearby", nearby); + query_params.insert("nonstop", nonstop); + + // Make the API request + let client = reqwest::Client::new(); + let response = client + .get("https://tripadvisor16.p.rapidapi.com/api/v1/flights/searchFlights") + .headers({ + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "X-RapidAPI-Host", + "tripadvisor16.p.rapidapi.com".parse().unwrap(), + ); + headers.insert("X-RapidAPI-Key", api_key.parse().unwrap()); + headers + }) + .query(&query_params) + .send() + .await + .map_err(|e| FlightSearchError::HttpRequestFailed(e.to_string()))?; + + // Get the status code before consuming `response` + let status = response.status(); + + // Read the response text (this consumes `response`) + let text = response + .text() + .await + .map_err(|e| FlightSearchError::HttpRequestFailed(e.to_string()))?; + + // Print the raw API response for debugging + // println!("Raw API response:\n{}", text); + + // Check if the response is an error + if !status.is_success() { + return Err(FlightSearchError::ApiError(format!( + "Status: {}, Response: {}", + status, text + ))); + } + + // Parse the response JSON + let data: Value = serde_json::from_str(&text) + .map_err(|e| FlightSearchError::HttpRequestFailed(e.to_string()))?; + + // Check for API errors in the JSON response + if let Some(error) = data.get("error") { + let error_message = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + return Err(FlightSearchError::ApiError(error_message.to_string())); + } + + let empty_leg = json!({}); + + // Extract flight options + let mut flight_options = Vec::new(); + + // Check if 'data' contains 'flights' array + if let Some(flights) = data + .get("data") + .and_then(|d| d.get("flights")) + .and_then(|f| f.as_array()) + { + // Iterate over flight entries, taking the first 5 + for flight in flights.iter().take(5) { + // Extract flight segments + if let Some(segments) = flight + .get("segments") + .and_then(|s| s.as_array()) + .and_then(|s| s.get(0)) + { + // Extract legs from the first segment + if let Some(legs) = segments.get("legs").and_then(|l| l.as_array()) { + let first_leg = legs.get(0).unwrap_or(&empty_leg); + let last_leg = legs.last().unwrap_or(&empty_leg); + + // Extract airline name + let airline = first_leg + .get("marketingCarrier") + .and_then(|mc| mc.get("displayName")) + .and_then(|dn| dn.as_str()) + .unwrap_or("Unknown") + .to_string(); + + // Extract flight number + let flight_number = format!( + "{}{}", + first_leg + .get("marketingCarrierCode") + .and_then(|c| c.as_str()) + .unwrap_or(""), + first_leg + .get("flightNumber") + .and_then(|n| n.as_str()) + .unwrap_or("") + ); + + // Extract departure and arrival times + let departure = first_leg + .get("departureDateTime") + .and_then(|dt| dt.as_str()) + .unwrap_or("") + .to_string(); + + let arrival = last_leg + .get("arrivalDateTime") + .and_then(|dt| dt.as_str()) + .unwrap_or("") + .to_string(); + + // Parse departure time or fallback to current UTC time + let departure_time = chrono::DateTime::parse_from_rfc3339(&departure) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + + // Parse arrival time or fallback to current UTC time + let arrival_time = chrono::DateTime::parse_from_rfc3339(&arrival) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + + // Calculate flight duration + let duration = arrival_time - departure_time; + let hours = duration.num_hours(); + let minutes = duration.num_minutes() % 60; + let duration_str = format!("{} hours {} minutes", hours, minutes); + + // Determine number of stops + let stops = if legs.len() > 1 { legs.len() - 1 } else { 0 }; + + // Extract purchase links array for price information + let purchase_links = flight + .get("purchaseLinks") + .and_then(|pl| pl.as_array()) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + // Find the best price from purchase links + let best_price = purchase_links.iter().min_by_key(|p| { + p.get("totalPrice") + .and_then(|tp| tp.as_f64()) + .unwrap_or(f64::MAX) as u64 + }); + + // Extract pricing and booking URL if available + if let Some(best_price) = best_price { + let total_price = best_price + .get("totalPrice") + .and_then(|tp| tp.as_f64()) + .unwrap_or(0.0); + let booking_url = best_price + .get("url") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + + // Skip flights with price 0.0 + if total_price == 0.0 { + continue; + } + + // Append extracted flight options to flight_options vector + flight_options.push(FlightOption { + airline, + flight_number, + departure, + arrival, + duration: duration_str, + stops, + price: total_price, + currency: currency.clone(), + booking_url, + }); + } + } + } + } + } else { + // Return an error if response structure is invalid + return Err(FlightSearchError::InvalidResponse); + } + + // Format flight_options into a readable string + // Check if there are any flight options + if flight_options.is_empty() { + return Ok("No flights found for the given criteria.".to_string()); + } + + // Initialize the output string + let mut output = String::new(); + output.push_str("Here are some flight options:\n\n"); + + // Iterate over each flight option and format the details + for (i, option) in flight_options.iter().enumerate() { + output.push_str(&format!("{}. **Airline**: {}\n", i + 1, option.airline)); + output.push_str(&format!( + " - **Flight Number**: {}\n", + option.flight_number + )); + output.push_str(&format!(" - **Departure**: {}\n", option.departure)); + output.push_str(&format!(" - **Arrival**: {}\n", option.arrival)); + output.push_str(&format!(" - **Duration**: {}\n", option.duration)); + output.push_str(&format!( + " - **Stops**: {}\n", + if option.stops == 0 { + "Non-stop".to_string() + } else { + format!("{} stop(s)", option.stops) + } + )); + output.push_str(&format!( + " - **Price**: {:.2} {}\n", + option.price, option.currency + )); + output.push_str(&format!(" - **Booking URL**: {}\n\n", option.booking_url)); + } + + // Return the formatted flight options + Ok(output) + } +} \ No newline at end of file diff --git a/hyperliquid_api_example/src/hyperliquid_perp_search_tool.rs b/hyperliquid_api_example/src/hyperliquid_perp_search_tool.rs new file mode 100644 index 0000000..ead19be --- /dev/null +++ b/hyperliquid_api_example/src/hyperliquid_perp_search_tool.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use reqwest; +use rig::completion::ToolDefinition; +use rig::tool::Tool; + +#[derive(Debug, Serialize, Deserialize)] +pub struct HyperliquidPerpArgs { + symbol: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PerpMarket { + #[serde(rename = "szDecimals")] + sz_decimals: i32, + name: String, + #[serde(rename = "maxLeverage")] + max_leverage: i32, + #[serde(rename = "onlyIsolated", default)] + only_isolated: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PerpAssetContext { + funding: String, + #[serde(rename = "openInterest")] + open_interest: String, + #[serde(rename = "prevDayPx")] + prev_day_px: String, + #[serde(rename = "dayNtlVlm")] + day_ntl_vlm: String, + premium: Option, + #[serde(rename = "oraclePx")] + oracle_px: String, + #[serde(rename = "markPx")] + mark_px: String, + #[serde(rename = "midPx")] + mid_px: Option, + #[serde(rename = "impactPxs")] + impact_pxs: Option>, + #[serde(rename = "dayBaseVlm")] + day_base_vlm: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PerpMetaResponse { + universe: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum HyperliquidPerpError { + #[error("HTTP request failed: {0}")] + HttpRequestFailed(String), + #[error("API error: {0}")] + ApiError(String), + #[error("Invalid response structure")] + InvalidResponse, + #[error("Symbol not found: {0}")] + SymbolNotFound(String), +} + +pub struct HyperliquidPerpSearchTool; + +impl Tool for HyperliquidPerpSearchTool { + const NAME: &'static str = "search_hyperliquid_perp"; + type Args = HyperliquidPerpArgs; + type Output = String; + type Error = HyperliquidPerpError; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "search_hyperliquid_perp".to_string(), + description: "Search for perpetual futures prices and data on Hyperliquid exchange".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Trading symbol to search for (e.g., 'BTC', 'ETH')" + } + }, + "required": ["symbol"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let client = reqwest::Client::new(); + + // Make request for perp metadata and asset contexts + let url = "https://api.hyperliquid.xyz/info"; + + let response = client + .post(url) + .header("Content-Type", "application/json") + .json(&json!({ + "type": "metaAndAssetCtxs" + })) + .send() + .await + .map_err(|e| HyperliquidPerpError::HttpRequestFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(HyperliquidPerpError::ApiError(format!( + "API returned status: {} - {}", + status, + error_text + ))); + } + + // Parse the response as array + let response_array: Vec = response + .json() + .await + .map_err(|_| HyperliquidPerpError::InvalidResponse)?; + + if response_array.len() != 2 { + return Err(HyperliquidPerpError::InvalidResponse); + } + + // Extract the metadata and contexts + let meta: PerpMetaResponse = serde_json::from_value(response_array[0].clone()) + .map_err(|_| HyperliquidPerpError::InvalidResponse)?; + let contexts: Vec = serde_json::from_value(response_array[1].clone()) + .map_err(|_| HyperliquidPerpError::InvalidResponse)?; + + // Find the market index for the requested symbol + let market_index = meta.universe + .iter() + .position(|market| market.name == args.symbol) + .ok_or_else(|| HyperliquidPerpError::SymbolNotFound(args.symbol.clone()))?; + + // Get the corresponding context + let context = &contexts[market_index]; + let market = &meta.universe[market_index]; + + // Format the response + let mut output = String::new(); + output.push_str(&format!("**{}** Perpetual Futures Information:\n\n", market.name)); + output.push_str(&format!("Mark Price: ${}\n", context.mark_px)); + if let Some(mid_px) = &context.mid_px { + output.push_str(&format!("Mid Price: ${}\n", mid_px)); + } + output.push_str(&format!("Oracle Price: ${}\n", context.oracle_px)); + output.push_str(&format!("Previous Day Price: ${}\n", context.prev_day_px)); + output.push_str(&format!("24h Volume: {}\n", context.day_base_vlm)); + output.push_str(&format!("Open Interest: {}\n", context.open_interest)); + output.push_str(&format!("Current Funding Rate: {}\n", context.funding)); + if let Some(premium) = &context.premium { + output.push_str(&format!("Premium: {}\n", premium)); + } + if let Some(impact_pxs) = &context.impact_pxs { + if impact_pxs.len() >= 2 { + output.push_str(&format!("Impact Prices (Buy/Sell): ${} / ${}\n", impact_pxs[0], impact_pxs[1])); + } + } + output.push_str(&format!("Max Leverage: {}x\n", market.max_leverage)); + output.push_str(&format!("Size Decimals: {}\n", market.sz_decimals)); + output.push_str(&format!("Isolated Only: {}\n", market.only_isolated)); + + Ok(output) + } +} \ No newline at end of file diff --git a/hyperliquid_api_example/src/hyperliquid_spot_search_tool.rs b/hyperliquid_api_example/src/hyperliquid_spot_search_tool.rs new file mode 100644 index 0000000..8afa4a2 --- /dev/null +++ b/hyperliquid_api_example/src/hyperliquid_spot_search_tool.rs @@ -0,0 +1,173 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Deserialize)] +pub struct HyperliquidSpotArgs { + // Required + symbol: String, +} + +#[derive(Deserialize, Serialize)] +pub struct Token { + name: String, + #[serde(rename = "szDecimals")] + sz_decimals: i32, + #[serde(rename = "weiDecimals")] + wei_decimals: i32, + index: i32, + #[serde(rename = "tokenId")] + token_id: String, + #[serde(rename = "isCanonical")] + is_canonical: bool, + #[serde(rename = "evmContract")] + evm_contract: Option, + #[serde(rename = "fullName")] + full_name: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct Market { + name: String, + tokens: Vec, + index: i32, + #[serde(rename = "isCanonical")] + is_canonical: bool, +} + +#[derive(Deserialize, Serialize)] +pub struct AssetContext { + #[serde(rename = "dayNtlVlm")] + day_ntl_vlm: String, + #[serde(rename = "markPx")] + mark_px: String, + #[serde(rename = "midPx")] + mid_px: Option, + #[serde(rename = "prevDayPx")] + prev_day_px: String, + coin: String, + #[serde(rename = "circulatingSupply")] + circulating_supply: String, + #[serde(rename = "totalSupply")] + total_supply: String, + #[serde(rename = "dayBaseVlm")] + day_base_vlm: String, +} + +#[derive(Deserialize, Serialize)] +pub struct SpotMetaResponse { + tokens: Vec, + universe: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum HyperliquidSpotError { + #[error("HTTP request failed: {0}")] + HttpRequestFailed(String), + #[error("Invalid response structure")] + InvalidResponse, + #[error("API error: {0}")] + ApiError(String), + #[error("Symbol not found: {0}")] + SymbolNotFound(String), +} + +pub struct HyperliquidSpotSearchTool; + +impl Tool for HyperliquidSpotSearchTool { + const NAME: &'static str = "search_hyperliquid_spot"; + type Args = HyperliquidSpotArgs; + type Output = String; + type Error = HyperliquidSpotError; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "search_hyperliquid_spot".to_string(), + description: "Search for spot prices on Hyperliquid exchange".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Trading symbol to search for (e.g., 'PURR', 'SPH')" + } + }, + "required": ["symbol"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let client = reqwest::Client::new(); + + // Make request for spot metadata and asset contexts + let url = "https://api.hyperliquid.xyz/info"; + + let response = client + .post(url) + .header("Content-Type", "application/json") + .json(&json!({ + "type": "spotMetaAndAssetCtxs" + })) + .send() + .await + .map_err(|e| HyperliquidSpotError::HttpRequestFailed(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(HyperliquidSpotError::ApiError(format!( + "API returned status: {} - {}", + status, + error_text + ))); + } + + // Parse the response + let response_array: Vec = response + .json() + .await + .map_err(|_| HyperliquidSpotError::InvalidResponse)?; + + // Extract the metadata and contexts + let meta: SpotMetaResponse = serde_json::from_value(response_array[0].clone()) + .map_err(|_| HyperliquidSpotError::InvalidResponse)?; + let contexts: Vec = serde_json::from_value(response_array[1].clone()) + .map_err(|_| HyperliquidSpotError::InvalidResponse)?; + + // First try to find the token in metadata by name + let token = meta.tokens.iter() + .find(|t| t.name == args.symbol.to_uppercase()) + .ok_or_else(|| HyperliquidSpotError::SymbolNotFound(args.symbol.clone()))?; + + // Find the market that uses this token + let market = meta.universe.iter() + .find(|m| m.name.split('/').next().unwrap_or("") == token.name) + .ok_or_else(|| HyperliquidSpotError::SymbolNotFound(args.symbol.clone()))?; + + // Find the context using the market name + let context = contexts.iter() + .find(|c| c.coin == market.name) + .ok_or_else(|| HyperliquidSpotError::SymbolNotFound(args.symbol.clone()))?; + + // Format the output + let mut output = String::new(); + output.push_str(&format!("**{}** Spot Information:\n\n", token.name)); + output.push_str(&format!("Mark Price: ${}\n", context.mark_px)); + if let Some(mid_px) = &context.mid_px { + output.push_str(&format!("Mid Price: ${}\n", mid_px)); + } + output.push_str(&format!("Previous Day Price: ${}\n", context.prev_day_px)); + output.push_str(&format!("24h Volume: ${}\n", context.day_ntl_vlm)); + output.push_str(&format!("24h Base Volume: {}\n", context.day_base_vlm)); + output.push_str(&format!("Circulating Supply: {}\n", context.circulating_supply)); + output.push_str(&format!("Total Supply: {}\n", context.total_supply)); + + if let Some(full_name) = &token.full_name { + output.push_str(&format!("Full Name: {}\n", full_name)); + } + + Ok(output) + } +} \ No newline at end of file diff --git a/hyperliquid_api_example/src/main.rs b/hyperliquid_api_example/src/main.rs new file mode 100644 index 0000000..17f7dc0 --- /dev/null +++ b/hyperliquid_api_example/src/main.rs @@ -0,0 +1,58 @@ +mod hyperliquid_spot_search_tool; +mod hyperliquid_perp_search_tool; + +use hyperliquid_spot_search_tool::HyperliquidSpotSearchTool; +use hyperliquid_perp_search_tool::HyperliquidPerpSearchTool; +use rig::{completion::Prompt, providers::openai}; +use std::io::{self, Write}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Load environment variables from .env file + dotenv::dotenv().ok(); + + // Create OpenAI client and model + // This requires the `OPENAI_API_KEY` environment variable to be set. + let openai_client = openai::Client::from_env(); + + let gpt4 = openai_client.agent("gpt-4") + .preamble("You are a helpful assistant that can search for cryptocurrency prices on Hyperliquid, most coins that are majors are on the perps platform, spot platform is only for coins on the hyperliquid platform") + .tool(HyperliquidSpotSearchTool) + .tool(HyperliquidPerpSearchTool) + .build(); + + // Original single-query version: + /* + let response = gpt4 + .prompt("What is the current BTC perpetual futures price on Hyperliquid?") + .await?; + + let formatted_response: String = serde_json::from_str(&response)?; + println!("Formatted response:\n{}", formatted_response); + */ + + // New interactive version: + loop { + print!("Enter your prompt (or 'quit' to exit): "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let input = input.trim(); + if input.eq_ignore_ascii_case("quit") { + println!("Goodbye!"); + break; + } + + match gpt4.prompt(input).await { + Ok(response) => { + let formatted_response: String = serde_json::from_str(&response)?; + println!("\nResponse:\n{}\n", formatted_response); + }, + Err(e) => println!("Error: {}", e), + } + } + + Ok(()) +} \ No newline at end of file