feat(buzz-agent): send User-Agent on LLM requests (#8116)

## Summary

`buzz-agent` builds its LLM `reqwest::Client` without a User-Agent, and
reqwest sends none by default, so OpenAI-compatible providers that
identify clients by product token see buzz-agent as an anonymous caller.

This sets `User-Agent: buzz-agent/<crate version>` on the shared LLM
client in `crates/buzz-agent/src/llm.rs`. The version is
`CARGO_PKG_VERSION`, the same value `agentInfo` reports over ACP. No
request body or endpoint changes.

Open point: the crate version is the workspace version (`0.1.0`), so
every build reports the same version. A per-build version would need the
desktop to pass one through at spawn time; this PR does not do that.

### Related issue

None found.

### Testing

- The test stub now captures the `User-Agent` request header; new
`llm_requests_carry_buzz_agent_user_agent` asserts
`buzz-agent/<version>` on a Chat Completions POST.
- Verified red without the `.user_agent(...)` line (`left: None`), green
with it.
- `cargo test -p buzz-agent --lib llm::tests`: 194 passed. `cargo fmt`
clean; the one clippy warning is pre-existing (`hints.rs:33`).
- Not exercised in a built desktop app.

Signed-off-by: Tom Bedor <tbedor@squareup.com>
Co-authored-by: Tom Bedor <tbedor@squareup.com>
This commit is contained in:
tbedor-square2
2026-10-06 10:07:15 -07:00
committed by GitHub
parent af5bb0af48
commit 1e2281fd45

View File

@@ -59,9 +59,16 @@ pub struct Llm {
/// network/reachability problem, not a slow generation.
const LLM_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// `User-Agent` sent on every LLM request. reqwest sends no User-Agent by
/// default, so OpenAI-compatible providers that identify clients by product
/// token saw buzz-agent as an anonymous caller. The version is the crate
/// version, matching the `agentInfo` reported over ACP.
const LLM_USER_AGENT: &str = concat!("buzz-agent/", env!("CARGO_PKG_VERSION"));
impl Llm {
pub fn new(cfg: &Config) -> Result<Self, AgentError> {
let http = Client::builder()
.user_agent(LLM_USER_AGENT)
.connect_timeout(LLM_CONNECT_TIMEOUT)
// No client-level read_timeout: we apply a per-request total
// timeout via RequestBuilder::timeout() so that escalated budgets
@@ -2654,6 +2661,7 @@ mod tests {
struct CapturedHttpRequest {
method: String,
path: String,
user_agent: Option<String>,
body: Option<Value>,
}
@@ -2728,6 +2736,11 @@ mod tests {
Ok(read) => bytes.extend_from_slice(&chunk[..read]),
}
}
let user_agent = header_text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("user-agent")
.then(|| value.trim().to_string())
});
let mut request_line = header_text.lines().next().unwrap_or_default().split(' ');
let method = request_line.next().unwrap_or_default().to_string();
let path = request_line.next().unwrap_or_default().to_string();
@@ -2736,10 +2749,12 @@ mod tests {
} else {
serde_json::from_slice(&bytes[header_end..header_end + content_length]).ok()
};
captured_for_server
.lock()
.await
.push(CapturedHttpRequest { method, path, body });
captured_for_server.lock().await.push(CapturedHttpRequest {
method,
path,
user_agent,
body,
});
let response = responses.lock().await.pop_front().unwrap_or_else(|| {
StubHttpResponse::error(500, "stub response sequence exhausted")
@@ -2800,6 +2815,23 @@ mod tests {
.collect()
}
/// Every LLM request identifies the client to the provider via the
/// first User-Agent product token.
#[tokio::test]
async fn llm_requests_carry_buzz_agent_user_agent() {
let (base_url, captured) =
spawn_sequence_stub(vec![StubHttpResponse::ok(chat_response("ok"))]).await;
let mut config = cfg(Provider::OpenAi);
config.base_url = base_url;
let llm = Llm::new(&config).unwrap();
complete_model(&llm, &config, "gpt-test").await.unwrap();
let requests = captured.lock().await;
let expected = format!("buzz-agent/{}", env!("CARGO_PKG_VERSION"));
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].user_agent.as_deref(), Some(expected.as_str()));
}
/// An explicit model is sent verbatim and never rewritten to something
/// else. A server error is retried under the *same* model (the ordinary
/// transport retry) and then surfaced -- there is no second model to fall