objectiveai_sdk/agent/openrouter/
agent.rs1use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use twox_hash::XxHash3_128;
7
8#[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 pub upstream: super::Upstream,
23
24 pub model: String,
26
27 #[serde(default)]
29 pub output_mode: super::OutputMode,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
35 #[schemars(extend("omitempty" = true))]
36 pub synthetic_reasoning: Option<bool>,
37
38 #[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 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
66 #[schemars(extend("omitempty" = true))]
67 pub mcp_servers: Option<super::super::McpServers>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
73 #[schemars(extend("omitempty" = true))]
74 pub client_objectiveai_mcp: Option<super::super::ClientObjectiveaiMcp>,
75
76 #[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 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
99 #[schemars(extend("omitempty" = true))]
100 pub stop: Option<super::Stop>,
101 #[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 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
125 #[schemars(extend("omitempty" = true))]
126 pub provider: Option<super::Provider>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 #[schemars(extend("omitempty" = true))]
130 pub reasoning: Option<super::Reasoning>,
131 #[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 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
148 #[schemars(extend("omitempty" = true))]
149 pub verbosity: Option<super::Verbosity>,
150 #[serde(skip_serializing_if = "Option::is_none")]
154 #[schemars(extend("omitempty" = true))]
155 pub context_compression: Option<super::ContextCompression>,
156}
157
158impl AgentBase {
159 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 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 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
462#[schemars(rename = "agent.openrouter.Agent")]
463pub struct Agent {
464 pub id: String,
466 #[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}