Skip to main content

objectiveai_sdk/agent/openrouter/
agent.rs

1//! OpenRouter Agent types and validation logic.
2
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use twox_hash::XxHash3_128;
7
8/// The base configuration for an OpenRouter Agent (without computed ID).
9#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, arbitrary::Arbitrary)]
10#[schemars(rename = "agent.openrouter.AgentBase")]
11pub struct AgentBase {
12    /// The upstream provider marker.
13    pub upstream: super::Upstream,
14
15    /// The upstream language model identifier (e.g., `"gpt-4"`, `"claude-3-opus"`).
16    pub model: String,
17
18    /// The output mode for vector completions. Ignored for agent completions.
19    #[serde(default)]
20    pub output_mode: super::OutputMode,
21
22    /// Enable synthetic reasoning for non-reasoning LLMs.
23    ///
24    /// **Vector completions only.** Ignored for agent completions.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    #[schemars(extend("omitempty" = true))]
27    pub synthetic_reasoning: Option<bool>,
28
29    /// Number of top log probabilities to return (2-20).
30    ///
31    /// **Vector completions only.** Ignored for agent completions.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    #[schemars(extend("omitempty" = true))]
34    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_u64)]
35    pub top_logprobs: Option<u64>,
36
37    /// Messages prepended to the user's prompt.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    #[schemars(extend("omitempty" = true))]
40    pub prefix_messages: Option<Vec<super::super::completions::message::Message>>,
41
42    /// Messages inserted after the leading chain of system/developer messages.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    #[schemars(extend("omitempty" = true))]
45    pub post_system_prefix_messages: Option<Vec<super::super::completions::message::Message>>,
46
47    /// Messages appended after the user's prompt.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    #[schemars(extend("omitempty" = true))]
50    pub suffix_messages: Option<Vec<super::super::completions::message::Message>>,
51
52    /// MCP servers the agent can connect to.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    #[schemars(extend("omitempty" = true))]
55    pub mcp_servers: Option<super::super::McpServers>,
56
57    // --- OpenAI-compatible parameters ---
58    /// Penalizes tokens based on their frequency in the output so far (-2.0 to 2.0).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    #[schemars(extend("omitempty" = true))]
61    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
62    pub frequency_penalty: Option<f64>,
63    /// Token ID to bias mapping (-100 to 100). Positive values increase likelihood.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    #[schemars(extend("omitempty" = true))]
66    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_indexmap_string_i64)]
67    pub logit_bias: Option<IndexMap<String, i64>>,
68    /// Maximum tokens in the completion.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    #[schemars(extend("omitempty" = true))]
71    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_u64)]
72    pub max_completion_tokens: Option<u64>,
73    /// Penalizes tokens based on their presence in the output so far (-2.0 to 2.0).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    #[schemars(extend("omitempty" = true))]
76    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
77    pub presence_penalty: Option<f64>,
78    /// Stop sequences that halt generation.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    #[schemars(extend("omitempty" = true))]
81    pub stop: Option<super::Stop>,
82    /// Sampling temperature (0.0 to 2.0). Higher = more random.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    #[schemars(extend("omitempty" = true))]
85    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
86    pub temperature: Option<f64>,
87    /// Nucleus sampling probability (0.0 to 1.0).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    #[schemars(extend("omitempty" = true))]
90    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
91    pub top_p: Option<f64>,
92
93    // --- OpenRouter-specific parameters ---
94    /// Maximum tokens (OpenRouter variant of max_completion_tokens).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    #[schemars(extend("omitempty" = true))]
97    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_u64)]
98    pub max_tokens: Option<u64>,
99    /// Minimum probability threshold for sampling (0.0 to 1.0).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    #[schemars(extend("omitempty" = true))]
102    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
103    pub min_p: Option<f64>,
104    /// Provider routing preferences.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    #[schemars(extend("omitempty" = true))]
107    pub provider: Option<super::Provider>,
108    /// Reasoning/thinking configuration for supported models.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    #[schemars(extend("omitempty" = true))]
111    pub reasoning: Option<super::Reasoning>,
112    /// Repetition penalty (0.0 to 2.0). Values > 1.0 penalize repetition.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    #[schemars(extend("omitempty" = true))]
115    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
116    pub repetition_penalty: Option<f64>,
117    /// Top-a sampling parameter (0.0 to 1.0).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    #[schemars(extend("omitempty" = true))]
120    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_f64)]
121    pub top_a: Option<f64>,
122    /// Top-k sampling: only consider the k most likely tokens.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    #[schemars(extend("omitempty" = true))]
125    #[arbitrary(with = crate::arbitrary_util::arbitrary_option_u64)]
126    pub top_k: Option<u64>,
127    /// Output verbosity hint for supported models.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    #[schemars(extend("omitempty" = true))]
130    pub verbosity: Option<super::Verbosity>,
131}
132
133impl AgentBase {
134    /// Normalizes the configuration for deterministic ID computation.
135    pub fn prepare(&mut self) {
136        self.synthetic_reasoning = match self.synthetic_reasoning {
137            Some(false) => None,
138            other => other,
139        };
140        self.top_logprobs = match self.top_logprobs {
141            Some(0) | Some(1) => None,
142            other => other,
143        };
144        self.prefix_messages = match self.prefix_messages.take() {
145            Some(prefix_messages) if prefix_messages.is_empty() => None,
146            Some(mut prefix_messages) => {
147                super::super::completions::message::prompt::prepare(&mut prefix_messages);
148                if prefix_messages.is_empty() { None } else { Some(prefix_messages) }
149            }
150            None => None,
151        };
152        self.post_system_prefix_messages = match self.post_system_prefix_messages.take() {
153            Some(msgs) if msgs.is_empty() => None,
154            Some(mut msgs) => {
155                super::super::completions::message::prompt::prepare(&mut msgs);
156                if msgs.is_empty() { None } else { Some(msgs) }
157            }
158            None => None,
159        };
160        self.suffix_messages = match self.suffix_messages.take() {
161            Some(suffix_messages) if suffix_messages.is_empty() => None,
162            Some(mut suffix_messages) => {
163                super::super::completions::message::prompt::prepare(&mut suffix_messages);
164                if suffix_messages.is_empty() { None } else { Some(suffix_messages) }
165            }
166            None => None,
167        };
168        self.mcp_servers = match self.mcp_servers.take() {
169            Some(mcp_servers) => super::super::mcp::mcp_servers::prepare(mcp_servers),
170            None => None,
171        };
172        self.frequency_penalty = match self.frequency_penalty {
173            Some(frequency_penalty) if frequency_penalty == 0.0 => None,
174            other => other,
175        };
176        self.logit_bias = match self.logit_bias.take() {
177            Some(logit_bias) if logit_bias.is_empty() => None,
178            Some(mut logit_bias) => {
179                logit_bias.retain(|_, &mut weight| weight != 0);
180                logit_bias.sort_unstable_keys();
181                Some(logit_bias)
182            }
183            None => None,
184        };
185        self.max_completion_tokens = match self.max_completion_tokens {
186            Some(0) => None,
187            other => other,
188        };
189        self.presence_penalty = match self.presence_penalty {
190            Some(presence_penalty) if presence_penalty == 0.0 => None,
191            other => other,
192        };
193        self.stop = match self.stop.take() {
194            Some(stop) => stop.prepare(),
195            None => None,
196        };
197        self.temperature = match self.temperature {
198            Some(temperature) if temperature == 1.0 => None,
199            other => other,
200        };
201        self.top_p = match self.top_p {
202            Some(top_p) if top_p == 1.0 => None,
203            other => other,
204        };
205        self.max_tokens = match self.max_tokens {
206            Some(0) => None,
207            other => other,
208        };
209        self.min_p = match self.min_p {
210            Some(min_p) if min_p == 0.0 => None,
211            other => other,
212        };
213        self.provider = match self.provider.take() {
214            Some(provider) => provider.prepare(),
215            None => None,
216        };
217        self.reasoning = match self.reasoning.take() {
218            Some(reasoning) => reasoning.prepare(),
219            None => None,
220        };
221        self.repetition_penalty = match self.repetition_penalty {
222            Some(repetition_penalty) if repetition_penalty == 1.0 => None,
223            other => other,
224        };
225        self.top_a = match self.top_a {
226            Some(top_a) if top_a == 0.0 => None,
227            other => other,
228        };
229        self.top_k = match self.top_k {
230            Some(0) => None,
231            other => other,
232        };
233        self.verbosity = match self.verbosity.take() {
234            Some(verbosity) => verbosity.prepare(),
235            None => None,
236        };
237    }
238
239    /// Validates the configuration.
240    pub fn validate(&self) -> Result<(), String> {
241        fn validate_f64(
242            name: &str,
243            value: Option<f64>,
244            min: f64,
245            max: f64,
246        ) -> Result<(), String> {
247            if let Some(v) = value {
248                if !v.is_finite() {
249                    return Err(format!("`{}` must be a finite number", name));
250                }
251                if v < min || v > max {
252                    return Err(format!(
253                        "`{}` must be between {} and {}",
254                        name, min, max
255                    ));
256                }
257            }
258            Ok(())
259        }
260        fn validate_u64(
261            name: &str,
262            value: Option<u64>,
263            min: u64,
264            max: u64,
265        ) -> Result<(), String> {
266            if let Some(v) = value {
267                if v < min || v > max {
268                    return Err(format!(
269                        "`{}` must be between {} and {}",
270                        name, min, max
271                    ));
272                }
273            }
274            Ok(())
275        }
276        if self.model.is_empty() {
277            return Err("`model` string cannot be empty".to_string());
278        }
279        if self.synthetic_reasoning.is_some()
280            && let super::OutputMode::Instruction = self.output_mode
281        {
282            return Err(
283                "`synthetic_reasoning` cannot be true when `output_mode` is \"instruction\""
284                    .to_string(),
285            );
286        }
287        if let Some(top_logprobs) = self.top_logprobs
288            && top_logprobs > 20
289        {
290            return Err("`top_logprobs` must be at most 20".to_string());
291        }
292        if let Some(mcp_servers) = &self.mcp_servers {
293            super::super::mcp::mcp_servers::validate(mcp_servers)?;
294        }
295        validate_f64("frequency_penalty", self.frequency_penalty, -2.0, 2.0)?;
296        if let Some(logit_bias) = &self.logit_bias {
297            for (token, weight) in logit_bias {
298                if token.is_empty() {
299                    return Err("`logit_bias` keys cannot be empty".to_string());
300                } else if !token.chars().all(|c| c.is_ascii_digit()) {
301                    return Err(
302                        "`logit_bias` keys must be stringified token IDs"
303                            .to_string(),
304                    );
305                } else if token.chars().next().unwrap() == '0'
306                    && token.len() > 1
307                {
308                    return Err("`logit_bias` keys cannot have leading zeros"
309                        .to_string());
310                } else if *weight < -100 || *weight > 100 {
311                    return Err(
312                        "`logit_bias` values must be between -100 and 100"
313                            .to_string(),
314                    );
315                }
316            }
317        }
318        validate_u64(
319            "max_completion_tokens",
320            self.max_completion_tokens,
321            0,
322            i32::MAX as u64,
323        )?;
324        validate_f64("presence_penalty", self.presence_penalty, -2.0, 2.0)?;
325        if let Some(stop) = &self.stop {
326            stop.validate()?;
327        }
328        validate_f64("temperature", self.temperature, 0.0, 2.0)?;
329        validate_f64("top_p", self.top_p, 0.0, 1.0)?;
330        validate_u64("max_tokens", self.max_tokens, 0, i32::MAX as u64)?;
331        validate_f64("min_p", self.min_p, 0.0, 1.0)?;
332        if let Some(provider) = &self.provider {
333            provider.validate()?;
334        }
335        if let Some(reasoning) = &self.reasoning {
336            reasoning.validate()?;
337        }
338        validate_f64("repetition_penalty", self.repetition_penalty, 0.0, 2.0)?;
339        validate_f64("top_a", self.top_a, 0.0, 1.0)?;
340        validate_u64("top_k", self.top_k, 0, i32::MAX as u64)?;
341        if let Some(verbosity) = &self.verbosity {
342            verbosity.validate()?;
343        }
344        Ok(())
345    }
346
347    /// Returns prefix messages, then the provided messages, then suffix messages.
348    pub fn merged_messages(
349        &self,
350        messages: Vec<super::super::completions::message::Message>,
351    ) -> Vec<super::super::completions::message::Message> {
352        use super::super::completions::message::Message;
353        let prefix_len = self.prefix_messages.as_ref().map_or(0, |m| m.len());
354        let post_sys_len = self.post_system_prefix_messages.as_ref().map_or(0, |m| m.len());
355        let suffix_len = self.suffix_messages.as_ref().map_or(0, |m| m.len());
356        let mut merged = Vec::with_capacity(prefix_len + post_sys_len + messages.len() + suffix_len);
357        if let Some(prefix) = &self.prefix_messages {
358            merged.extend(prefix.iter().cloned());
359        }
360        let mut post_sys_inserted = self.post_system_prefix_messages.is_none();
361        for msg in messages {
362            if !post_sys_inserted {
363                if !matches!(msg, Message::System(_) | Message::Developer(_)) {
364                    merged.extend(self.post_system_prefix_messages.as_ref().unwrap().iter().cloned());
365                    post_sys_inserted = true;
366                }
367            }
368            merged.push(msg);
369        }
370        if !post_sys_inserted {
371            merged.extend(self.post_system_prefix_messages.as_ref().unwrap().iter().cloned());
372        }
373        if let Some(suffix) = &self.suffix_messages {
374            merged.extend(suffix.iter().cloned());
375        }
376        merged
377    }
378
379    /// Computes the deterministic content-addressed ID.
380    pub fn id(&self) -> String {
381        let mut hasher = XxHash3_128::with_seed(0);
382        hasher.write(serde_json::to_string(self).unwrap().as_bytes());
383        format!("{:0>22}", base62::encode(hasher.finish_128()))
384    }
385}
386
387/// A validated OpenRouter Agent with its computed content-addressed ID.
388#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
389#[schemars(rename = "agent.openrouter.Agent")]
390pub struct Agent {
391    /// The deterministic content-addressed ID (22-character base62 string).
392    pub id: String,
393    /// The normalized configuration.
394    #[serde(flatten)]
395    pub base: AgentBase,
396}
397
398impl TryFrom<AgentBase> for Agent {
399    type Error = String;
400    fn try_from(mut base: AgentBase) -> Result<Self, Self::Error> {
401        base.prepare();
402        base.validate()?;
403        let id = base.id();
404        Ok(Agent { id, base })
405    }
406}