iicp-client 0.7.90

Use the open IICP AI mesh from Rust without running a node
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
// SPDX-License-Identifier: Apache-2.0
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// Client-side remote-routing profile (#585).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingProfile {
    #[default]
    Standard,
    Sensitive,
    EuRestricted,
    StrictPolicy,
    DebugOverride,
}

impl RoutingProfile {
    pub fn from_cli(value: &str) -> Self {
        match value.replace('-', "_").to_ascii_lowercase().as_str() {
            "sensitive" => Self::Sensitive,
            "eu_restricted" => Self::EuRestricted,
            "strict_policy" => Self::StrictPolicy,
            "debug_override" => Self::DebugOverride,
            _ => Self::Standard,
        }
    }
}

/// Client-side pre-dispatch routing policy (#585).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoutingPolicy {
    pub profile: RoutingProfile,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_regions: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub require_encryption: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub require_policy_manifest: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub require_no_payload_retention: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub allow_remote_executor: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub known_operator_only: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub required_manifest_identity_level: Option<String>,
}

/// Client configuration (SDK-04: timeout_ms enforced at construction time).
#[derive(Debug, Clone)]
pub struct ClientConfig {
    pub directory_url: String,
    /// Maximum request timeout in milliseconds. Must be ≤ 120 000 (SDK-04).
    pub timeout_ms: u64,
    pub region: Option<String>,
    pub node_token: Option<String>,
    /// IICP-CX S.16: encrypt task payloads when the node advertises cx_public_key. Default: false.
    pub use_confidentiality: bool,
    /// ε-greedy exploration probability for provider selection (R4). Default: 0.05.
    /// Override with IICP_ROUTING_EPSILON env var. Set to 0.0 to disable.
    pub routing_epsilon: f64,
    /// Selection strategy: deterministic | epsilon | softmax_top_k | weighted_v1 (opt-in).
    pub routing_strategy: String,
    /// Candidate pool size for softmax_top_k.
    pub routing_top_k: usize,
    /// Softmax temperature for softmax_top_k.
    pub routing_softmax_tau: f64,
    /// Phase 6 (#585): default client-side policy applied before remote dispatch.
    pub routing_policy: RoutingPolicy,
    /// Route endpoint migration mode: auto | ticketed | legacy.
    pub route_discovery_mode: String,
    pub profile_request: Option<ProfileRequest>,
}

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" | "weighted_v1"
                )
            })
            .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,
            routing_policy: RoutingPolicy::default(),
            route_discovery_mode: std::env::var("IICP_ROUTE_DISCOVERY_MODE")
                .ok()
                .filter(|s| matches!(s.as_str(), "auto" | "ticketed" | "legacy"))
                .unwrap_or_else(|| "auto".into()),
            profile_request: None,
        }
    }
}

/// Options for `discover()` calls.
#[derive(Debug, Default, Clone)]
pub struct DiscoverOptions {
    pub region: Option<String>,
    pub model: Option<String>,
    pub min_reputation: Option<f64>,
    pub limit: Option<u32>,
    /// Browser-like consumers can keep only HTTPS/loopback endpoints. Native default: false.
    pub browser_usable_only: Option<bool>,
    /// Optional additive directory capability request for a draft profile.
    pub profile_request: Option<ProfileRequest>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ProfileRequest {
    pub profile_id: String,
    pub profile_version: String,
    pub profile_fixture_sha256: String,
    pub required: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileNegotiation {
    pub requested: bool,
    pub status: Option<String>,
    pub reason: Option<String>,
    pub dispatch_allowed: Option<bool>,
}

/// X25519 public key advertised by a CX-Provider node (IICP-CX S.16 §3.1).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CxPublicKey {
    pub algorithm: String,
    /// Encoding for `key`; directory validation expects base64url on REGISTER.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub encoding: Option<String>,
    /// Base64url-encoded 32-byte X25519 public key.
    pub key: String,
    /// Stable provider-key identifier, currently `cx-` plus 16 hex chars.
    pub key_id: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub features: Vec<String>,
}

/// A single IICP node returned by `/v1/discover`.
#[derive(Debug, Clone)]
pub struct Node {
    pub node_id: String,
    pub endpoint: String,
    pub score: f64,
    pub load: f64,
    pub available: bool,
    pub region: String,
    pub models: Option<Vec<String>>,
    pub cip_policy: Option<CipPolicy>,
    /// ADR-044 composed health label (healthy/degraded/impaired/critical/offline).
    /// `None` against a directory predating v1.10.0.
    pub health_label: Option<String>,
    /// ADR-043 8-category network exposure classification. `None` if unset.
    pub exposure_mode: Option<String>,
    /// IICP-CX S.16 §3.1 — X25519 public key for E2E payload confidentiality.
    /// Canonical IICP-CX key advertised by discovery; `public_key` is a deprecated alias.
    pub cx_public_key: Option<CxPublicKey>,
    /// #397 — transport protocols the node speaks (e.g. ["https","iicp-native"]).
    /// Empty/absent against a directory predating the field.
    pub transport: Vec<String>,
    /// Additive routing-signal split from directory v1.10.50+.
    pub directory_observed_reachable: Option<bool>,
    pub route_evidence: Option<String>,
    pub routing_hint: Option<String>,
    pub browser_usable: Option<bool>,
    /// Phase-1 compliance: public, self-attested node policy manifest.
    pub node_policy_manifest: Option<Value>,
    pub dispatch_ticket_id_prefix: Option<String>,
}

#[derive(Deserialize)]
struct NodeWire {
    pub node_id: String,
    pub endpoint: String,
    pub score: f64,
    #[serde(default)]
    pub load: 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>,
    #[serde(default)]
    pub node_policy_manifest: Option<Value>,
}

impl From<NodeWire> for Node {
    fn from(wire: NodeWire) -> Self {
        Self {
            node_id: wire.node_id,
            endpoint: wire.endpoint,
            score: wire.score,
            load: wire.load,
            available: wire.available,
            region: wire.region,
            models: wire.models,
            cip_policy: wire.cip_policy,
            health_label: wire.health_label,
            exposure_mode: wire.exposure_mode,
            // Prefer the canonical field if both appear. The deprecated alias is
            // tolerated so a transitional directory response cannot break query
            // with serde's "duplicate field `cx_public_key`" error.
            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,
            node_policy_manifest: wire.node_policy_manifest,
            dispatch_ticket_id_prefix: None,
        }
    }
}

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)
    }
}

