Skip to main content

perspt_sdk/model/
capabilities.rs

1//! Provider capability record and honest degradation (PSP-9 system 2).
2//!
3//! Providers differ in what they support, and a platform that hides those
4//! differences will silently violate its own contracts. The record type lives
5//! here so routing can filter on it; probing and the degradation *ladder* are
6//! runtime concerns and live with the transport in `perspt-core`.
7//!
8//! **Silent emulation is prohibited (Gate U).** A provider without tool
9//! calling is degraded to bundle mode with the reason recorded — never given
10//! a text protocol that imitates tool calls, because emulated calls are not
11//! recorded structured observations and would break the recording obligation
12//! (R2) that Theorem 7 depends on.
13
14use serde::{Deserialize, Serialize};
15
16/// Declared and probed capabilities of one provider route.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ProviderCapabilities {
19    pub tool_calling: bool,
20    pub strict_schema: bool,
21    pub parallel_tool_calls: bool,
22    pub streaming_tool_calls: bool,
23    pub prompt_caching: bool,
24    pub structured_output: bool,
25    pub max_context_tokens: u32,
26}
27
28impl ProviderCapabilities {
29    /// The most conservative record: nothing beyond plain text chat.
30    pub fn text_only(max_context_tokens: u32) -> Self {
31        Self {
32            tool_calling: false,
33            strict_schema: false,
34            parallel_tool_calls: false,
35            streaming_tool_calls: false,
36            prompt_caching: false,
37            structured_output: false,
38            max_context_tokens,
39        }
40    }
41
42    /// Whether this record satisfies every capability `required` asks for.
43    pub fn satisfies(&self, required: &ProviderCapabilityMask) -> bool {
44        (!required.tool_calling || self.tool_calling)
45            && (!required.strict_schema || self.strict_schema)
46            && (!required.parallel_tool_calls || self.parallel_tool_calls)
47            && (!required.streaming_tool_calls || self.streaming_tool_calls)
48            && (!required.prompt_caching || self.prompt_caching)
49            && (!required.structured_output || self.structured_output)
50            && self.max_context_tokens >= required.min_context_tokens
51    }
52}
53
54/// A requirement mask for route resolution (`RouteObjective::require`).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
56pub struct ProviderCapabilityMask {
57    pub tool_calling: bool,
58    pub strict_schema: bool,
59    pub parallel_tool_calls: bool,
60    pub streaming_tool_calls: bool,
61    pub prompt_caching: bool,
62    pub structured_output: bool,
63    pub min_context_tokens: u32,
64}
65
66impl ProviderCapabilityMask {
67    /// The requirement the tool loop itself imposes on a route.
68    pub fn tool_loop() -> Self {
69        Self {
70            tool_calling: true,
71            ..Self::default()
72        }
73    }
74}
75
76/// One recorded degradation: a capability the route lacks and the explicit,
77/// invariant-preserving fallback taken instead. Every degradation is a ledger
78/// event and is visible in `perspt providers`; none may silently emulate the
79/// missing capability.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case", tag = "missing")]
82pub enum CapabilityDegradation {
83    /// Non-strict schemas sent; arguments validated locally, violations become
84    /// `ToolArgumentInvalid` residuals with a directed correction.
85    StrictSchema,
86    /// One tool call per turn. Costs turns, changes no invariant.
87    ParallelToolCalls,
88    /// Non-streaming turn; progress is turn-granular rather than
89    /// token-granular.
90    StreamingToolCalls,
91    /// Route accounting marks the route cache-cold; routing weights it
92    /// accordingly.
93    PromptCaching,
94    /// Route is ineligible as an actuator; an explicit tool-capable fallback
95    /// must be selected or the session must stop.
96    ToolCalling,
97    /// The route's context window is smaller than the node's packed context;
98    /// the route is ineligible for that node.
99    ContextWindow { needed: u32, available: u32 },
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn full() -> ProviderCapabilities {
107        ProviderCapabilities {
108            tool_calling: true,
109            strict_schema: true,
110            parallel_tool_calls: true,
111            streaming_tool_calls: true,
112            prompt_caching: true,
113            structured_output: true,
114            max_context_tokens: 200_000,
115        }
116    }
117
118    #[test]
119    fn a_full_record_satisfies_the_tool_loop_mask() {
120        assert!(full().satisfies(&ProviderCapabilityMask::tool_loop()));
121    }
122
123    #[test]
124    fn text_only_fails_the_tool_loop_mask() {
125        assert!(
126            !ProviderCapabilities::text_only(8192).satisfies(&ProviderCapabilityMask::tool_loop())
127        );
128    }
129
130    #[test]
131    fn context_window_is_a_hard_requirement() {
132        let mask = ProviderCapabilityMask {
133            min_context_tokens: 1_000_000,
134            ..ProviderCapabilityMask::default()
135        };
136        assert!(!full().satisfies(&mask));
137    }
138}