From 584617229c56f88cd33ae23ce36aa964d21b28b5 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Fri, 15 May 2026 10:45:57 -0400 Subject: [PATCH] fix(agent): fix OpenAI-compat request body serialization and max_tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues prevented sprout-agent from working with newer OpenAI models (e.g. gpt-5.5): 1. max_tokens → max_completion_tokens: Newer OpenAI models reject the legacy 'max_tokens' parameter and require 'max_completion_tokens'. 2. Request body dropped by reqwest 0.13: The post() helper used .json(body) which sets Content-Type AND serializes the body, but callers then added .header("content-type", "application/json") via the closure. This caused reqwest to silently drop the body, resulting in OpenAI returning 'you must provide a model parameter'. Fix: Switch post() to manual serialization via serde_json::to_vec() + .body() + explicit Content-Type header, removing duplicate headers from all callers. --- crates/sprout-agent/src/llm.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/sprout-agent/src/llm.rs b/crates/sprout-agent/src/llm.rs index 82b0c803f..9760dd049 100644 --- a/crates/sprout-agent/src/llm.rs +++ b/crates/sprout-agent/src/llm.rs @@ -34,7 +34,6 @@ impl Llm { let v = post(&self.http, &url, &body, |r| { r.header("x-api-key", &cfg.api_key) .header("anthropic-version", &cfg.anthropic_api_version) - .header("content-type", "application/json") }) .await?; parse_anthropic(v) @@ -42,11 +41,7 @@ impl Llm { Provider::OpenAi => { let body = openai_body(cfg, history, tools); let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); - let v = post(&self.http, &url, &body, |r| { - r.bearer_auth(&cfg.api_key) - .header("content-type", "application/json") - }) - .await?; + let v = post(&self.http, &url, &body, |r| r.bearer_auth(&cfg.api_key)).await?; parse_openai(v) } } @@ -74,7 +69,6 @@ impl Llm { let v = post(&self.http, &url, &body, |r| { r.header("x-api-key", &cfg.api_key) .header("anthropic-version", &cfg.anthropic_api_version) - .header("content-type", "application/json") }) .await?; Ok(parse_anthropic(v)?.text) @@ -83,18 +77,14 @@ impl Llm { let body = json!({ "model": cfg.model, "stream": false, - "max_tokens": max_output_tokens, + "max_completion_tokens": max_output_tokens, "messages": [ { "role": "system", "content": system_prompt }, { "role": "user", "content": user_prompt }, ], }); let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); - let v = post(&self.http, &url, &body, |r| { - r.bearer_auth(&cfg.api_key) - .header("content-type", "application/json") - }) - .await?; + let v = post(&self.http, &url, &body, |r| r.bearer_auth(&cfg.api_key)).await?; Ok(parse_openai(v)?.text) } } @@ -193,8 +183,8 @@ fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Valu "parameters": t.input_schema } }) }) .collect(); - let mut body = json!({ "model": cfg.model, "stream": false, "max_tokens": cfg.max_output_tokens, - "messages": messages }); + let mut body = json!({ "model": cfg.model, "stream": false, + "max_completion_tokens": cfg.max_output_tokens, "messages": messages }); if !tools_json.is_empty() { body["tools"] = Value::Array(tools_json); body["tool_choice"] = json!("auto"); @@ -338,8 +328,17 @@ async fn post(http: &Client, url: &str, body: &Value, apply: F) -> Result reqwest::RequestBuilder, { + let body_bytes = + serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; for attempt in 0..MAX_RETRIES { - let resp = match apply(http.post(url).json(body)).send().await { + let resp = match apply( + http.post(url) + .header("content-type", "application/json") + .body(body_bytes.clone()), + ) + .send() + .await + { Ok(r) => r, Err(e) => { if attempt + 1 < MAX_RETRIES && (e.is_timeout() || e.is_connect()) {