everruns_core/capabilities/
openrouter_server_tools.rs1use super::{Capability, CapabilityLocalization, CapabilityStatus, SystemPromptContext};
18use crate::capabilities::RiskLevel;
19use crate::driver_registry::{OpenRouterServerTool, OpenRouterServerToolKind};
20use async_trait::async_trait;
21use serde_json::{Value, json};
22
23pub const OPENROUTER_SERVER_TOOLS_CAPABILITY_ID: &str = "openrouter_server_tools";
25
26const TOOLS_KEY: &str = "tools";
28const WEB_SEARCH_MAX_RESULTS_KEY: &str = "web_search_max_results";
30
31pub struct OpenRouterServerToolsCapability;
36
37pub fn server_tools_from_config(config: &Value) -> Vec<OpenRouterServerTool> {
46 let Some(names) = config.get(TOOLS_KEY).and_then(Value::as_array) else {
47 return Vec::new();
48 };
49 let max_results = config
52 .get(WEB_SEARCH_MAX_RESULTS_KEY)
53 .and_then(Value::as_u64)
54 .filter(|n| *n >= 1);
55
56 let mut seen: Vec<OpenRouterServerToolKind> = Vec::new();
57 let mut tools: Vec<OpenRouterServerTool> = Vec::new();
58 for name in names.iter().filter_map(Value::as_str) {
59 let Some(kind) = OpenRouterServerToolKind::from_name(name) else {
60 continue;
61 };
62 if seen.contains(&kind) {
63 continue;
64 }
65 seen.push(kind);
66
67 let parameters = match (kind, max_results) {
69 (OpenRouterServerToolKind::WebSearch, Some(max)) => Some(json!({ "max_results": max })),
70 _ => None,
71 };
72 tools.push(OpenRouterServerTool { kind, parameters });
73 }
74 tools
75}
76
77impl OpenRouterServerToolsCapability {
78 fn tool_one_of() -> Vec<Value> {
82 OpenRouterServerToolKind::ALL
83 .iter()
84 .map(|kind| json!({ "const": kind.name(), "title": kind.display_name() }))
85 .collect()
86 }
87}
88
89#[async_trait]
90impl Capability for OpenRouterServerToolsCapability {
91 fn id(&self) -> &str {
92 OPENROUTER_SERVER_TOOLS_CAPABILITY_ID
93 }
94
95 fn name(&self) -> &str {
96 "OpenRouter Server Tools"
97 }
98
99 fn description(&self) -> &str {
100 "Enables OpenRouter's provider-executed server tools (web search, web \
101 fetch, datetime, image generation, and more). OpenRouter runs these \
102 server-side; non-OpenRouter providers ignore the setting."
103 }
104
105 fn localizations(&self) -> Vec<CapabilityLocalization> {
106 vec![
107 CapabilityLocalization {
108 locale: "en",
109 name: None,
110 description: None,
111 config_description: Some(
112 "Choose which OpenRouter server tools the model may invoke.",
113 ),
114 config_overlay: None,
115 },
116 CapabilityLocalization {
117 locale: "uk",
118 name: Some("Серверні інструменти OpenRouter"),
119 description: Some(
120 "Вмикає серверні інструменти OpenRouter (веб-пошук, веб-завантаження, дата й час, генерація зображень тощо), які виконуються на боці OpenRouter. Інші провайдери ігнорують це налаштування.",
121 ),
122 config_description: Some(
123 "Визначає, які серверні інструменти OpenRouter може викликати модель.",
124 ),
125 config_overlay: Some(json!({
126 "properties": {
127 TOOLS_KEY: {
128 "title": "Увімкнені серверні інструменти",
129 "description": "Серверні інструменти OpenRouter, які може викликати модель.",
130 "items": {
131 "title": "Серверний інструмент",
132 "enum_labels": {
133 "web_search": "Веб-пошук",
134 "web_fetch": "Веб-завантаження",
135 "datetime": "Дата й час",
136 "image_generation": "Генерація зображень",
137 "apply_patch": "Застосування патчів",
138 "fusion": "Fusion",
139 "advisor": "Порадник",
140 "subagent": "Субагент",
141 },
142 },
143 },
144 WEB_SEARCH_MAX_RESULTS_KEY: {
145 "title": "Максимум результатів веб-пошуку",
146 "description": "Максимальна кількість результатів для інструмента веб-пошуку.",
147 },
148 },
149 })),
150 },
151 ]
152 }
153
154 fn status(&self) -> CapabilityStatus {
155 CapabilityStatus::Available
156 }
157
158 fn category(&self) -> Option<&str> {
159 Some("Tools")
160 }
161
162 fn risk_level(&self) -> RiskLevel {
168 RiskLevel::High
169 }
170
171 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
172 None
173 }
174
175 fn config_schema(&self) -> Option<Value> {
176 Some(json!({
177 "type": "object",
178 "properties": {
179 TOOLS_KEY: {
180 "type": "array",
181 "title": "Enabled server tools",
182 "description": "OpenRouter server tools the model may invoke.",
183 "items": {
184 "type": "string",
185 "title": "Server tool",
186 "oneOf": Self::tool_one_of(),
187 },
188 "uniqueItems": true,
189 },
190 WEB_SEARCH_MAX_RESULTS_KEY: {
191 "type": "integer",
192 "title": "Web search max results",
193 "description": "Maximum results for the web_search server tool.",
194 "minimum": 1,
195 },
196 },
197 "additionalProperties": false,
198 }))
199 }
200
201 fn config_ui_schema(&self) -> Option<Value> {
202 Some(json!({
203 "ui:order": [TOOLS_KEY, WEB_SEARCH_MAX_RESULTS_KEY],
206 TOOLS_KEY: {
207 "ui:widget": "checkboxes",
208 },
209 }))
210 }
211
212 fn validate_config(&self, config: &Value) -> Result<(), String> {
213 if config.is_null() {
214 return Ok(());
215 }
216 let obj = config
217 .as_object()
218 .ok_or_else(|| "config must be an object".to_string())?;
219
220 for key in obj.keys() {
223 if key != TOOLS_KEY && key != WEB_SEARCH_MAX_RESULTS_KEY {
224 return Err(format!("unknown config key: {key}"));
225 }
226 }
227
228 if let Some(tools) = obj.get(TOOLS_KEY) {
229 let arr = tools
230 .as_array()
231 .ok_or_else(|| format!("`{TOOLS_KEY}` must be an array of tool names"))?;
232 for entry in arr {
233 let name = entry
234 .as_str()
235 .ok_or_else(|| format!("`{TOOLS_KEY}` entries must be strings"))?;
236 if OpenRouterServerToolKind::from_name(name).is_none() {
237 return Err(format!("unknown OpenRouter server tool: {name}"));
238 }
239 }
240 }
241
242 if let Some(max) = obj.get(WEB_SEARCH_MAX_RESULTS_KEY)
243 && !matches!(max.as_u64(), Some(n) if n >= 1)
244 {
245 return Err(format!(
246 "`{WEB_SEARCH_MAX_RESULTS_KEY}` must be a positive integer"
247 ));
248 }
249
250 Ok(())
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
261 fn schema_lists_every_tool_with_a_title() {
262 let schema = OpenRouterServerToolsCapability.config_schema().unwrap();
263 let one_of = schema["properties"][TOOLS_KEY]["items"]["oneOf"]
264 .as_array()
265 .expect("tools render as a oneOf of labeled consts");
266 assert_eq!(one_of.len(), OpenRouterServerToolKind::ALL.len());
267 for entry in one_of {
268 assert!(entry["const"].is_string());
269 assert!(
270 entry["title"].as_str().is_some_and(|t| !t.is_empty()),
271 "every server tool needs a UI title: {entry}"
272 );
273 }
274 }
275
276 #[test]
277 fn localization_resolves_ukrainian() {
278 let cap = OpenRouterServerToolsCapability;
279 assert_eq!(
280 cap.localized_name(Some("uk")),
281 "Серверні інструменти OpenRouter"
282 );
283 assert!(cap.describe_schema(Some("en")).is_some());
285 assert!(cap.describe_schema(Some("uk")).is_some());
286 assert_ne!(
287 cap.describe_schema(Some("en")),
288 cap.describe_schema(Some("uk"))
289 );
290 }
291
292 #[test]
293 fn ukrainian_overlay_labels_every_tool() {
294 let loc = OpenRouterServerToolsCapability
296 .localizations()
297 .into_iter()
298 .find(|l| l.locale == "uk")
299 .expect("uk localization present");
300 let overlay = loc.config_overlay.expect("uk overlay present");
301 let labels = &overlay["properties"][TOOLS_KEY]["items"]["enum_labels"];
302 for kind in OpenRouterServerToolKind::ALL {
303 assert!(
304 labels[kind.name()].as_str().is_some_and(|s| !s.is_empty()),
305 "missing uk enum_label for {}",
306 kind.name()
307 );
308 }
309 }
310
311 #[test]
312 fn empty_config_yields_no_server_tools() {
313 assert!(server_tools_from_config(&json!({})).is_empty());
314 assert!(server_tools_from_config(&Value::Null).is_empty());
315 assert!(server_tools_from_config(&json!({ "tools": [] })).is_empty());
316 }
317
318 #[test]
319 fn maps_known_tools_and_skips_unknown() {
320 let tools = server_tools_from_config(&json!({
321 "tools": ["web_fetch", "datetime", "not_a_real_tool"],
322 }));
323 let kinds: Vec<_> = tools.iter().map(|t| t.kind).collect();
324 assert_eq!(
325 kinds,
326 vec![
327 OpenRouterServerToolKind::WebFetch,
328 OpenRouterServerToolKind::Datetime
329 ]
330 );
331 assert!(tools.iter().all(|t| t.parameters.is_none()));
332 }
333
334 #[test]
335 fn web_search_attaches_max_results() {
336 let tools = server_tools_from_config(&json!({
337 "tools": ["web_search", "datetime"],
338 "web_search_max_results": 5,
339 }));
340 let web = tools
341 .iter()
342 .find(|t| t.kind == OpenRouterServerToolKind::WebSearch)
343 .expect("web_search present");
344 assert_eq!(web.parameters, Some(json!({ "max_results": 5 })));
345 let datetime = tools
347 .iter()
348 .find(|t| t.kind == OpenRouterServerToolKind::Datetime)
349 .expect("datetime present");
350 assert!(datetime.parameters.is_none());
351 }
352
353 #[test]
354 fn stale_zero_max_results_is_not_forwarded() {
355 let tools = server_tools_from_config(&json!({
357 "tools": ["web_search"],
358 "web_search_max_results": 0,
359 }));
360 let web = tools
361 .iter()
362 .find(|t| t.kind == OpenRouterServerToolKind::WebSearch)
363 .expect("web_search present");
364 assert!(web.parameters.is_none());
365 }
366
367 #[test]
368 fn duplicate_tool_names_are_deduped() {
369 let tools = server_tools_from_config(&json!({
370 "tools": ["datetime", "datetime"],
371 }));
372 assert_eq!(tools.len(), 1);
373 }
374
375 #[test]
376 fn validate_rejects_unknown_tool_and_bad_max_results() {
377 let cap = OpenRouterServerToolsCapability;
378 assert!(
379 cap.validate_config(&json!({ "tools": ["web_search"] }))
380 .is_ok()
381 );
382 assert!(cap.validate_config(&json!({ "tools": ["bogus"] })).is_err());
383 assert!(
384 cap.validate_config(&json!({ "tools": ["web_search"], "web_search_max_results": 0 }))
385 .is_err()
386 );
387 assert!(
389 cap.validate_config(&json!({ "tools": ["web_search"], "extra": true }))
390 .is_err()
391 );
392 assert!(
393 cap.validate_config(&json!({ "web_search_max_results": 3 }))
394 .is_ok()
395 );
396 assert!(cap.validate_config(&Value::Null).is_ok());
397 }
398}