Skip to main content

iicp_client/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6/// Client-side remote-routing profile (#585).
7#[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/// Client-side pre-dispatch routing policy (#585).
31#[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/// Client configuration (SDK-04: timeout_ms enforced at construction time).
51#[derive(Debug, Clone)]
52pub struct ClientConfig {
53    pub directory_url: String,
54    /// Maximum request timeout in milliseconds. Must be ≤ 120 000 (SDK-04).
55    pub timeout_ms: u64,
56    pub region: Option<String>,
57    pub node_token: Option<String>,
58    /// Directory-issued consumer identity policy: optional | required | disabled.
59    pub consumer_auth_mode: String,
60    /// IICP-CX S.16: encrypt task payloads when the node advertises cx_public_key. Default: false.
61    pub use_confidentiality: bool,
62    /// ε-greedy exploration probability for provider selection (R4). Default: 0.05.
63    /// Override with IICP_ROUTING_EPSILON env var. Set to 0.0 to disable.
64    pub routing_epsilon: f64,
65    /// Selection strategy: deterministic | epsilon | softmax_top_k | weighted_v1 (opt-in).
66    pub routing_strategy: String,
67    /// Candidate pool size for softmax_top_k.
68    pub routing_top_k: usize,
69    /// Softmax temperature for softmax_top_k.
70    pub routing_softmax_tau: f64,
71    /// Phase 6 (#585): default client-side policy applied before remote dispatch.
72    pub routing_policy: RoutingPolicy,
73    /// Route endpoint migration mode: auto | ticketed | legacy.
74    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/// Options for `discover()` calls.
126#[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    /// Browser-like consumers can keep only HTTPS/loopback endpoints. Native default: false.
134    pub browser_usable_only: Option<bool>,
135    /// Optional additive directory capability request for a draft profile.
136    pub profile_request: Option<ProfileRequest>,
137}
138
139/// Prompt-free criteria used only for provider discovery and selection.
140#[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/// X25519 public key advertised by a CX-Provider node (IICP-CX S.16 §3.1).
168#[derive(Debug, Clone, Deserialize, Serialize)]
169pub struct CxPublicKey {
170    pub algorithm: String,
171    /// Encoding for `key`; directory validation expects base64url on REGISTER.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub encoding: Option<String>,
174    /// Base64url-encoded 32-byte X25519 public key.
175    pub key: String,
176    /// Stable provider-key identifier, currently `cx-` plus 16 hex chars.
177    pub key_id: String,
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub features: Vec<String>,
180}
181
182/// A single IICP node returned by `/v1/discover`.
183#[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    /// ADR-044 composed health label (healthy/degraded/impaired/critical/offline).
194    /// `None` against a directory predating v1.10.0.
195    pub health_label: Option<String>,
196    /// ADR-043 8-category network exposure classification. `None` if unset.
197    pub exposure_mode: Option<String>,
198    /// IICP-CX S.16 §3.1 — X25519 public key for E2E payload confidentiality.
199    /// Canonical IICP-CX key advertised by discovery; `public_key` is a deprecated alias.
200    pub cx_public_key: Option<CxPublicKey>,
201    /// #397 — transport protocols the node speaks (e.g. ["https","iicp-native"]).
202    /// Empty/absent against a directory predating the field.
203    pub transport: Vec<String>,
204    /// Additive routing-signal split from directory v1.10.50+.
205    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    /// Phase-1 compliance: public, self-attested node policy manifest.
210    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            // Prefer the canonical field if both appear. The deprecated alias is
261            // tolerated so a transitional directory response cannot break query
262            // with serde's "duplicate field `cx_public_key`" error.
263            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/// CIP policy block from the discover response.
285#[derive(Debug, Clone, Deserialize)]
286pub struct CipPolicy {
287    pub allow_remote_inference: bool,
288}
289
290/// Response from `/v1/discover`.
291#[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/// IICP task request body.
300#[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    /// Local-only route criteria. Never serialized to providers.
308    #[serde(skip)]
309    pub route_constraints: Option<RouteConstraints>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub auth: Option<TaskAuth>,
312    /// #488 — querying node identity for self-query neutrality at the directory.
313    /// Set to the requester's node_id so the serving node can include it in the
314    /// CIPWorkerReceipt, enabling the directory to detect same-operator loops.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub source_node_id: Option<String>,
317    /// Phase 6 (#585): optional per-request policy. Never serialized to nodes.
318    #[serde(skip)]
319    pub routing_policy: Option<RoutingPolicy>,
320}
321
322/// Constraints block for a task request.
323#[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    /// Compatibility route hint; omitted from provider serialization.
334    #[serde(skip)]
335    pub region: Option<String>,
336    /// Compatibility route hint; omitted from provider serialization.
337    #[serde(skip)]
338    pub min_reputation: Option<f64>,
339}
340
341/// Auth block for a task request.
342#[derive(Debug, Clone, Serialize)]
343pub struct TaskAuth {
344    pub token: String,
345}
346
347/// Response from `POST /v1/task`.
348#[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    /// Structured error block on a non-success node response (carries the IICP error
357    /// code the proxy surfaces). Defaults to None for success responses / older nodes.
358    #[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/// Task execution metrics.
380#[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/// A single chat message (role + content).
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ChatMessage {
390    pub role: String,
391    pub content: String,
392}
393
394/// Options for `chat()` calls.
395#[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/// OpenAI-compatible chat completion response.
411#[derive(Debug, Clone, Deserialize, Default)]
412pub struct ChatResponse {
413    pub choices: Vec<ChatChoice>,
414    pub usage: Option<ChatUsage>,
415    /// Task ID from the IICP task response (correlation handle).
416    #[serde(default)]
417    pub task_id: String,
418    /// IICP node that served this request — from task metrics.
419    #[serde(default)]
420    pub node_id: Option<String>,
421    #[serde(default)]
422    pub generated_by_ai: bool,
423}
424
425/// A single choice in a chat response.
426#[derive(Debug, Clone, Deserialize)]
427pub struct ChatChoice {
428    pub message: ChatMessage,
429    pub finish_reason: Option<String>,
430}
431
432/// Token usage from a chat completion.
433#[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    // ADR-044 — discover Node parses the composed health_label + exposure_mode.
445    #[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        // #397 — transport parses from discover.
452        assert_eq!(n.transport, vec!["https", "iicp-native"]);
453    }
454
455    // A directory predating v1.10.0 omits the fields; parsing must not break.
456    #[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}