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}