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