From 3263d0c21e2bf3eba87b85ef93d482359417546e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 28 Feb 2026 01:14:04 -0500 Subject: [PATCH] Fix web_fetch summarizer routing to wrong LLM provider The WebFetchSummarizer was sending requests without specifying a provider, so they always routed to the default (Anthropic). When using the OpenAI or Gemini profile, the summarizer model (e.g. gpt-4o-mini) was rejected by Anthropic with a 404. Add a `provider` field to WebFetchSummarizer so the summarization request routes to the correct provider. Also improve the error message to include the model name, and relax the parity test assertion to accept summarized content. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/agent/src/cli.rs | 6 +++ crates/agent/src/tools.rs | 61 ++++++++++++++++++++++++++++- crates/agent/tests/parity_matrix.rs | 14 ++++--- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index 324bab607..2acab319a 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -150,9 +150,15 @@ fn build_summarizer(provider: &str, llm_client: Option) -> Option "claude-haiku-4-5-20251001", }; + let provider_name = match provider { + "openai" => Some("openai".to_string()), + "gemini" => Some("gemini".to_string()), + _ => None, + }; Some(crate::tools::WebFetchSummarizer { client, model: model.into(), + provider: provider_name, }) } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 10ba7570d..2886ea9ea 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -14,6 +14,7 @@ const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; pub struct WebFetchSummarizer { pub client: Client, pub model: String, + pub provider: Option, } /// Returns true if the input looks like it contains HTML markup. @@ -535,7 +536,7 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg let request = Request { model: s.model.clone(), messages: vec![Message::user(summarization_prompt)], - provider: None, + provider: s.provider.clone(), tools: None, tool_choice: None, response_format: None, @@ -547,7 +548,9 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg metadata: None, provider_options: None, }; - let response = s.client.complete(&request).await.map_err(|e| format!("Summarization failed: {e}"))?; + let response = s.client.complete(&request).await.map_err(|e| { + format!("web_fetch summarization (model={}) failed: {e}", s.model) + })?; Ok(response.text()) } (Some(_), None) => { @@ -982,6 +985,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, model: "mock-model".into(), + provider: None, }; let tool = make_web_fetch_tool(Some(summarizer)); @@ -1027,6 +1031,59 @@ mod tests { assert!(output.contains("Rust is a systems programming language"), "should contain page content, got: {output}"); } + #[tokio::test] + async fn web_fetch_summarizer_routes_to_specified_provider() { + use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; + use llm::error::{ProviderErrorDetail, ProviderErrorKind, SdkError}; + use llm::provider::ProviderAdapter; + + // "other_provider" is the default — it rejects all requests. + let default_provider: Arc = Arc::new( + MockErrorProvider { + error: SdkError::Provider { + kind: ProviderErrorKind::NotFound, + detail: Box::new(ProviderErrorDetail::new( + "model not found", "other_provider", + )), + }, + }, + ); + // "target_provider" has the model we actually want. + let target_provider: Arc = Arc::new( + MockLlmProvider::new(vec![text_response("summarized content")]), + ); + + let mut providers = HashMap::new(); + providers.insert("other_provider".to_string(), default_provider); + providers.insert(target_provider.name().to_string(), target_provider); + let client = Client::new(providers, Some("other_provider".into()), vec![]); + + let summarizer = WebFetchSummarizer { + client, + model: "target-model".into(), + provider: Some("mock".into()), + }; + + let tool = make_web_fetch_tool(Some(summarizer)); + let env: Arc = Arc::new(MockExecutionEnvironment { + exec_result: ExecResult { + stdout: "

Page content

".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, + duration_ms: 100, + }, + ..Default::default() + }); + let result = (tool.executor)( + serde_json::json!({"url": "https://example.com", "prompt": "Summarize this"}), + ToolContext { env, cancel: CancellationToken::new() }, + ) + .await; + let output = result.expect("summarization should succeed when provider is correctly routed"); + assert_eq!(output, "summarized content"); + } + #[test] fn html_to_markdown_converts_basic_html() { let result = html_to_markdown("

Hello

World

"); diff --git a/crates/agent/tests/parity_matrix.rs b/crates/agent/tests/parity_matrix.rs index f2d897a57..b0dbf640f 100644 --- a/crates/agent/tests/parity_matrix.rs +++ b/crates/agent/tests/parity_matrix.rs @@ -8,14 +8,15 @@ use agent::{ use llm::client::Client; fn build_summarizer(provider: &str, client: &Client) -> WebFetchSummarizer { - let summarizer_model = match provider { - "openai" => "gpt-4o-mini", - "gemini" => "gemini-2.0-flash", - _ => "claude-haiku-4-5-20251001", + let (summarizer_model, provider_name) = match provider { + "openai" => ("gpt-4o-mini", Some("openai".to_string())), + "gemini" => ("gemini-2.0-flash", Some("gemini".to_string())), + _ => ("claude-haiku-4-5-20251001", None), }; WebFetchSummarizer { client: client.clone(), model: summarizer_model.into(), + provider: provider_name, } } @@ -420,9 +421,10 @@ async fn scenario_web_fetch(session: &mut Session, dir: &Path) { let path = dir.join("fetched.txt"); assert!(path.exists(), "fetched.txt should have been created"); let content = std::fs::read_to_string(&path).expect("failed to read fetched.txt"); + let lower = content.to_lowercase(); assert!( - content.contains("Example Domain"), - "Expected 'Example Domain' in fetched content, got first 200 chars: {}", + lower.contains("example domain") || lower.contains("example.com"), + "Expected 'Example Domain' or 'example.com' in fetched content, got first 200 chars: {}", &content[..content.len().min(200)] );