Skip to main content

tenzro_types/
model.rs

1//! AI Model inference types for Tenzro Network
2//!
3//! This module defines types for AI model registration, inference requests,
4//! and provider management.
5
6use crate::primitives::{Address, Hash, Timestamp};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Information about an AI model on Tenzro Network
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ModelInfo {
13    /// Unique model identifier
14    pub model_id: String,
15    /// Model name
16    pub name: String,
17    /// Model version
18    pub version: String,
19    /// Model description
20    pub description: String,
21    /// Model modality
22    pub modality: ModelModality,
23    /// Model architecture
24    pub architecture: String,
25    /// Model provider/creator
26    pub provider: Address,
27    /// Model hash for verification
28    pub model_hash: Hash,
29    /// Model parameters
30    pub parameters: ModelParameters,
31    /// Model pricing
32    pub pricing: PricingConfig,
33    /// Model status
34    pub status: ModelStatus,
35    /// Model metadata
36    pub metadata: HashMap<String, String>,
37    /// Mixture-of-Experts routing metadata (optional).
38    ///
39    /// Populated for MoE architectures (Mixtral, DeepSeek-V2/V3, Qwen2-MoE,
40    /// OpenMythos RDT-MoE, etc.) to enable routing schedulers to reason
41    /// about expert utilization, per-token expert selection cost, and
42    /// specialization-aware dispatch.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub moe: Option<MoeMetadata>,
45    /// Timeseries-specific parameters (forecast horizon, context length, …).
46    /// Populated only when `modality == Timeseries`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub timeseries: Option<TimeseriesParameters>,
49    /// Vision encoder parameters (input size, embedding dim, normalization).
50    /// Populated for `Image` and image-bearing compound modalities.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub vision: Option<VisionParameters>,
53    /// Audio model parameters (sample rate, encoder/decoder filenames, langs).
54    /// Populated for `Audio` and audio-bearing compound modalities.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub audio: Option<AudioParameters>,
57    /// Video model parameters (frame size, num frames, fps, embedding dim).
58    /// Populated for `Video` modality.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub video: Option<VideoParameters>,
61}
62
63impl ModelInfo {
64    /// Creates a new model info
65    pub fn new(
66        model_id: String,
67        name: String,
68        version: String,
69        modality: ModelModality,
70        provider: Address,
71    ) -> Self {
72        Self {
73            model_id,
74            name,
75            version,
76            description: String::new(),
77            modality,
78            architecture: String::new(),
79            provider,
80            model_hash: Hash::zero(),
81            parameters: ModelParameters::default(),
82            pricing: PricingConfig::default(),
83            status: ModelStatus::Pending,
84            metadata: HashMap::new(),
85            moe: None,
86            timeseries: None,
87            vision: None,
88            audio: None,
89            video: None,
90        }
91    }
92
93    /// Declares the model as a Mixture-of-Experts architecture and
94    /// attaches routing metadata.
95    pub fn with_moe(mut self, moe: MoeMetadata) -> Self {
96        self.moe = Some(moe);
97        self
98    }
99
100    /// Attach timeseries-specific parameters (forecast horizon, context
101    /// length, etc.). Should only be set when `modality == Timeseries`.
102    pub fn with_timeseries(mut self, params: TimeseriesParameters) -> Self {
103        self.timeseries = Some(params);
104        self
105    }
106
107    /// Attach vision-encoder parameters (input size, embedding dim,
108    /// normalization). Should be set for image and image-bearing compound
109    /// modalities.
110    pub fn with_vision(mut self, params: VisionParameters) -> Self {
111        self.vision = Some(params);
112        self
113    }
114
115    /// Attach audio-model parameters (sample rate, ONNX bundle filenames,
116    /// supported languages). Should be set for `Audio` modality.
117    pub fn with_audio(mut self, params: AudioParameters) -> Self {
118        self.audio = Some(params);
119        self
120    }
121
122    /// Attach video-model parameters (frame size, num frames, fps,
123    /// embedding dim). Should be set for `Video` modality.
124    pub fn with_video(mut self, params: VideoParameters) -> Self {
125        self.video = Some(params);
126        self
127    }
128
129    /// Returns `true` if this model is an MoE architecture with routing
130    /// metadata available.
131    pub fn is_moe(&self) -> bool {
132        self.moe.is_some()
133    }
134
135    /// Sets the description
136    pub fn with_description(mut self, description: String) -> Self {
137        self.description = description;
138        self
139    }
140
141    /// Sets the architecture
142    pub fn with_architecture(mut self, architecture: String) -> Self {
143        self.architecture = architecture;
144        self
145    }
146
147    /// Sets the model hash
148    pub fn with_hash(mut self, hash: Hash) -> Self {
149        self.model_hash = hash;
150        self
151    }
152}
153
154/// AI model modality
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
156pub enum ModelModality {
157    /// Text-only model
158    #[default]
159    Text,
160    /// Image-only model
161    Image,
162    /// Audio/speech model
163    Audio,
164    /// Timeseries forecasting model (numeric inputs/outputs)
165    Timeseries,
166    /// Video model
167    Video,
168    /// Text and image (multimodal)
169    TextImage,
170    /// Text and audio
171    TextAudio,
172    /// Multiple modalities
173    Multimodal,
174}
175
176impl ModelModality {
177    /// Returns true if this modality supports the requested capability.
178    ///
179    /// Compound modalities (TextImage, TextAudio, Multimodal) are treated as
180    /// supersets of their component modalities. For example, a `TextImage`
181    /// model supports both `Text` and `Image` queries, and a `Multimodal`
182    /// model supports all modalities.
183    ///
184    /// This enables inclusive model filtering: searching for `Text` returns
185    /// not just `Text` models but also `TextImage`, `TextAudio`, and
186    /// `Multimodal` models.
187    ///
188    /// `Timeseries` is treated as a single-purpose modality — it does not
189    /// participate in compound supersets and only matches itself.
190    pub fn supports(&self, requested: ModelModality) -> bool {
191        if *self == requested {
192            return true;
193        }
194        match *self {
195            ModelModality::Multimodal => !matches!(requested, ModelModality::Timeseries),
196            ModelModality::TextImage => matches!(requested, ModelModality::Text | ModelModality::Image),
197            ModelModality::TextAudio => matches!(requested, ModelModality::Text | ModelModality::Audio),
198            _ => false,
199        }
200    }
201}
202
203/// Model parameters and capabilities
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ModelParameters {
206    /// Number of parameters (e.g., 7B, 13B, 70B)
207    pub parameter_count: Option<u64>,
208    /// Context window size (tokens)
209    pub context_window: u32,
210    /// Maximum output tokens
211    pub max_output_tokens: u32,
212    /// Supported input formats
213    pub input_formats: Vec<String>,
214    /// Supported output formats
215    pub output_formats: Vec<String>,
216    /// Model capabilities/features
217    pub capabilities: Vec<String>,
218}
219
220impl Default for ModelParameters {
221    fn default() -> Self {
222        Self {
223            parameter_count: None,
224            context_window: 4096,
225            max_output_tokens: 2048,
226            input_formats: vec!["text".to_string()],
227            output_formats: vec!["text".to_string()],
228            capabilities: Vec::new(),
229        }
230    }
231}
232
233/// Mixture-of-Experts routing metadata for MoE architectures.
234///
235/// Captures the parameters an inference router needs to reason about
236/// per-token cost, expert utilization, and specialization-aware dispatch.
237/// Designed to cover Mixtral 8x7B / 8x22B, DeepSeek-V2 / V3 (shared +
238/// routed experts), Qwen2-MoE, and recurrent-depth MoE stacks such as
239/// OpenMythos.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct MoeMetadata {
242    /// Total number of routed experts in the model (e.g., 8 for Mixtral 8x7B,
243    /// 64 for Qwen2-MoE).
244    pub num_experts: u32,
245    /// Number of experts activated per token (top-k routing). Typical
246    /// values: 2 for Mixtral, 6 for DeepSeek-V2, 8 for Qwen2-MoE.
247    pub experts_per_token: u8,
248    /// Shared ("always-on") experts that process every token alongside
249    /// the routed experts. Used by DeepSeekMoE-style architectures;
250    /// zero for Mixtral-style models.
251    #[serde(default)]
252    pub shared_experts: u32,
253    /// Parameters per expert (in billions, scaled x10 for fixed-point —
254    /// e.g., 70 = 7.0B). `None` when unknown.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub params_per_expert_x10: Option<u32>,
257    /// Routing strategy used by the gating network.
258    pub routing_strategy: MoeRoutingStrategy,
259    /// Auxiliary load-balancing loss coefficient (x10000 fixed point).
260    /// Helps schedulers estimate how evenly load spreads across experts.
261    /// `None` when unknown.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub load_balance_coef_x10000: Option<u32>,
264    /// Attention mechanism variant (e.g., "mla" for Multi-head Latent
265    /// Attention, "mha", "mqa", "gqa").
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub attention_type: Option<String>,
268    /// Optional per-expert specialization labels (ordered by expert
269    /// index). E.g., `["math", "code", "reasoning", ...]`. Routers can
270    /// use these to bias toward specialized experts for known task
271    /// categories.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub expert_specialization: Option<Vec<String>>,
274    /// Expert capacity factor (x100 fixed point — e.g., 125 = 1.25).
275    /// Drives per-expert token budget: `capacity = ceil(tokens * top_k *
276    /// capacity_factor / num_experts)`. `None` defaults to 1.0.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub capacity_factor_x100: Option<u32>,
279}
280
281impl MoeMetadata {
282    /// Construct a minimal MoE metadata block from the required fields.
283    pub fn new(
284        num_experts: u32,
285        experts_per_token: u8,
286        routing_strategy: MoeRoutingStrategy,
287    ) -> Self {
288        Self {
289            num_experts,
290            experts_per_token,
291            shared_experts: 0,
292            params_per_expert_x10: None,
293            routing_strategy,
294            load_balance_coef_x10000: None,
295            attention_type: None,
296            expert_specialization: None,
297            capacity_factor_x100: None,
298        }
299    }
300
301    /// Declare shared ("always-on") experts.
302    pub fn with_shared_experts(mut self, shared: u32) -> Self {
303        self.shared_experts = shared;
304        self
305    }
306
307    /// Declare parameters-per-expert in billions (scaled x10).
308    pub fn with_params_per_expert_x10(mut self, params_x10: u32) -> Self {
309        self.params_per_expert_x10 = Some(params_x10);
310        self
311    }
312
313    /// Declare the attention variant (e.g., "mla", "gqa").
314    pub fn with_attention_type(mut self, attn: impl Into<String>) -> Self {
315        self.attention_type = Some(attn.into());
316        self
317    }
318
319    /// Attach a per-expert specialization label list.
320    pub fn with_expert_specialization(mut self, labels: Vec<String>) -> Self {
321        self.expert_specialization = Some(labels);
322        self
323    }
324
325    /// Declare the expert capacity factor as a x100 fixed-point value.
326    pub fn with_capacity_factor_x100(mut self, cf_x100: u32) -> Self {
327        self.capacity_factor_x100 = Some(cf_x100);
328        self
329    }
330
331    /// Total activated experts per token (routed top-k + shared).
332    pub fn active_experts_per_token(&self) -> u32 {
333        self.experts_per_token as u32 + self.shared_experts
334    }
335
336    /// Total parameters across all routed experts, in billions scaled x10.
337    /// Returns `None` if `params_per_expert_x10` is unset.
338    pub fn total_routed_params_x10(&self) -> Option<u64> {
339        self.params_per_expert_x10
340            .map(|p| p as u64 * self.num_experts as u64)
341    }
342
343    /// Active parameters per token, in billions scaled x10. Useful for
344    /// cost/latency estimation since MoE inference only pays for
345    /// activated experts, not the total parameter count.
346    pub fn active_params_per_token_x10(&self) -> Option<u64> {
347        self.params_per_expert_x10
348            .map(|p| p as u64 * self.active_experts_per_token() as u64)
349    }
350}
351
352/// Gating-network routing strategy for an MoE model.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
354pub enum MoeRoutingStrategy {
355    /// Classic top-k expert selection (Mixtral, Qwen2-MoE).
356    TopK,
357    /// Top-p (nucleus) expert selection — dynamic experts-per-token
358    /// based on cumulative gate probability.
359    TopP,
360    /// Expert-Choice routing — each expert picks its top-k tokens
361    /// (Zhou et al., 2022).
362    ExpertChoice,
363    /// Switch Transformer single-expert routing (top-1).
364    Switch,
365    /// Soft routing (all experts weighted, no hard top-k).
366    Soft,
367    /// Sinkhorn / BASE-layer balanced assignment.
368    Sinkhorn,
369    /// Hash-based fixed routing (no learned gate).
370    Hash,
371    /// Custom / proprietary routing.
372    Custom,
373}
374
375/// Timeseries forecasting model parameters.
376///
377/// Captures the shape contract of an ONNX timeseries model: how many
378/// historical points it consumes (`context_length`), how many points it
379/// emits (`max_horizon`), how many quantiles per step (`n_quantiles`,
380/// `1` for point forecasts), and how many parallel input series it
381/// accepts (`num_features`, `1` for univariate). Used by
382/// `TimeseriesRuntime` and the catalog-driven loader to validate
383/// inputs before invoking ORT.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct TimeseriesParameters {
386    /// Number of historical points the model conditions on.
387    pub context_length: u32,
388    /// Maximum number of forecast steps the model can emit in one pass.
389    pub max_horizon: u32,
390    /// Number of quantiles emitted per step (1 = point forecast,
391    /// >1 = quantile forecast).
392    pub n_quantiles: u32,
393    /// Number of input feature channels (1 = univariate;
394    /// >1 = multivariate / covariate-aware).
395    pub num_features: u32,
396}
397
398impl TimeseriesParameters {
399    /// Construct univariate point-forecast parameters.
400    pub fn univariate(context_length: u32, max_horizon: u32) -> Self {
401        Self {
402            context_length,
403            max_horizon,
404            n_quantiles: 1,
405            num_features: 1,
406        }
407    }
408
409    /// Construct quantile-forecast parameters.
410    pub fn with_quantiles(mut self, n_quantiles: u32) -> Self {
411        self.n_quantiles = n_quantiles;
412        self
413    }
414
415    /// Construct multivariate parameters.
416    pub fn with_features(mut self, num_features: u32) -> Self {
417        self.num_features = num_features;
418        self
419    }
420}
421
422/// Vision encoder parameters.
423///
424/// Captures everything needed to feed an image into an ONNX vision
425/// encoder: spatial input size, output embedding dimensionality, the
426/// normalization recipe (e.g., "clip", "imagenet", "siglip"), and the
427/// list of accepted image container formats. Used by `VisionRuntime`
428/// and the catalog-driven loader.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct VisionParameters {
431    /// Square input edge in pixels (e.g., 224, 256, 336, 384).
432    pub input_size: u32,
433    /// Output embedding dimensionality (e.g., 512 for CLIP B/32,
434    /// 1024 for DINOv2 large).
435    pub embedding_dim: u32,
436    /// Normalization recipe key — `"clip" | "imagenet" | "siglip"`.
437    pub normalization: String,
438    /// Accepted image container formats (e.g., `["png", "jpeg", "webp"]`).
439    pub image_formats: Vec<String>,
440}
441
442/// Audio model parameters.
443///
444/// Audio ONNX models are typically multi-file bundles
445/// (encoder + decoder + optional joiner for RNN-T style models). The
446/// filenames map to entries inside the HuggingFace repo. Sample rate
447/// is the canonical input rate the model expects; preprocessing
448/// resamples to it. `languages` carries ISO-639 codes for ASR models
449/// that advertise specific language coverage.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct AudioParameters {
452    /// Required input sample rate in Hz (typically 16000).
453    pub sample_rate: u32,
454    /// Encoder ONNX filename inside the HF repo bundle.
455    pub encoder_filename: String,
456    /// Decoder ONNX filename (Whisper-style, RNN-T joiner-decoder split).
457    /// `None` for single-encoder models like Moonshine.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub decoder_filename: Option<String>,
460    /// Joiner ONNX filename (RNN-T architectures, e.g., Parakeet TDT).
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub joiner_filename: Option<String>,
463    /// Maximum audio duration the model can process in one pass.
464    pub max_audio_seconds: u32,
465    /// Supported languages as ISO-639 codes (e.g., `["en", "de", "fr"]`).
466    /// Empty for monolingual models or where docs don't specify.
467    #[serde(default)]
468    pub languages: Vec<String>,
469}
470
471/// Video model parameters.
472///
473/// Captures the spatio-temporal input contract for a video encoder:
474/// per-frame spatial size, the number of frames consumed per inference,
475/// the target frames-per-second the model was trained on (used for
476/// stride during preprocessing), and the output embedding dimensionality.
477/// Wave 1 ships the type but the catalog is empty until a permissive
478/// + ONNX-shippable encoder lands.
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct VideoParameters {
481    /// Square frame edge in pixels.
482    pub frame_size: u32,
483    /// Number of frames consumed per inference (e.g., 16 for VideoMAE).
484    pub num_frames: u32,
485    /// Target FPS the model was trained on. Drives temporal stride
486    /// during frame extraction.
487    pub fps: u32,
488    /// Output embedding dimensionality.
489    pub embedding_dim: u32,
490}
491
492/// Model operational status
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494pub enum ModelStatus {
495    /// Registration pending verification
496    Pending,
497    /// Active and available for inference
498    Active,
499    /// Temporarily inactive
500    Inactive,
501    /// Deprecated
502    Deprecated,
503}
504
505/// An inference request to an AI model
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct InferenceRequest {
508    /// Request ID
509    pub request_id: String,
510    /// Model to use
511    pub model_id: String,
512    /// Requester address
513    pub requester: Address,
514    /// Input data
515    pub input: Vec<u8>,
516    /// Inference parameters
517    pub parameters: InferenceParameters,
518    /// Maximum price willing to pay (in smallest TNZO unit)
519    pub max_price: u64,
520    /// Request timestamp
521    pub timestamp: Timestamp,
522    /// Optional callback address
523    pub callback: Option<Address>,
524}
525
526impl InferenceRequest {
527    /// Creates a new inference request
528    pub fn new(
529        model_id: String,
530        requester: Address,
531        input: Vec<u8>,
532        max_price: u64,
533    ) -> Self {
534        Self {
535            request_id: uuid::Uuid::new_v4().to_string(),
536            model_id,
537            requester,
538            input,
539            parameters: InferenceParameters::default(),
540            max_price,
541            timestamp: Timestamp::now(),
542            callback: None,
543        }
544    }
545
546    /// Sets inference parameters
547    pub fn with_parameters(mut self, parameters: InferenceParameters) -> Self {
548        self.parameters = parameters;
549        self
550    }
551
552    /// Sets callback address
553    pub fn with_callback(mut self, callback: Address) -> Self {
554        self.callback = Some(callback);
555        self
556    }
557}
558
559/// Parameters for model inference
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct InferenceParameters {
562    /// Temperature for sampling
563    pub temperature: Option<u32>, // Stored as fixed-point (e.g., 100 = 1.0)
564    /// Top-p sampling
565    pub top_p: Option<u32>, // Stored as fixed-point
566    /// Top-k sampling
567    pub top_k: Option<u32>,
568    /// Maximum tokens to generate
569    pub max_tokens: Option<u32>,
570    /// Stop sequences
571    pub stop_sequences: Vec<String>,
572    /// Additional custom parameters
573    pub custom: HashMap<String, String>,
574}
575
576impl Default for InferenceParameters {
577    fn default() -> Self {
578        Self {
579            temperature: Some(100), // 1.0
580            top_p: None,
581            top_k: None,
582            max_tokens: None,
583            stop_sequences: Vec::new(),
584            custom: HashMap::new(),
585        }
586    }
587}
588
589/// Response from model inference
590///
591/// EU AI Act Article 50 (effective 2026-08-02) requires generative-AI outputs
592/// to carry both (a) a machine-readable disclosure that the content is
593/// AI-generated and (b) a verifiable provenance manifest. Both fields here
594/// are always populated by `tenzro-model::routing` for real inferences:
595/// `synthetic_content` is unconditionally `true` (every inference response is
596/// AI-generated by definition) and `provenance` carries a signed
597/// [`ProvenanceManifest`] when a `ProvenanceSigner` is wired into the router.
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599pub struct InferenceResponse {
600    /// Request ID this response is for
601    pub request_id: String,
602    /// Response ID
603    pub response_id: String,
604    /// Model that generated the response
605    pub model_id: String,
606    /// Provider that served the request
607    pub provider: Address,
608    /// Output data
609    pub output: Vec<u8>,
610    /// Response metadata
611    pub metadata: InferenceMetadata,
612    /// Actual price charged (in smallest TNZO unit)
613    pub price: u64,
614    /// Response timestamp
615    pub timestamp: Timestamp,
616    /// EU AI Act Article 50(2) — content is machine-generated. Always `true`
617    /// for genuine inference responses; deserialized as `true` by default so
618    /// integrations that build responses by hand cannot accidentally drop the
619    /// disclosure.
620    #[serde(default = "default_synthetic_content")]
621    pub synthetic_content: bool,
622    /// EU AI Act Article 50(2) — content provenance. `None` only for in-memory
623    /// transient responses that haven't been signed yet (e.g. mid-router). All
624    /// responses returned to RPC/MCP/A2A clients have this populated.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub provenance: Option<ProvenanceManifest>,
627}
628
629fn default_synthetic_content() -> bool {
630    true
631}
632
633impl InferenceResponse {
634    /// Creates a new inference response. The result is marked as
635    /// `synthetic_content = true` automatically per EU AI Act Article 50.
636    /// Callers should attach a [`ProvenanceManifest`] via [`with_provenance`]
637    /// before publishing the response off the node.
638    ///
639    /// [`with_provenance`]: InferenceResponse::with_provenance
640    pub fn new(
641        request_id: String,
642        model_id: String,
643        provider: Address,
644        output: Vec<u8>,
645        price: u64,
646    ) -> Self {
647        Self {
648            request_id,
649            response_id: uuid::Uuid::new_v4().to_string(),
650            model_id,
651            provider,
652            output,
653            metadata: InferenceMetadata::default(),
654            price,
655            timestamp: Timestamp::now(),
656            synthetic_content: true,
657            provenance: None,
658        }
659    }
660
661    /// Builder helper to attach a signed provenance manifest before the
662    /// response leaves the inference router.
663    pub fn with_provenance(mut self, manifest: ProvenanceManifest) -> Self {
664        self.provenance = Some(manifest);
665        self
666    }
667}
668
669/// Content provenance manifest — a C2PA-style attestation that an AI output
670/// was produced on Tenzro Network by a specific model + provider, signed by
671/// the provider's key. The manifest is small enough to embed in a JSON-RPC
672/// response and self-contained enough to verify offline given the signer's
673/// public key.
674///
675/// This is intentionally protocol-agnostic: when the C2PA Content Credentials
676/// final spec under the EU AI Office Code of Practice (June 2026) is
677/// finalized, the on-the-wire encoding can be swapped for a real `c2pa-rs`
678/// manifest store while keeping this type as the in-memory representation.
679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
680pub struct ProvenanceManifest {
681    /// SHA-256 of the inference output bytes (`InferenceResponse.output`).
682    /// Acts as the lookup key for `tenzro_getProvenance(content_hash)`.
683    pub content_hash: Hash,
684    /// Model that produced the content (mirror of `InferenceResponse.model_id`).
685    pub model_id: String,
686    /// Provider that ran the inference (mirror of `InferenceResponse.provider`).
687    pub provider: Address,
688    /// Wall-clock timestamp at which the manifest was signed.
689    pub signed_at: Timestamp,
690    /// Content classification — `"ai-generated"` for ordinary inference
691    /// outputs, `"deepfake"` for outputs that imitate a real person, place,
692    /// or event (EU AI Act Art. 50(4) labeling).
693    pub assertion: String,
694    /// Signer's public key (raw bytes — Ed25519 = 32B, secp256k1 = 33B).
695    pub signer_public_key: Vec<u8>,
696    /// Detached signature over the canonical preimage:
697    /// `content_hash || model_id (utf8) || provider (32B) || signed_at_ms (le_u64) || assertion (utf8)`.
698    pub signature: Vec<u8>,
699    /// Algorithm tag matching `signature` — `"ed25519"` or `"secp256k1"`.
700    pub algorithm: String,
701}
702
703impl ProvenanceManifest {
704    /// Canonical preimage used to verify [`signature`]. Recomputed by both
705    /// the signer (in `tenzro-model::provenance`) and any third-party
706    /// verifier — encoding here is the single source of truth.
707    ///
708    /// [`signature`]: ProvenanceManifest::signature
709    pub fn canonical_preimage(&self) -> Vec<u8> {
710        let mut buf = Vec::with_capacity(
711            self.content_hash.0.len()
712                + self.model_id.len()
713                + self.provider.0.len()
714                + 8
715                + self.assertion.len(),
716        );
717        buf.extend_from_slice(&self.content_hash.0);
718        buf.extend_from_slice(self.model_id.as_bytes());
719        buf.extend_from_slice(&self.provider.0);
720        buf.extend_from_slice(&self.signed_at.as_millis().to_le_bytes());
721        buf.extend_from_slice(self.assertion.as_bytes());
722        buf
723    }
724}
725
726/// Metadata about an inference response
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
728pub struct InferenceMetadata {
729    /// Tokens in the input
730    pub input_tokens: u32,
731    /// Tokens in the output
732    pub output_tokens: u32,
733    /// Inference latency (milliseconds)
734    pub latency_ms: u64,
735    /// Model version used
736    pub model_version: Option<String>,
737    /// Finish reason
738    pub finish_reason: Option<String>,
739}
740
741/// Information about a model inference provider
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743pub struct InferenceProvider {
744    /// Provider address
745    pub address: Address,
746    /// Provider name
747    pub name: String,
748    /// OpenAI-compatible API endpoint URL (e.g., "http://192.168.1.10:8545/v1")
749    pub endpoint_url: Option<String>,
750    /// Models this provider serves
751    pub models: Vec<String>,
752    /// Provider capacity
753    pub capacity: ProviderCapacity,
754    /// Provider pricing
755    pub pricing: PricingConfig,
756    /// Provider reputation
757    pub reputation: u64,
758    /// Total inferences served
759    pub total_inferences: u64,
760    /// Provider status
761    pub status: ProviderStatus,
762    /// Registration timestamp
763    pub registered_at: Timestamp,
764}
765
766impl InferenceProvider {
767    /// Creates a new inference provider
768    pub fn new(address: Address, name: String) -> Self {
769        Self {
770            address,
771            name,
772            endpoint_url: None,
773            models: Vec::new(),
774            capacity: ProviderCapacity::default(),
775            pricing: PricingConfig::default(),
776            reputation: 0,
777            total_inferences: 0,
778            status: ProviderStatus::Pending,
779            registered_at: Timestamp::now(),
780        }
781    }
782
783    /// Sets the provider's OpenAI-compatible API endpoint URL
784    pub fn with_endpoint_url(mut self, url: impl Into<String>) -> Self {
785        self.endpoint_url = Some(url.into());
786        self
787    }
788
789    /// Adds a model to the provider
790    pub fn add_model(&mut self, model_id: String) {
791        if !self.models.contains(&model_id) {
792            self.models.push(model_id);
793        }
794    }
795
796    /// Checks if provider serves a model
797    pub fn serves_model(&self, model_id: &str) -> bool {
798        self.models.iter().any(|m| m == model_id)
799    }
800}
801
802/// Provider capacity information
803#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
804pub struct ProviderCapacity {
805    /// Maximum concurrent requests
806    pub max_concurrent_requests: u32,
807    /// Current active requests
808    pub active_requests: u32,
809    /// Requests per second capacity
810    pub requests_per_second: u32,
811    /// Maximum batch size
812    pub max_batch_size: u32,
813    /// Multi-Token Prediction availability. Set by the provider at
814    /// `tenzro_registerProvider` time when their serving runtime has
815    /// the target's paired drafter co-loaded (`HfModelEntry.drafter_id`
816    /// + `mtp_kind == DraftMtp` or `Generic`). When true, the
817    /// `InferenceRouter` may route MTP-eligible requests preferentially
818    /// to this provider; when false, it falls back to standard
819    /// autoregressive providers.
820    #[serde(default)]
821    pub mtp_enabled: bool,
822    /// VRAM headroom (GB) the provider has reserved for the speculative
823    /// drafter alongside the target. Unsloth measures ~2 GB extra for
824    /// Gemma 4 MTP heads. `None` means the provider hasn't declared a
825    /// drafter footprint, which is fine when `mtp_enabled = false`.
826    #[serde(default)]
827    pub drafter_vram_gb: Option<f32>,
828    /// MoE expert-shard declaration. When a provider can't fit an entire
829    /// MoE model (e.g. Qwen 3.5 397B-A17B) on its hardware, it can host
830    /// a subset of expert weights and serve as one peer in a
831    /// decentralized expert-parallel dispatch. Empty `holdings` means
832    /// the provider does not participate in MoE expert serving for any
833    /// model and is treated as a full-model replica only.
834    #[serde(default)]
835    pub moe_holdings: Vec<MoeExpertHolding>,
836    /// MoE-pipeline role this provider plays. `Replica` is the default —
837    /// the provider holds the full model and serves single-peer
838    /// inference. `Router` provides the gating-network step and fans
839    /// out batched expert calls. `ExpertHolder` participates in the
840    /// expert-shard pool. `PrefillDecode` runs both phases co-located
841    /// (the centralized SOTA default). Providers can declare more than
842    /// one role; the router picks the matching role per request.
843    #[serde(default)]
844    pub moe_roles: Vec<MoeProviderRole>,
845    /// Iroh endpoint id of this provider. Used by the MoE router to
846    /// dispatch batched expert calls over QUIC directly to the holder
847    /// peer without going through the OpenAI-compatible HTTP endpoint.
848    /// Required when `moe_roles` includes `Router` or `ExpertHolder`.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub iroh_endpoint_id: Option<String>,
851}
852
853/// Provider's holding declaration for one MoE expert in one model.
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct MoeExpertHolding {
856    /// Tenzro model id this holding covers.
857    pub model_id: String,
858    /// Transformer layer index.
859    pub layer: u32,
860    /// Expert index inside the layer's MoE block.
861    pub expert: u32,
862    /// Residency state — `Warm` (VRAM-resident), `Cold` (disk only),
863    /// or `Evicting` (being unloaded). Schedulers prefer warm holdings.
864    pub residency: MoeExpertResidency,
865    /// Maximum tokens per second this provider commits to for this
866    /// expert post-batch. `0` means "best effort" with no SLA.
867    pub committed_tps: u32,
868}
869
870/// MoE expert residency state.
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872pub enum MoeExpertResidency {
873    /// In VRAM, ready to dispatch.
874    Warm,
875    /// On disk / CPU RAM, eviction-eligible.
876    Cold,
877    /// Currently being unloaded.
878    Evicting,
879}
880
881/// MoE pipeline roles a provider can play in distributed serving.
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
883pub enum MoeProviderRole {
884    /// Holds the full model; serves single-peer inference. The default.
885    Replica,
886    /// Runs the gating-network step and fans out batched expert calls
887    /// to the appropriate expert holders.
888    Router,
889    /// Holds one or more experts declared in `moe_holdings`.
890    ExpertHolder,
891    /// Runs both prefill and decode phases co-located (SOTA central
892    /// pattern; the Tenzro fallback when only one provider can fit the
893    /// model).
894    PrefillDecode,
895    /// Runs only the prefill phase; hands off KV cache to a decode
896    /// peer over iroh. Pairs with `Decode`.
897    Prefill,
898    /// Runs only the decode phase; accepts KV cache from a prefill
899    /// peer over iroh.
900    Decode,
901}
902
903impl Default for ProviderCapacity {
904    fn default() -> Self {
905        Self {
906            max_concurrent_requests: 10,
907            active_requests: 0,
908            requests_per_second: 100,
909            max_batch_size: 1,
910            mtp_enabled: false,
911            drafter_vram_gb: None,
912            moe_holdings: Vec::new(),
913            moe_roles: Vec::new(),
914            iroh_endpoint_id: None,
915        }
916    }
917}
918
919impl ProviderCapacity {
920    /// Checks if provider has capacity for a new request
921    pub fn has_capacity(&self) -> bool {
922        self.active_requests < self.max_concurrent_requests
923    }
924
925    /// Returns the utilization percentage (0-100)
926    pub fn utilization(&self) -> u8 {
927        if self.max_concurrent_requests == 0 {
928            0
929        } else {
930            ((self.active_requests as f64 / self.max_concurrent_requests as f64) * 100.0) as u8
931        }
932    }
933}
934
935/// Provider operational status
936#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
937pub enum ProviderStatus {
938    /// Registration pending
939    Pending,
940    /// Active and accepting requests
941    Active,
942    /// Temporarily inactive
943    Inactive,
944    /// Suspended
945    Suspended,
946}
947
948/// Pricing configuration for models and providers
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950pub struct PricingConfig {
951    /// Price per input token (in smallest TNZO unit)
952    pub price_per_input_token: u64,
953    /// Price per output token (in smallest TNZO unit)
954    pub price_per_output_token: u64,
955    /// Minimum price per request (in smallest TNZO unit)
956    pub minimum_price: u64,
957    /// Pricing model
958    pub pricing_model: PricingModel,
959}
960
961impl Default for PricingConfig {
962    fn default() -> Self {
963        Self {
964            price_per_input_token: 10,
965            price_per_output_token: 20,
966            minimum_price: 100,
967            pricing_model: PricingModel::PerToken,
968        }
969    }
970}
971
972/// Pricing models for inference
973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
974pub enum PricingModel {
975    /// Price per token (input and output priced separately)
976    PerToken,
977    /// Flat price per request
978    PerRequest,
979    /// Price based on compute time
980    PerComputeTime,
981    /// Dynamic pricing based on demand
982    Dynamic,
983}
984
985// === Model Service Instances ===
986
987/// A served model instance on the Tenzro network.
988///
989/// Each model that is actively served (locally or by a remote provider)
990/// gets a unique UUID-based service instance with both API and MCP endpoints.
991#[derive(Debug, Clone, Serialize, Deserialize)]
992pub struct ModelServiceInstance {
993    /// Unique service instance ID (UUID v4)
994    pub instance_id: String,
995    /// Catalog model ID (e.g., "qwen3-8b")
996    pub model_id: String,
997    /// Human-readable model name
998    pub model_name: String,
999    /// Provider's network address
1000    pub provider_address: Address,
1001    /// Human-readable provider name
1002    pub provider_name: String,
1003    /// Whether the model is local or on a remote network provider
1004    pub location: ModelLocation,
1005    /// OpenAI-compatible API endpoint (e.g., "http://host:8545/v1")
1006    pub api_endpoint: String,
1007    /// MCP server endpoint (e.g., "http://host:3001/mcp")
1008    pub mcp_endpoint: String,
1009    /// Current service status
1010    pub status: ServiceStatus,
1011    /// Model parameters (e.g., "8B")
1012    pub parameters: String,
1013    /// Pricing configuration
1014    pub pricing: PricingConfig,
1015    /// Timestamp when this instance was registered
1016    pub created_at: u64,
1017    /// Last time this endpoint was confirmed alive (Unix timestamp).
1018    /// Network endpoints expire after 5 minutes without heartbeat.
1019    #[serde(default)]
1020    pub last_seen: u64,
1021    /// Current load information (updated dynamically, only for local models)
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub load_info: Option<ModelLoadInfo>,
1024}
1025
1026/// Whether a model is served locally or by a remote network provider
1027#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1028pub enum ModelLocation {
1029    /// Served on this node
1030    Local,
1031    /// Served by a remote provider on the Tenzro network
1032    Network,
1033}
1034
1035impl std::fmt::Display for ModelLocation {
1036    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1037        match self {
1038            Self::Local => write!(f, "local"),
1039            Self::Network => write!(f, "network"),
1040        }
1041    }
1042}
1043
1044/// Operational status of a model service instance
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1046pub enum ServiceStatus {
1047    /// Online and accepting requests
1048    Online,
1049    /// Offline or unreachable
1050    Offline,
1051    /// Degraded performance
1052    Degraded,
1053}
1054
1055impl std::fmt::Display for ServiceStatus {
1056    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1057        match self {
1058            Self::Online => write!(f, "online"),
1059            Self::Offline => write!(f, "offline"),
1060            Self::Degraded => write!(f, "degraded"),
1061        }
1062    }
1063}
1064
1065/// Dynamic load information for a model service instance.
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1067pub struct ModelLoadInfo {
1068    /// Number of requests currently being processed or queued
1069    pub active_requests: u32,
1070    /// Maximum concurrent requests this instance can handle
1071    pub max_concurrent: u32,
1072    /// Utilization percentage (0-100)
1073    pub utilization_percent: u8,
1074    /// Human-readable load level
1075    pub load_level: String,
1076}
1077
1078// ─────────────────────────────────────────────────────────────────────────────
1079// Rich chat shape types
1080//
1081// These types support the "rich" call shape of `tenzro_chat` — multi-turn
1082// conversations, system prompts, tool calls, vision input, and structured
1083// assistant responses built from content blocks. The simple call shape
1084// (single `message` string) does not use these types and routes through
1085// `ModelChatMessage` in `tenzro-model::runtime`.
1086//
1087// Schema mirrors Anthropic's Messages API content-block format. See
1088// `docs/chat-api.md` for the public RPC contract.
1089// ─────────────────────────────────────────────────────────────────────────────
1090
1091/// A content block — the atomic unit of structured chat content.
1092///
1093/// Tagged externally on `type` for wire compatibility with Anthropic's
1094/// Messages API (so SDKs that already speak that schema work unchanged).
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096#[serde(tag = "type", rename_all = "snake_case")]
1097pub enum ContentBlock {
1098    /// Plain text content. Both directions (user input, assistant output).
1099    Text {
1100        text: String,
1101        #[serde(default, skip_serializing_if = "Option::is_none")]
1102        cache_control: Option<CacheControl>,
1103    },
1104    /// Extended-thinking trace. Assistant-only.
1105    Thinking { thinking: String },
1106    /// A tool invocation by the assistant. Assistant-only.
1107    ToolUse {
1108        id: String,
1109        name: String,
1110        input: serde_json::Value,
1111    },
1112    /// A tool execution result returned by the client. User-only.
1113    ToolResult {
1114        tool_use_id: String,
1115        /// Result content — either a plain string or a list of blocks
1116        /// (typically `text` blocks, or an `image` for vision tools).
1117        content: ToolResultContent,
1118        #[serde(default, skip_serializing_if = "Option::is_none")]
1119        is_error: Option<bool>,
1120    },
1121    /// Vision input. User-only.
1122    Image { source: ImageSource },
1123}
1124
1125/// Cache control marker — pins the prefix up to and including this block
1126/// as a cache breakpoint. Identical-prefix subsequent calls reuse the KV
1127/// cache and are billed at a discounted rate.
1128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1129#[serde(tag = "type", rename_all = "snake_case")]
1130pub enum CacheControl {
1131    /// Ephemeral cache (≤5 min lifetime).
1132    Ephemeral,
1133}
1134
1135/// Tool-result payload. Either a single string (the common case) or a list
1136/// of content blocks (when the tool returns structured or visual content).
1137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1138#[serde(untagged)]
1139pub enum ToolResultContent {
1140    Text(String),
1141    Blocks(Vec<ContentBlock>),
1142}
1143
1144/// Image source — only base64 inline for now. URL sources may be added later.
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146#[serde(tag = "type", rename_all = "snake_case")]
1147pub enum ImageSource {
1148    Base64 {
1149        media_type: String,
1150        data: String,
1151    },
1152}
1153
1154/// A message in the rich shape. `content` is either a plain string (which
1155/// the handler normalizes to a single `text` block) or an explicit array
1156/// of blocks.
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1158pub struct RichChatMessage {
1159    /// `"user"` or `"assistant"`. The simple/rich routing keeps the system
1160    /// prompt out of the messages array — see `RichChatRequest::system`.
1161    pub role: String,
1162    pub content: MessageContent,
1163}
1164
1165/// Message content — string or block array. The wire format permits either
1166/// for user messages; assistant messages are always emitted as block arrays.
1167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1168#[serde(untagged)]
1169pub enum MessageContent {
1170    Text(String),
1171    Blocks(Vec<ContentBlock>),
1172}
1173
1174impl MessageContent {
1175    /// Normalizes content to a vec of blocks. A plain string becomes a
1176    /// single `text` block.
1177    pub fn into_blocks(self) -> Vec<ContentBlock> {
1178        match self {
1179            MessageContent::Text(s) => vec![ContentBlock::Text {
1180                text: s,
1181                cache_control: None,
1182            }],
1183            MessageContent::Blocks(b) => b,
1184        }
1185    }
1186
1187    /// Borrows content as a slice of blocks, allocating only when the
1188    /// content is a plain string. Useful for read-only passes (token
1189    /// counting, validation).
1190    pub fn as_blocks(&self) -> std::borrow::Cow<'_, [ContentBlock]> {
1191        match self {
1192            MessageContent::Text(s) => std::borrow::Cow::Owned(vec![ContentBlock::Text {
1193                text: s.clone(),
1194                cache_control: None,
1195            }]),
1196            MessageContent::Blocks(b) => std::borrow::Cow::Borrowed(b),
1197        }
1198    }
1199}
1200
1201/// A tool the model may invoke. The model emits `ContentBlock::ToolUse`
1202/// blocks whose `input` validates against `input_schema`.
1203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1204pub struct ToolSchema {
1205    /// Tool name. Must match `^[a-zA-Z0-9_-]{1,64}$`.
1206    pub name: String,
1207    pub description: String,
1208    /// JSON Schema (draft 2020-12) describing the tool's input.
1209    pub input_schema: serde_json::Value,
1210}
1211
1212/// Reasoning effort budget for extended-thinking models.
1213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1214#[serde(rename_all = "lowercase")]
1215pub enum ReasoningEffort {
1216    Low,
1217    #[default]
1218    Medium,
1219    High,
1220}
1221
1222/// System-prompt content — either a plain string or a block array (so
1223/// `cache_control` can be applied to system text).
1224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1225#[serde(untagged)]
1226pub enum SystemPrompt {
1227    Text(String),
1228    Blocks(Vec<ContentBlock>),
1229}
1230
1231impl SystemPrompt {
1232    /// Returns the system prompt as a single concatenated string, suitable
1233    /// for chat templates that take a flat system field.
1234    pub fn as_text(&self) -> String {
1235        match self {
1236            SystemPrompt::Text(s) => s.clone(),
1237            SystemPrompt::Blocks(blocks) => blocks
1238                .iter()
1239                .filter_map(|b| match b {
1240                    ContentBlock::Text { text, .. } => Some(text.as_str()),
1241                    _ => None,
1242                })
1243                .collect::<Vec<_>>()
1244                .join(""),
1245        }
1246    }
1247}
1248
1249/// Why the assistant stopped generating.
1250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1251#[serde(rename_all = "snake_case")]
1252pub enum StopReason {
1253    /// Model finished naturally.
1254    EndTurn,
1255    /// Hit the `max_tokens` limit.
1256    MaxTokens,
1257    /// Hit a sequence in `stop_sequences`.
1258    StopSequence,
1259    /// Model emitted one or more `tool_use` blocks; the client is expected
1260    /// to execute them and return `tool_result` blocks in the next turn.
1261    ToolUse,
1262}
1263
1264/// Token usage and cache metrics on a rich-shape response.
1265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1266pub struct RichUsage {
1267    pub input_tokens: u32,
1268    pub output_tokens: u32,
1269    #[serde(default)]
1270    pub cache_creation_input_tokens: u32,
1271    #[serde(default)]
1272    pub cache_read_input_tokens: u32,
1273}
1274
1275#[cfg(test)]
1276mod rich_chat_tests {
1277    use super::*;
1278
1279    #[test]
1280    fn text_block_roundtrip() {
1281        let b = ContentBlock::Text {
1282            text: "hello".to_string(),
1283            cache_control: None,
1284        };
1285        let json = serde_json::to_string(&b).unwrap();
1286        assert_eq!(json, r#"{"type":"text","text":"hello"}"#);
1287        let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
1288        assert_eq!(decoded, b);
1289    }
1290
1291    #[test]
1292    fn thinking_block_roundtrip() {
1293        let b = ContentBlock::Thinking {
1294            thinking: "let me check".to_string(),
1295        };
1296        let json = serde_json::to_string(&b).unwrap();
1297        assert!(json.contains(r#""type":"thinking""#));
1298        let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
1299        assert_eq!(decoded, b);
1300    }
1301
1302    #[test]
1303    fn tool_use_block_roundtrip() {
1304        let b = ContentBlock::ToolUse {
1305            id: "tu_01".to_string(),
1306            name: "get_price".to_string(),
1307            input: serde_json::json!({"pair": "TNZO/USD"}),
1308        };
1309        let json = serde_json::to_string(&b).unwrap();
1310        assert!(json.contains(r#""type":"tool_use""#));
1311        let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
1312        assert_eq!(decoded, b);
1313    }
1314
1315    #[test]
1316    fn tool_result_string_content() {
1317        let b = ContentBlock::ToolResult {
1318            tool_use_id: "tu_01".to_string(),
1319            content: ToolResultContent::Text("0.42".to_string()),
1320            is_error: None,
1321        };
1322        let json = serde_json::to_string(&b).unwrap();
1323        let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
1324        assert_eq!(decoded, b);
1325    }
1326
1327    #[test]
1328    fn message_content_string_normalizes_to_text_block() {
1329        let mc = MessageContent::Text("hello".to_string());
1330        let blocks = mc.into_blocks();
1331        assert_eq!(blocks.len(), 1);
1332        match &blocks[0] {
1333            ContentBlock::Text { text, .. } => assert_eq!(text, "hello"),
1334            _ => panic!("expected text block"),
1335        }
1336    }
1337
1338    #[test]
1339    fn message_content_accepts_string_or_blocks() {
1340        let s: MessageContent = serde_json::from_str(r#""hello""#).unwrap();
1341        assert!(matches!(s, MessageContent::Text(_)));
1342        let b: MessageContent =
1343            serde_json::from_str(r#"[{"type":"text","text":"hi"}]"#).unwrap();
1344        assert!(matches!(b, MessageContent::Blocks(_)));
1345    }
1346
1347    #[test]
1348    fn stop_reason_serializes_snake_case() {
1349        assert_eq!(serde_json::to_string(&StopReason::EndTurn).unwrap(), r#""end_turn""#);
1350        assert_eq!(serde_json::to_string(&StopReason::ToolUse).unwrap(), r#""tool_use""#);
1351        assert_eq!(
1352            serde_json::to_string(&StopReason::MaxTokens).unwrap(),
1353            r#""max_tokens""#
1354        );
1355    }
1356
1357    #[test]
1358    fn reasoning_effort_serializes_lowercase() {
1359        assert_eq!(serde_json::to_string(&ReasoningEffort::Low).unwrap(), r#""low""#);
1360        assert_eq!(serde_json::to_string(&ReasoningEffort::High).unwrap(), r#""high""#);
1361    }
1362
1363    #[test]
1364    fn system_prompt_blocks_concatenate() {
1365        let sp = SystemPrompt::Blocks(vec![
1366            ContentBlock::Text {
1367                text: "you are ".to_string(),
1368                cache_control: None,
1369            },
1370            ContentBlock::Text {
1371                text: "helpful".to_string(),
1372                cache_control: Some(CacheControl::Ephemeral),
1373            },
1374        ]);
1375        assert_eq!(sp.as_text(), "you are helpful");
1376    }
1377
1378    #[test]
1379    fn full_rich_request_roundtrip() {
1380        let json = r#"{
1381            "role": "user",
1382            "content": [
1383                {"type": "text", "text": "What is TNZO trading at?"}
1384            ]
1385        }"#;
1386        let msg: RichChatMessage = serde_json::from_str(json).unwrap();
1387        assert_eq!(msg.role, "user");
1388        assert_eq!(msg.content.as_blocks().len(), 1);
1389    }
1390
1391    #[test]
1392    fn assistant_with_thinking_and_tool_use() {
1393        let json = r#"{
1394            "role": "assistant",
1395            "content": [
1396                {"type": "thinking", "thinking": "I should query the price oracle."},
1397                {"type": "tool_use", "id": "tu_01", "name": "get_price", "input": {"pair": "TNZO/USD"}}
1398            ]
1399        }"#;
1400        let msg: RichChatMessage = serde_json::from_str(json).unwrap();
1401        let blocks = msg.content.as_blocks();
1402        assert_eq!(blocks.len(), 2);
1403        assert!(matches!(&blocks[0], ContentBlock::Thinking { .. }));
1404        assert!(matches!(&blocks[1], ContentBlock::ToolUse { .. }));
1405    }
1406}
1407
1408#[cfg(test)]
1409mod moe_tests {
1410    use super::*;
1411
1412    #[test]
1413    fn moe_metadata_mixtral_8x7b() {
1414        // Mixtral 8x7B: 8 routed experts, top-2 routing, no shared experts.
1415        let moe = MoeMetadata::new(8, 2, MoeRoutingStrategy::TopK)
1416            .with_params_per_expert_x10(70) // 7.0B per expert
1417            .with_attention_type("gqa");
1418        assert_eq!(moe.num_experts, 8);
1419        assert_eq!(moe.experts_per_token, 2);
1420        assert_eq!(moe.shared_experts, 0);
1421        assert_eq!(moe.active_experts_per_token(), 2);
1422        assert_eq!(moe.total_routed_params_x10(), Some(560)); // 56.0B total
1423        assert_eq!(moe.active_params_per_token_x10(), Some(140)); // 14.0B active
1424    }
1425
1426    #[test]
1427    fn moe_metadata_deepseek_shared_experts() {
1428        // DeepSeek-V2-style: routed + shared (always-on) experts.
1429        let moe = MoeMetadata::new(64, 6, MoeRoutingStrategy::TopK)
1430            .with_shared_experts(2)
1431            .with_params_per_expert_x10(3); // 0.3B per expert
1432        assert_eq!(moe.active_experts_per_token(), 8); // 6 routed + 2 shared
1433        assert_eq!(moe.active_params_per_token_x10(), Some(24)); // 2.4B active
1434    }
1435
1436    #[test]
1437    fn moe_metadata_specialization_roundtrip() {
1438        let labels = vec!["math".to_string(), "code".to_string(), "reasoning".to_string()];
1439        let moe = MoeMetadata::new(3, 1, MoeRoutingStrategy::Switch)
1440            .with_expert_specialization(labels.clone());
1441        assert_eq!(moe.expert_specialization.as_ref(), Some(&labels));
1442    }
1443
1444    #[test]
1445    fn model_info_moe_wiring() {
1446        let info = ModelInfo::new(
1447            "mixtral-8x7b".to_string(),
1448            "Mixtral".to_string(),
1449            "0.1".to_string(),
1450            ModelModality::Text,
1451            Address::zero(),
1452        );
1453        assert!(!info.is_moe());
1454        let info = info.with_moe(MoeMetadata::new(8, 2, MoeRoutingStrategy::TopK));
1455        assert!(info.is_moe());
1456        assert_eq!(info.moe.as_ref().unwrap().num_experts, 8);
1457    }
1458
1459    #[test]
1460    fn moe_metadata_serde_json_omits_when_absent() {
1461        let info = ModelInfo::new(
1462            "dense-7b".to_string(),
1463            "Dense".to_string(),
1464            "0.1".to_string(),
1465            ModelModality::Text,
1466            Address::zero(),
1467        );
1468        let json = serde_json::to_string(&info).unwrap();
1469        // `moe: None` is skipped via skip_serializing_if.
1470        assert!(!json.contains("\"moe\""));
1471    }
1472
1473    #[test]
1474    fn moe_metadata_serde_roundtrip() {
1475        let info = ModelInfo::new(
1476            "mixtral-8x7b".to_string(),
1477            "Mixtral".to_string(),
1478            "0.1".to_string(),
1479            ModelModality::Text,
1480            Address::zero(),
1481        )
1482        .with_moe(
1483            MoeMetadata::new(8, 2, MoeRoutingStrategy::TopK)
1484                .with_params_per_expert_x10(70)
1485                .with_attention_type("gqa")
1486                .with_capacity_factor_x100(125),
1487        );
1488        let json = serde_json::to_string(&info).unwrap();
1489        let decoded: ModelInfo = serde_json::from_str(&json).unwrap();
1490        assert_eq!(decoded.moe, info.moe);
1491    }
1492}