Skip to content

Commit 122bd2a

Browse files
committed
fix: surface hosted web tools
1 parent 47fbe34 commit 122bd2a

38 files changed

Lines changed: 1328 additions & 86 deletions

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ members = [
77
"crates/safety",
88
"crates/tasks",
99
"crates/mcp",
10+
"crates/network-proxy",
1011
"crates/protocol",
1112
"crates/client",
1213
"crates/config",
@@ -58,6 +59,7 @@ devo-core = { path = "crates/core" }
5859
devo-file-search = { path = "crates/file-search" }
5960
devo-keyring-store = { path = "crates/keyring-store" }
6061
devo-mcp = { path = "crates/mcp" }
62+
devo-network-proxy = { path = "crates/network-proxy" }
6163
devo-protocol = { path = "crates/protocol" }
6264
devo-provider = { path = "crates/provider" }
6365
devo-rmcp-client = { path = "crates/rmcp-client" }
@@ -103,6 +105,7 @@ regex-lite = "0.1.8"
103105
reqwest = { version = "0.12", default-features = false, features = [
104106
"json",
105107
"rustls-tls",
108+
"socks",
106109
"stream",
107110
] }
108111
reqwest-eventsource = "0.6"

crates/cli/src/prompt_command.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub(crate) async fn run_prompt(
8282
collaboration_mode: devo_protocol::CollaborationMode::Build,
8383
agent_coordinator: None,
8484
local_web_search: None,
85+
network_proxy: None,
8586
},
8687
);
8788
let model_catalog = PresetModelCatalog::load_from_config(&home_dir, Some(&cwd))?;

crates/config/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,26 @@ the provider default endpoint and result count. Compatibility aliases
275275
`websearch` and `web-search` route to `web_search`, but aliases are not exposed
276276
to the model.
277277

278+
## Web Fetch
279+
280+
`[tools.web_fetch]` controls whether a turn exposes URL fetching to the model.
281+
It resolves with the same priority as web search:
282+
283+
1. `[model_bindings.<id>.web_fetch]`
284+
2. `[providers.<id>.web_fetch]`
285+
3. `[tools.web_fetch]`
286+
287+
Supported modes:
288+
289+
- `disabled`: do not provide provider-hosted web fetch and do not expose the
290+
local `webfetch` function tool.
291+
- `provider`: let the active provider adapter inject provider-hosted fetch into
292+
the request. OpenAI Responses uses hosted tool `{"type":"web_fetch"}`;
293+
OpenAI Chat Completions uses `web_fetch_options`; Anthropic Messages uses
294+
server tool `{"type":"web_fetch_20250910","name":"web_fetch"}`.
295+
- `local`: expose the existing local `webfetch` function tool. This is the
296+
default to preserve the existing local fetch behavior.
297+
278298
## Provider Resolution
279299

280300
`resolve_provider_settings_from_config_and_auth` chooses the active model

crates/config/src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ impl AppConfigStore {
286286
.as_deref()
287287
.and_then(non_empty_string),
288288
web_search: None,
289+
web_fetch: None,
289290
enabled: binding.enabled,
290291
},
291292
);

crates/config/src/provider/schema.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use devo_protocol::ProviderWireApi;
44
use serde::Deserialize;
55
use serde::Serialize;
66

7+
use crate::WebFetchConfig;
78
use crate::WebSearchConfig;
89

910
pub(crate) const AUTH_CONFIG_VERSION: u32 = 1;
@@ -58,6 +59,8 @@ pub struct ProviderVendorConfig {
5859
pub wire_apis: Vec<ProviderWireApi>,
5960
#[serde(skip_serializing_if = "Option::is_none")]
6061
pub web_search: Option<WebSearchConfig>,
62+
#[serde(skip_serializing_if = "Option::is_none")]
63+
pub web_fetch: Option<WebFetchConfig>,
6164
#[serde(default = "default_true")]
6265
pub enabled: bool,
6366
}
@@ -71,6 +74,7 @@ impl ProviderVendorConfig {
7174
&& self.headers.is_none()
7275
&& self.wire_apis.is_empty()
7376
&& self.web_search.is_none()
77+
&& self.web_fetch.is_none()
7478
&& self.enabled
7579
}
7680
}
@@ -92,6 +96,8 @@ pub struct ModelBindingConfig {
9296
pub default_reasoning_effort: Option<String>,
9397
#[serde(skip_serializing_if = "Option::is_none")]
9498
pub web_search: Option<WebSearchConfig>,
99+
#[serde(skip_serializing_if = "Option::is_none")]
100+
pub web_fetch: Option<WebFetchConfig>,
95101
#[serde(default = "default_true")]
96102
pub enabled: bool,
97103
}
@@ -106,6 +112,7 @@ impl Default for ModelBindingConfig {
106112
invocation_method: default_provider_wire_api(),
107113
default_reasoning_effort: None,
108114
web_search: None,
115+
web_fetch: None,
109116
enabled: true,
110117
}
111118
}
@@ -251,6 +258,9 @@ impl ProviderConfigSection {
251258
if overlay_provider.web_search.is_some() {
252259
provider.web_search = overlay_provider.web_search;
253260
}
261+
if overlay_provider.web_fetch.is_some() {
262+
provider.web_fetch = overlay_provider.web_fetch;
263+
}
254264
provider.enabled = overlay_provider.enabled;
255265
}
256266
for (binding_id, overlay_binding) in overlay.model_bindings {
@@ -274,6 +284,9 @@ impl ProviderConfigSection {
274284
if overlay_binding.web_search.is_some() {
275285
binding.web_search = overlay_binding.web_search;
276286
}
287+
if overlay_binding.web_fetch.is_some() {
288+
binding.web_fetch = overlay_binding.web_fetch;
289+
}
277290
binding.enabled = overlay_binding.enabled;
278291
}
279292
}