/// CIP policy block from the discover response.
#[derive(Debug, Clone, Deserialize)]
pub struct CipPolicy {
    pub allow_remote_inference: bool,
}

/// Response from `/v1/discover`.
#[derive(Debug, Clone, Deserialize)]
pub struct NodeList {
    pub nodes: Vec<Node>,
    pub count: u32,
    #[serde(default)]
    pub profile_negotiation: Option<ProfileNegotiation>,
}

/// IICP task request body.
#[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>,
    /// #488 — querying node identity for self-query neutrality at the directory.
    /// Set to the requester's node_id so the serving node can include it in the
    /// CIPWorkerReceipt, enabling the directory to detect same-operator loops.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_node_id: Option<String>,
    /// Phase 6 (#585): optional per-request policy. Never serialized to nodes.
    #[serde(skip)]
    pub routing_policy: Option<RoutingPolicy>,
}

/// Constraints block for a task request.
#[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>,
}

/// Auth block for a task request.
#[derive(Debug, Clone, Serialize)]
pub struct TaskAuth {
    pub token: String,
}

/// Response from `POST /v1/task`.
#[derive(Debug, Clone, Deserialize)]
pub struct TaskResponse {
    pub task_id: String,
    pub status: String,
    pub result: Option<serde_json::Value>,
    #[serde(default)]
    pub iicp_conf_resp: Option<HashMap<String, serde_json::Value>>,
    pub metrics: Option<TaskMetrics>,
    /// Structured error block on a non-success node response (carries the IICP error
    /// code the proxy surfaces). Defaults to None for success responses / older nodes.
    #[serde(default)]
    pub error: Option<serde_json::Value>,
    #[serde(default)]
    pub generated_by_ai: bool,
    #[serde(default)]
    pub dispatch_ticket_id_prefix: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub routing_receipt: Option<RoutingReceipt>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingReceipt {
    pub receipt_version: String,
    pub selection_profile: String,
    pub eligible_candidate_count: usize,
    pub selected_node_id_prefix: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub profile_negotiation: Option<ProfileNegotiation>,
    pub redaction: String,
}

/// Task execution metrics.
#[derive(Debug, Clone, Deserialize)]
pub struct TaskMetrics {
    pub latency_ms: Option<f64>,
    pub tokens_used: Option<u32>,
    pub node_id: Option<String>,
}

/// A single chat message (role + content).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
    pub role: String,
    pub content: String,
}

/// Options for `chat()` calls.
#[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>,
    pub routing_policy: Option<RoutingPolicy>,
}

/// OpenAI-compatible chat completion response.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ChatResponse {
    pub choices: Vec<ChatChoice>,
    pub usage: Option<ChatUsage>,
    /// Task ID from the IICP task response (correlation handle).
    #[serde(default)]
    pub task_id: String,
    /// IICP node that served this request — from task metrics.
    #[serde(default)]
    pub node_id: Option<String>,
    #[serde(default)]
    pub generated_by_ai: bool,
}

/// A single choice in a chat response.
#[derive(Debug, Clone, Deserialize)]
pub struct ChatChoice {
    pub message: ChatMessage,
    pub finish_reason: Option<String>,
}

/// Token usage from a chat completion.
#[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;

    // ADR-044 — discover Node parses the composed health_label + exposure_mode.
    #[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"));
        // #397 — transport parses from discover.
        assert_eq!(n.transport, vec!["https", "iicp-native"]);
    }

    // A directory predating v1.10.0 omits the fields; parsing must not break.
    #[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")
        );
    }
}