use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub directory_url: String,
pub timeout_ms: u64,
pub region: Option<String>,
pub node_token: Option<String>,
pub use_confidentiality: bool,
pub routing_epsilon: f64,
pub routing_strategy: String,
pub routing_top_k: usize,
pub routing_softmax_tau: f64,
}
impl Default for ClientConfig {
fn default() -> Self {
let epsilon = std::env::var("IICP_ROUTING_EPSILON")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.map(|v| v.clamp(0.0, 1.0))
.unwrap_or(0.05);
let strategy = std::env::var("IICP_ROUTING_STRATEGY")
.ok()
.filter(|s| matches!(s.as_str(), "deterministic" | "epsilon" | "softmax_top_k"))
.unwrap_or_else(|| "epsilon".into());
let top_k = std::env::var("IICP_ROUTING_TOP_K")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.map(|v| v.max(1))
.unwrap_or(3);
let tau = std::env::var("IICP_ROUTING_SOFTMAX_TAU")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.map(|v| v.max(0.001))
.unwrap_or(0.04);
Self {
directory_url: "https://iicp.network/api".into(),
timeout_ms: 30_000,
region: None,
node_token: None,
use_confidentiality: false,
routing_epsilon: epsilon,
routing_strategy: strategy,
routing_top_k: top_k,
routing_softmax_tau: tau,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct DiscoverOptions {
pub region: Option<String>,
pub model: Option<String>,
pub min_reputation: Option<f64>,
pub limit: Option<u32>,
pub browser_usable_only: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CxPublicKey {
pub algorithm: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
pub key: String,
pub key_id: String,
}
#[derive(Debug, Clone)]
pub struct Node {
pub node_id: String,
pub endpoint: String,
pub score: f64,
pub available: bool,
pub region: String,
pub models: Option<Vec<String>>,
pub cip_policy: Option<CipPolicy>,
pub health_label: Option<String>,
pub exposure_mode: Option<String>,
pub cx_public_key: Option<CxPublicKey>,
pub transport: Vec<String>,
pub directory_observed_reachable: Option<bool>,
pub route_evidence: Option<String>,
pub routing_hint: Option<String>,
pub browser_usable: Option<bool>,
}
#[derive(Deserialize)]
struct NodeWire {
pub node_id: String,
pub endpoint: String,
pub score: f64,
pub available: bool,
pub region: String,
pub models: Option<Vec<String>>,
pub cip_policy: Option<CipPolicy>,
#[serde(default)]
pub health_label: Option<String>,
#[serde(default)]
pub exposure_mode: Option<String>,
#[serde(default)]
pub cx_public_key: Option<CxPublicKey>,
#[serde(default)]
pub public_key: Option<CxPublicKey>,
#[serde(default)]
pub transport: Vec<String>,
#[serde(default)]
pub directory_observed_reachable: Option<bool>,
#[serde(default)]
pub route_evidence: Option<String>,
#[serde(default)]
pub routing_hint: Option<String>,
#[serde(default)]
pub browser_usable: Option<bool>,
}
impl From<NodeWire> for Node {
fn from(wire: NodeWire) -> Self {
Self {
node_id: wire.node_id,
endpoint: wire.endpoint,
score: wire.score,
available: wire.available,
region: wire.region,
models: wire.models,
cip_policy: wire.cip_policy,
health_label: wire.health_label,
exposure_mode: wire.exposure_mode,
cx_public_key: wire.cx_public_key.or(wire.public_key),
transport: wire.transport,
directory_observed_reachable: wire.directory_observed_reachable,
route_evidence: wire.route_evidence,
routing_hint: wire.routing_hint,
browser_usable: wire.browser_usable,
}
}
}
impl<'de> Deserialize<'de> for Node {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
NodeWire::deserialize(deserializer).map(Self::from)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CipPolicy {
pub allow_remote_inference: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NodeList {
pub nodes: Vec<Node>,
pub count: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskRequest {
pub task_id: String,
pub intent: String,
pub payload: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub constraints: Option<TaskConstraints>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<TaskAuth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_node_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskAuth {
pub token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TaskResponse {
pub task_id: String,
pub status: String,
pub result: Option<serde_json::Value>,
pub metrics: Option<TaskMetrics>,
#[serde(default)]
pub error: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TaskMetrics {
pub latency_ms: Option<f64>,
pub tokens_used: Option<u32>,
pub node_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Default, Clone)]
pub struct ChatOptions {
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub timeout_ms: Option<u64>,
pub temperature: Option<f64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ChatResponse {
pub choices: Vec<ChatChoice>,
pub usage: Option<ChatUsage>,
#[serde(default)]
pub task_id: String,
#[serde(default)]
pub node_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatChoice {
pub message: ChatMessage,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatUsage {
pub total_tokens: Option<u32>,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::Node;
#[test]
fn node_parses_health_label_and_exposure_mode() {
let json = r#"{"node_id":"n1","endpoint":"https://x","score":0.9,"available":true,"region":"eu","health_label":"healthy","exposure_mode":"ipv4_public_direct","transport":["https","iicp-native"]}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert_eq!(n.health_label.as_deref(), Some("healthy"));
assert_eq!(n.exposure_mode.as_deref(), Some("ipv4_public_direct"));
assert_eq!(n.transport, vec!["https", "iicp-native"]);
}
#[test]
fn node_health_fields_default_none_for_old_directory() {
let json =
r#"{"node_id":"n1","endpoint":"https://x","score":0.5,"available":true,"region":"eu"}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert!(n.health_label.is_none());
assert!(n.exposure_mode.is_none());
}
#[test]
fn node_accepts_deprecated_public_key_alias_for_cx_key() {
let json = r#"{
"node_id":"n1",
"endpoint":"https://x",
"score":0.9,
"available":true,
"region":"eu",
"public_key":{
"algorithm":"X25519",
"encoding":"base64url",
"key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"key_id":"cx-alias"
}
}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert_eq!(
n.cx_public_key.as_ref().map(|key| key.key_id.as_str()),
Some("cx-alias")
);
}
#[test]
fn node_accepts_both_canonical_and_alias_without_duplicate_field_error() {
let json = r#"{
"node_id":"n1",
"endpoint":"https://x",
"score":0.9,
"available":true,
"region":"eu",
"cx_public_key":{
"algorithm":"X25519",
"encoding":"base64url",
"key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"key_id":"cx-canonical"
},
"public_key":{
"algorithm":"X25519",
"encoding":"base64url",
"key":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
"key_id":"cx-alias"
}
}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert_eq!(
n.cx_public_key.as_ref().map(|key| key.key_id.as_str()),
Some("cx-canonical")
);
}
}