crates/config/src/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ invocation_method = "openai_responses"
297297
headers: Some(r#"{"X-User":"yes"}"#.to_string()),
298298
wire_apis: vec![ProviderWireApi::OpenAIResponses],
299299
web_search: None,
300+
web_fetch: None,
300301
enabled: true,
301302
},
302303
)]),

crates/config/src/tools.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ use crate::UserAuthConfigFile;
1212
pub struct ToolsConfig {
1313
#[serde(default, skip_serializing_if = "WebSearchConfig::is_default")]
1414
pub web_search: WebSearchConfig,
15+
#[serde(default, skip_serializing_if = "WebFetchConfig::is_default")]
16+
pub web_fetch: WebFetchConfig,
1517
}
1618

1719
impl ToolsConfig {
1820
pub fn is_empty(&self) -> bool {
19-
self.web_search.is_default()
21+
self.web_search.is_default() && self.web_fetch.is_default()
2022
}
2123
}
2224

@@ -49,6 +51,29 @@ impl WebSearchConfig {
4951
}
5052
}
5153

54+
/// Selects how Devo exposes web fetch for a provider/model.
55+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
56+
#[serde(rename_all = "snake_case")]
57+
pub enum WebFetchMode {
58+
Disabled,
59+
Provider,
60+
#[default]
61+
Local,
62+
}
63+
64+
/// Configures the effective `web_fetch` capability.
65+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
66+
pub struct WebFetchConfig {
67+
#[serde(default, skip_serializing_if = "is_default_web_fetch_mode")]
68+
pub mode: WebFetchMode,
69+
}
70+
71+
impl WebFetchConfig {
72+
pub fn is_default(&self) -> bool {
73+
self.mode == WebFetchMode::Local
74+
}
75+
}
76+
5277
/// Local third-party search provider configuration.
5378
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5479
pub struct LocalWebSearchProviderConfig {
@@ -88,6 +113,26 @@ impl ResolvedWebSearchConfig {
88113
}
89114
}
90115

116+
/// Fully resolved web fetch behavior for one model invocation.
117+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
118+
#[serde(tag = "mode", rename_all = "snake_case")]
119+
pub enum ResolvedWebFetchConfig {
120+
Disabled,
121+
Provider,
122+
#[default]
123+
Local,
124+
}
125+
126+
impl ResolvedWebFetchConfig {
127+
pub fn is_local(self) -> bool {
128+
matches!(self, Self::Local)
129+
}
130+
131+
pub fn is_provider(self) -> bool {
132+
matches!(self, Self::Provider)
133+
}
134+
}
135+
91136
/// Local search service plus user-scoped API key for one turn.
92137
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93138
pub struct ResolvedLocalWebSearchConfig {
@@ -114,6 +159,19 @@ pub fn resolve_web_search_config(
114159
}
115160
}
116161

162+
pub fn resolve_web_fetch_config(
163+
global: &WebFetchConfig,
164+
provider_override: Option<&WebFetchConfig>,
165+
binding_override: Option<&WebFetchConfig>,
166+
) -> ResolvedWebFetchConfig {
167+
let effective = binding_override.or(provider_override).unwrap_or(global);
168+
match effective.mode {
169+
WebFetchMode::Disabled => ResolvedWebFetchConfig::Disabled,
170+
WebFetchMode::Provider => ResolvedWebFetchConfig::Provider,
171+
WebFetchMode::Local => ResolvedWebFetchConfig::Local,
172+
}
173+
}
174+
117175
fn resolve_local_web_search(
118176
global: &WebSearchConfig,
119177
effective: &WebSearchConfig,
@@ -167,6 +225,10 @@ fn is_default_web_search_mode(mode: &WebSearchMode) -> bool {
167225
*mode == WebSearchMode::Provider
168226
}
169227

228+
fn is_default_web_fetch_mode(mode: &WebFetchMode) -> bool {
229+
*mode == WebFetchMode::Local
230+
}
231+
170232
#[cfg(test)]
171233
mod tests {
172234
use std::collections::BTreeMap;

crates/core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ version.workspace = true
88
[dependencies]
99
devo-config = { workspace = true }
1010
devo-code-search = { workspace = true }
11+
devo-network-proxy = { workspace = true }
1112
devo-protocol = { workspace = true }
1213
devo-provider = { workspace = true }
1314
devo-safety = { workspace = true }

0 commit comments

Comments
 (0)