Skip to main content

rpi_ai/
model.rs

1//! Mirrors `packages/ai/src/models.ts` — `Model<TApi>` + the compatibility structs.
2//!
3//! The compat structs (`AnthropicMessagesCompat`, etc.) are kept here rather than in
4//! `types.rs` because they are model-layer configuration, not the core message
5//! contract. Providers read them off `Model` to decide request shaping.
6
7use crate::types::{Api, InputModality, ModelCost, ProviderId, ThinkingLevelMap};
8use serde::{Deserialize, Serialize};
9
10/// Compatibility flags for Anthropic Messages-compatible APIs.
11/// Mirrors TS `AnthropicMessagesCompat`. `Default` matches the documented defaults
12/// at each field.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(default, rename_all = "camelCase")]
15pub struct AnthropicMessagesCompat {
16    /// Default: true.
17    pub supports_eager_tool_input_streaming: Option<bool>,
18    /// Default: true.
19    pub supports_long_cache_retention: Option<bool>,
20    /// Default: false.
21    pub send_session_affinity_headers: Option<bool>,
22    /// Default: true.
23    pub supports_cache_control_on_tools: Option<bool>,
24    /// Default: true.
25    pub supports_temperature: Option<bool>,
26    /// Default: false.
27    pub force_adaptive_thinking: Option<bool>,
28    /// Default: false.
29    pub allow_empty_signature: Option<bool>,
30    /// Default: false.
31    pub supports_strict_tools: Option<bool>,
32    /// Default: true for first-party Anthropic (except Haiku / Claude ≤ 4.4); false otherwise.
33    pub supports_tool_references: Option<bool>,
34}
35
36impl AnthropicMessagesCompat {
37    /// Resolve a flag to its effective value given the documented default.
38    pub fn eager_tool_input_streaming(&self) -> bool {
39        self.supports_eager_tool_input_streaming.unwrap_or(true)
40    }
41    pub fn long_cache_retention(&self) -> bool {
42        self.supports_long_cache_retention.unwrap_or(true)
43    }
44    pub fn cache_control_on_tools(&self) -> bool {
45        self.supports_cache_control_on_tools.unwrap_or(true)
46    }
47    pub fn temperature(&self) -> bool {
48        self.supports_temperature.unwrap_or(true)
49    }
50    pub fn adaptive_thinking(&self) -> bool {
51        self.force_adaptive_thinking.unwrap_or(false)
52    }
53    pub fn empty_signature(&self) -> bool {
54        self.allow_empty_signature.unwrap_or(false)
55    }
56    pub fn strict_tools(&self) -> bool {
57        self.supports_strict_tools.unwrap_or(false)
58    }
59    pub fn tool_references(&self) -> bool {
60        // Mirrors defaultSupportsToolReferences: true for first-party Anthropic
61        // except Haiku and Claude ≤ 4.4. The model catalog overrides this for
62        // non-Anthropic providers (false).
63        self.supports_tool_references.unwrap_or(true)
64    }
65}
66
67/// Whether the next request carries the model's own prior reasoning back to it.
68///
69/// This decides what the system prompt may claim about the working-state
70/// channel: when reasoning *is* replayed, telling the model "reasoning is not
71/// carried into your next turn" is simply false, and the instruction to restate
72/// the plan every turn becomes busy-work (the re-plan symptom in
73/// `docs/debugging/llm-repetition-forensics.md`).
74///
75/// - `openai-completions`: with `compat.requiresThinkingAsText` (text parts) or
76///   `compat.requiresReasoningContent` (a separate reasoning_content field).
77/// - `openai-responses`: visible thinking is replayed as input content.
78/// - `anthropic-messages`: thinking blocks are echoed back with their signature.
79/// - everything else: reasoning is dropped, so the model cannot see it again.
80pub fn prior_reasoning_is_replayed(model: &Model) -> bool {
81    match model.api {
82        Api::OpenaiCompletions => matches!(
83            model.compat.as_ref(),
84            Some(StreamingProtocolCompat::OpenaiCompletions(compat))
85                if compat.requires_thinking_as_text == Some(true)
86                    || compat.requires_reasoning_content == Some(true)
87        ),
88        Api::OpenaiResponses | Api::AzureOpenaiResponses | Api::OpenaiCodexResponses => true,
89        Api::AnthropicMessages => true,
90        _ => false,
91    }
92}
93
94/// Compatibility flags for OpenAI Completions-compat APIs. Only a subset is
95/// modeled for v1 (we have no OpenAI provider yet); the rest live here as
96/// `Option`s for forward-compat.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
98#[serde(default, rename_all = "camelCase")]
99pub struct OpenaiCompletionsCompat {
100    pub supports_store: Option<bool>,
101    pub supports_developer_role: Option<bool>,
102    pub supports_reasoning_effort: Option<bool>,
103    pub supports_usage_in_streaming: Option<bool>,
104    pub supports_finish_reason: Option<bool>,
105    pub max_tokens_field: Option<MaxTokensField>,
106    pub requires_tool_result_name: Option<bool>,
107    pub requires_assistant_after_tool_result: Option<bool>,
108    pub requires_thinking_as_text: Option<bool>,
109    /// Replay prior assistant thinking in the wire reasoning_content field.
110    /// Opt-in per model; ordinary OpenAI endpoints keep their existing shape.
111    pub requires_reasoning_content: Option<bool>,
112    pub supports_strict_mode: Option<bool>,
113    pub send_session_affinity_headers: Option<bool>,
114    pub supports_long_cache_retention: Option<bool>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum MaxTokensField {
120    MaxCompletionTokens,
121    MaxTokens,
122}
123
124/// Compatibility flags for OpenAI Responses-compat APIs.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
126#[serde(default, rename_all = "camelCase")]
127pub struct OpenaiResponsesCompat {
128    pub supports_developer_role: Option<bool>,
129    pub supports_long_cache_retention: Option<bool>,
130    pub supports_strict_mode: Option<bool>,
131    /// Whether this endpoint accepts OpenAI custom tools with Lark/regex
132    /// grammar formats. Grammar tools fall back to ordinary function tools
133    /// when this is unset or false.
134    #[serde(rename = "supportsOpenAIGrammarTools")]
135    pub supports_openai_grammar_tools: Option<bool>,
136}
137
138/// Compatibility flags for Bedrock.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
140#[serde(default, rename_all = "camelCase")]
141pub struct BedrockCompat {
142    pub supports_strict_mode: Option<bool>,
143}
144
145/// Per-API compat blob. Matches the TS `compat?` discriminator on `Model`.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub enum StreamingProtocolCompat {
149    AnthropicMessages(AnthropicMessagesCompat),
150    OpenaiCompletions(OpenaiCompletionsCompat),
151    OpenaiResponses(OpenaiResponsesCompat),
152    Bedrock(BedrockCompat),
153    /// Custom / unknown API compat (opaque passthrough).
154    Other(serde_json::Value),
155    /// No compat configured (default for faux and simple providers).
156    None,
157}
158
159impl Default for StreamingProtocolCompat {
160    fn default() -> Self {
161        StreamingProtocolCompat::None
162    }
163}
164
165impl StreamingProtocolCompat {
166    pub fn as_anthropic(&self) -> Option<&AnthropicMessagesCompat> {
167        match self {
168            StreamingProtocolCompat::AnthropicMessages(c) => Some(c),
169            _ => None,
170        }
171    }
172}
173
174/// A model entry. Mirrors TS `Model<TApi>`; the generic is erased to a runtime
175/// `Api` field so a single `Model` works for every provider.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct Model {
179    pub id: String,
180    pub name: String,
181    pub api: Api,
182    pub provider: ProviderId,
183    pub base_url: String,
184    pub reasoning: bool,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub thinking_level_map: Option<ThinkingLevelMap>,
187    pub input: Vec<InputModality>,
188    pub cost: ModelCost,
189    /// Whether pricing was explicitly supplied, including intentionally free models.
190    #[serde(default)]
191    pub cost_configured: bool,
192    pub context_window: u64,
193    pub max_tokens: u64,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub sampling_params: Option<serde_json::Map<String, serde_json::Value>>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub headers: Option<std::collections::BTreeMap<String, String>>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub compat: Option<StreamingProtocolCompat>,
200}
201
202impl Model {
203    /// Construct a minimal model with empty cost + zeroed windows.
204    pub fn new(
205        id: impl Into<String>,
206        name: impl Into<String>,
207        api: Api,
208        provider: impl Into<String>,
209        base_url: impl Into<String>,
210    ) -> Self {
211        Self {
212            id: id.into(),
213            name: name.into(),
214            api,
215            provider: provider.into(),
216            base_url: base_url.into(),
217            reasoning: false,
218            thinking_level_map: None,
219            input: vec![InputModality::Text],
220            cost: ModelCost::default(),
221            cost_configured: false,
222            context_window: 0,
223            max_tokens: 0,
224            sampling_params: None,
225            headers: None,
226            compat: None,
227        }
228    }
229
230    /// Explicit zero rates and built-in nonzero rates both count as known pricing.
231    pub fn has_pricing(&self) -> bool {
232        self.cost_configured || self.cost != ModelCost::default()
233    }
234
235    /// Returns the supported thinking levels for this model. Mirrors
236    /// `getSupportedThinkingLevels`: `xhigh`/`max` are only listed when their map
237    /// entry is a non-null string; other levels are supported unless mapped to `null`.
238    pub fn supported_thinking_levels(&self) -> Vec<crate::types::ThinkingLevel> {
239        use crate::types::ThinkingLevel::*;
240        let map = self.thinking_level_map.as_ref();
241        let supported = |lvl: crate::types::ThinkingLevel| -> bool {
242            match map.and_then(|m| m.get(&lvl)) {
243                Some(None) => false,   // explicitly null → unsupported
244                Some(Some(_)) => true, // mapped → supported
245                None => true,          // absent → use provider default (supported)
246            }
247        };
248        let mut levels = vec![Off, Minimal, Low, Medium, High];
249        // xhigh/max only when their map entry is a non-null string.
250        if matches!(map.and_then(|m| m.get(&Xhigh)), Some(Some(_))) {
251            levels.push(Xhigh);
252        }
253        if matches!(map.and_then(|m| m.get(&Max)), Some(Some(_))) {
254            levels.push(Max);
255        }
256        levels.retain(|&lvl| supported(lvl));
257        levels
258    }
259
260    /// Clamp a requested thinking level to a supported one. Mirrors
261    /// `clampThinkingLevel`: walks the ordered list and accepts if supported,
262    /// otherwise falls back to the nearest lower supported level.
263    pub fn clamp_thinking_level(
264        &self,
265        requested: crate::types::ThinkingLevel,
266    ) -> crate::types::ThinkingLevel {
267        use crate::types::ThinkingLevel::*;
268        let order = [Off, Minimal, Low, Medium, High, Xhigh, Max];
269        let supported: std::collections::HashSet<_> =
270            self.supported_thinking_levels().into_iter().collect();
271        let req_idx = order
272            .iter()
273            .position(|l| *l == requested)
274            .unwrap_or(order.len() - 1);
275        // Walk from requested down to find nearest supported.
276        for i in (0..=req_idx).rev() {
277            if supported.contains(&order[i]) {
278                return order[i];
279            }
280        }
281        Off
282    }
283}