1use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RoutingProfile {
10 #[default]
11 Standard,
12 Sensitive,
13 EuRestricted,
14 StrictPolicy,
15 DebugOverride,
16}
17
18impl RoutingProfile {
19 pub fn from_cli(value: &str) -> Self {
20 match value.replace('-', "_").to_ascii_lowercase().as_str() {
21 "sensitive" => Self::Sensitive,
22 "eu_restricted" => Self::EuRestricted,
23 "strict_policy" => Self::StrictPolicy,
24 "debug_override" => Self::DebugOverride,
25 _ => Self::Standard,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct RoutingPolicy {
33 pub profile: RoutingProfile,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub allowed_regions: Vec<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub require_encryption: Option<bool>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub require_policy_manifest: Option<bool>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub require_no_payload_retention: Option<bool>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub allow_remote_executor: Option<bool>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub known_operator_only: Option<bool>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub required_manifest_identity_level: Option<String>,
48}
49
50#[derive(Debug, Clone)]
52pub struct ClientConfig {
53 pub directory_url: String,
54 pub timeout_ms: u64,
56 pub region: Option<String>,
57 pub node_token: Option<String>,
58 pub consumer_auth_mode: String,
60 pub use_confidentiality: bool,
62 pub routing_epsilon: f64,
65 pub routing_strategy: String,
67 pub routing_top_k: usize,
69 pub routing_softmax_tau: f64,
71 pub routing_policy: RoutingPolicy,
73 pub route_discovery_mode: String,
75 pub profile_request: Option<ProfileRequest>,
76}
77
78impl Default for ClientConfig {
79 fn default() -> Self {
80 let epsilon = std::env::var("IICP_ROUTING_EPSILON")
81 .ok()
82 .and_then(|s| s.parse::<f64>().ok())
83 .map(|v| v.clamp(0.0, 1.0))
84 .unwrap_or(0.05);
85 let strategy = std::env::var("IICP_ROUTING_STRATEGY")
86 .ok()
87 .filter(|s| {
88 matches!(
89 s.as_str(),
90 "deterministic" | "epsilon" | "softmax_top_k" | "weighted_v1"
91 )
92 })
93 .unwrap_or_else(|| "epsilon".into());
94 let top_k = std::env::var("IICP_ROUTING_TOP_K")
95 .ok()
96 .and_then(|s| s.parse::<usize>().ok())
97 .map(|v| v.max(1))
98 .unwrap_or(3);
99 let tau = std::env::var("IICP_ROUTING_SOFTMAX_TAU")
100 .ok()
101 .and_then(|s| s.parse::<f64>().ok())
102 .map(|v| v.max(0.001))
103 .unwrap_or(0.04);
104 Self {
105 directory_url: "https://iicp.network/api".into(),
106 timeout_ms: 30_000,
107 region: None,
108 node_token: None,
109 consumer_auth_mode: "optional".into(),
110 use_confidentiality: false,
111 routing_epsilon: epsilon,
112 routing_strategy: strategy,
113 routing_top_k: top_k,
114 routing_softmax_tau: tau,
115 routing_policy: RoutingPolicy::default(),
116 route_discovery_mode: std::env::var("IICP_ROUTE_DISCOVERY_MODE")
117 .ok()
118 .filter(|s| matches!(s.as_str(), "auto" | "ticketed" | "legacy"))
119 .unwrap_or_else(|| "auto".into()),
120 profile_request: None,
121 }
122 }
123}
124
125#[derive(Debug, Default, Clone)]
127pub struct DiscoverOptions {
128 pub region: Option<String>,
129 pub qos: Option<String>,
130 pub model: Option<String>,
131 pub min_reputation: Option<f64>,
132 pub limit: Option<u32>,
133 pub browser_usable_only: Option<bool>,
135 pub profile_request: Option<ProfileRequest>,
137}
138
139#[derive(Debug, Default, Clone)]
141pub struct RouteConstraints {
142 pub region: Option<String>,
143 pub qos: Option<String>,
144 pub model: Option<String>,
145 pub min_reputation: Option<f64>,
146 pub limit: Option<u32>,
147 pub browser_usable_only: Option<bool>,
148 pub profile_request: Option<ProfileRequest>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152pub struct ProfileRequest {
153 pub profile_id: String,
154 pub profile_version: String,
155 pub profile_fixture_sha256: String,
156 pub required: bool,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ProfileNegotiation {
161 pub requested: bool,
162 pub status: Option<String>,
163 pub reason: Option<String>,
164 pub dispatch_allowed: Option<bool>,
165}
166
167#[derive(Debug, Clone, Deserialize, Serialize)]
169pub struct CxPublicKey {
170 pub algorithm: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub encoding: Option<String>,
174 pub key: String,
176 pub key_id: String,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub features: Vec<String>,
180}
181
182#[derive(Debug, Clone)]
184pub struct Node {
185 pub node_id: String,
186 pub endpoint: String,
187 pub score: f64,
188 pub load: f64,
189 pub available: bool,
190 pub region: String,
191 pub models: Option<Vec<String>>,
192 pub cip_policy: Option<CipPolicy>,
193 pub health_label: Option<String>,
196 pub exposure_mode: Option<String>,
198 pub cx_public_key: Option<CxPublicKey>,
201 pub transport: Vec<String>,
204 pub directory_observed_reachable: Option<bool>,
206 pub route_evidence: Option<String>,
207 pub routing_hint: Option<String>,
208 pub browser_usable: Option<bool>,
209 pub node_policy_manifest: Option<Value>,
211 pub dispatch_ticket_id_prefix: Option<String>,
212}
213
214#[derive(Deserialize)]
215struct NodeWire {
216 pub node_id: String,
217 pub endpoint: String,
218 pub score: f64,
219 #[serde(default)]
220 pub load: f64,
221 pub available: bool,
222 pub region: String,
223 pub models: Option<Vec<String>>,
224 pub cip_policy: Option<CipPolicy>,
225 #[serde(default)]
226 pub health_label: Option<String>,
227 #[serde(default)]
228 pub exposure_mode: Option<String>,
229 #[serde(default)]
230 pub cx_public_key: Option<CxPublicKey>,
231 #[serde(default)]
232 pub public_key: Option<CxPublicKey>,
233 #[serde(default)]
234 pub transport: Vec<String>,
235 #[serde(default)]
236 pub directory_observed_reachable: Option<bool>,
237 #[serde(default)]
238 pub route_evidence: Option<String>,
239 #[serde(default)]
240 pub routing_hint: Option<String>,
241 #[serde(default)]
242 pub browser_usable: Option<bool>,
243 #[serde(default)]
244 pub node_policy_manifest: Option<Value>,
245}
246
247impl From<NodeWire> for Node {
248 fn from(wire: NodeWire) -> Self {
249 Self {
250 node_id: wire.node_id,
251 endpoint: wire.endpoint,
252 score: wire.score,
253 load: wire.load,
254 available: wire.available,
255 region: wire.region,
256 models: wire.models,
257 cip_policy: wire.cip_policy,
258 health_label: wire.health_label,
259 exposure_mode: wire.exposure_mode,
260 cx_public_key: wire.cx_public_key.or(wire.public_key),
264 transport: wire.transport,
265 directory_observed_reachable: wire.directory_observed_reachable,
266 route_evidence: wire.route_evidence,
267 routing_hint: wire.routing_hint,
268 browser_usable: wire.browser_usable,
269 node_policy_manifest: wire.node_policy_manifest,
270 dispatch_ticket_id_prefix: None,
271 }
272 }
273}
274
275impl<'de> Deserialize<'de> for Node {
276 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
277 where
278 D: serde::Deserializer<'de>,
279 {
280 NodeWire::deserialize(deserializer).map(Self::from)
281 }
282}
283
284#[derive(Debug, Clone, Deserialize)]
286pub struct CipPolicy {
287 pub allow_remote_inference: bool,
288}
289
290#[derive(Debug, Clone, Deserialize)]
292pub struct NodeList {
293 pub nodes: Vec<Node>,
294 pub count: u32,
295 #[serde(default)]
296 pub profile_negotiation: Option<ProfileNegotiation>,
297}
298
299#[derive(Debug, Clone, Serialize)]
301pub struct TaskRequest {
302 pub task_id: String,
303 pub intent: String,
304 pub payload: serde_json::Value,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub constraints: Option<TaskConstraints>,
307 #[serde(skip)]
309 pub route_constraints: Option<RouteConstraints>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub auth: Option<TaskAuth>,
312 #[serde(skip_serializing_if = "Option::is_none")]
316 pub source_node_id: Option<String>,
317 #[serde(skip)]
319 pub routing_policy: Option<RoutingPolicy>,
320}
321
322#[derive(Debug, Clone, Serialize)]
324pub struct TaskConstraints {
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub timeout_ms: Option<u64>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub max_tokens: Option<u32>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub model: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub qos: Option<String>,
333 #[serde(skip)]
335 pub region: Option<String>,
336 #[serde(skip)]
338 pub min_reputation: Option<f64>,
339}
340
341#[derive(Debug, Clone, Serialize)]
343pub struct TaskAuth {
344 pub token: String,
345}
346
347#[derive(Debug, Clone, Deserialize)]
349pub struct TaskResponse {
350 pub task_id: String,
351 pub status: String,
352 pub result: Option<serde_json::Value>,
353 #[serde(default)]
354 pub iicp_conf_resp: Option<HashMap<String, serde_json::Value>>,
355 pub metrics: Option<TaskMetrics>,
356 #[serde(default)]
359 pub error: Option<serde_json::Value>,
360 #[serde(default)]
361 pub generated_by_ai: bool,
362 #[serde(default)]
363 pub dispatch_ticket_id_prefix: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub routing_receipt: Option<RoutingReceipt>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct RoutingReceipt {
370 pub receipt_version: String,
371 pub selection_profile: String,
372 pub eligible_candidate_count: usize,
373 pub selected_node_id_prefix: String,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub profile_negotiation: Option<ProfileNegotiation>,
376 pub redaction: String,
377}
378
379#[derive(Debug, Clone, Deserialize)]
381pub struct TaskMetrics {
382 pub latency_ms: Option<f64>,
383 pub tokens_used: Option<u32>,
384 pub node_id: Option<String>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ChatMessage {
390 pub role: String,
391 pub content: String,
392}
393
394#[derive(Debug, Default, Clone)]
396pub struct ChatOptions {
397 pub model: Option<String>,
398 pub max_tokens: Option<u32>,
399 pub timeout_ms: Option<u64>,
400 pub temperature: Option<f64>,
401 pub routing_policy: Option<RoutingPolicy>,
402 pub region: Option<String>,
403 pub qos: Option<String>,
404 pub min_reputation: Option<f64>,
405 pub browser_usable_only: Option<bool>,
406 pub profile_request: Option<ProfileRequest>,
407 pub route_constraints: Option<RouteConstraints>,
408}
409
410#[derive(Debug, Clone, Deserialize, Default)]
412pub struct ChatResponse {
413 pub choices: Vec<ChatChoice>,
414 pub usage: Option<ChatUsage>,
415 #[serde(default)]
417 pub task_id: String,
418 #[serde(default)]
420 pub node_id: Option<String>,
421 #[serde(default)]
422 pub generated_by_ai: bool,
423}
424
425#[derive(Debug, Clone, Deserialize)]
427pub struct ChatChoice {
428 pub message: ChatMessage,
429 pub finish_reason: Option<String>,
430}
431
432#[derive(Debug, Clone, Deserialize)]
434pub struct ChatUsage {
435 pub total_tokens: Option<u32>,
436 pub prompt_tokens: Option<u32>,
437 pub completion_tokens: Option<u32>,
438}
439
440#[cfg(test)]
441mod tests {
442 use super::Node;
443
444 #[test]
446 fn node_parses_health_label_and_exposure_mode() {
447 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"]}"#;
448 let n: Node = serde_json::from_str(json).unwrap();
449 assert_eq!(n.health_label.as_deref(), Some("healthy"));
450 assert_eq!(n.exposure_mode.as_deref(), Some("ipv4_public_direct"));
451 assert_eq!(n.transport, vec!["https", "iicp-native"]);
453 }
454
455 #[test]
457 fn node_health_fields_default_none_for_old_directory() {
458 let json =
459 r#"{"node_id":"n1","endpoint":"https://x","score":0.5,"available":true,"region":"eu"}"#;
460 let n: Node = serde_json::from_str(json).unwrap();
461 assert!(n.health_label.is_none());
462 assert!(n.exposure_mode.is_none());
463 }
464
465 #[test]
466 fn node_accepts_deprecated_public_key_alias_for_cx_key() {
467 let json = r#"{
468 "node_id":"n1",
469 "endpoint":"https://x",
470 "score":0.9,
471 "available":true,
472 "region":"eu",
473 "public_key":{
474 "algorithm":"X25519",
475 "encoding":"base64url",
476 "key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
477 "key_id":"cx-alias"
478 }
479 }"#;
480 let n: Node = serde_json::from_str(json).unwrap();
481 assert_eq!(
482 n.cx_public_key.as_ref().map(|key| key.key_id.as_str()),
483 Some("cx-alias")
484 );
485 }
486
487 #[test]
488 fn node_accepts_both_canonical_and_alias_without_duplicate_field_error() {
489 let json = r#"{
490 "node_id":"n1",
491 "endpoint":"https://x",
492 "score":0.9,
493 "available":true,
494 "region":"eu",
495 "cx_public_key":{
496 "algorithm":"X25519",
497 "encoding":"base64url",
498 "key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
499 "key_id":"cx-canonical"
500 },
501 "public_key":{
502 "algorithm":"X25519",
503 "encoding":"base64url",
504 "key":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
505 "key_id":"cx-alias"
506 }
507 }"#;
508 let n: Node = serde_json::from_str(json).unwrap();
509 assert_eq!(
510 n.cx_public_key.as_ref().map(|key| key.key_id.as_str()),
511 Some("cx-canonical")
512 );
513 }
514}