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