Skip to main content

everruns_core/capabilities/
openrouter_server_tools.rs

1// OpenRouter Server Tools Capability
2//
3// When added to an agent, enables OpenRouter's provider-executed "server tools"
4// (beta). Unlike normal function tools, these are run by OpenRouter server-side
5// — it loops internally and returns the final answer, so the agent loop never
6// dispatches them. This capability therefore contributes *request intent*, not
7// executable tools: the selected tools are compiled into
8// `LlmCallConfig.openrouter_routing.server_tools` and serialized by the
9// OpenRouter driver into the request `tools` array as `{"type":"openrouter:…"}`.
10//
11// Non-OpenRouter providers ignore the routing config entirely, so enabling this
12// capability on a non-OpenRouter agent is a harmless no-op.
13//
14// See specs/llm-drivers.md ("OpenRouter Server Tools") and
15// https://openrouter.ai/docs/guides/features/server-tools.
16
17use 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
23/// Capability ID for OpenRouter server tools.
24pub const OPENROUTER_SERVER_TOOLS_CAPABILITY_ID: &str = "openrouter_server_tools";
25
26/// Config key holding the list of enabled server-tool names.
27const TOOLS_KEY: &str = "tools";
28/// Config key holding the optional web-search `max_results` override.
29const WEB_SEARCH_MAX_RESULTS_KEY: &str = "web_search_max_results";
30
31/// OpenRouter server tools capability.
32///
33/// Drivers translate the collected `server_tools` into provider-executed tool
34/// entries when the resolved provider is OpenRouter; other providers ignore it.
35pub struct OpenRouterServerToolsCapability;
36
37/// Compile the per-agent config into the list of activated server tools.
38///
39/// This is the read path; it is defensive about malformed input. The write
40/// path's `validate_config` already rejects unknown tool names and a
41/// non-positive `web_search_max_results`, so a normally-validated config never
42/// hits these guards — they only matter for legacy or hand-edited/corrupt
43/// configs. Unknown tool names are dropped, `max_results` is forwarded only when
44/// `>= 1`, and tools are de-duplicated by kind in config order.
45pub 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    // Enforce the schema's `>= 1` constraint here too: a stale `0` must not be
50    // forwarded as an invalid OpenRouter `max_results`.
51    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        // web_search is the only server tool that takes parameters today.
68        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    /// `oneOf` entries (`{const, title}`) for the server-tool enum, so the UI
79    /// renders a readable label per tool and `enum_labels` overlays can localize
80    /// each one. Title source of truth is `OpenRouterServerToolKind::display_name`.
81    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    /// THREAT[TM-AGENT-026]: enabling server tools grants the model
163    /// provider-executed web reach (`web_search`/`web_fetch`). OpenRouter runs
164    /// these, so Everruns' egress controls (TM-AGENT-018) do not apply — same
165    /// exfil class as web_fetch (TM-AGENT-013). High so assignment uses the
166    /// same admin-only trust gate as other outbound web egress capabilities.
167    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            // Render the tool list as a multi-select of checkboxes, and show it
204            // before the web-search-only numeric option.
205            "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        // Honor the schema's `additionalProperties: false` — server write paths
221        // gate on this method, not on JSON-schema validation.
222        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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
259
260    #[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        // config_description has an `en` base and a `uk` override.
284        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        // Drift guard: adding a server tool must also add its localized label.
295        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        // max_results only decorates web_search.
346        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        // A legacy/corrupt `0` must not become an invalid OpenRouter request.
356        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        // additionalProperties: false — unknown keys are rejected.
388        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}