Skip to content

Commit fe6cf8f

Browse files
committed
feat: add model.extra_body support for chat completion requests
1 parent fe16889 commit fe6cf8f

3 files changed

Lines changed: 141 additions & 3 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,22 @@ url = "https://generativelanguage.googleapis.com/v1beta/openai"
198198
api_key_env = "GEMINI_API_KEY"
199199
```
200200

201+
Provider-specific request fields can be added under `model.extra_body`. These are
202+
merged into the top-level chat-completions JSON body after `ut` assembles its
203+
standard `model`, `messages`, and `temperature` fields. Reserved keys cannot be
204+
overridden.
205+
206+
For example, with `llama-server`:
207+
208+
```toml
209+
[model]
210+
model = "meta-llama/Llama-3.3-70B-Instruct"
211+
url = "http://127.0.0.1:8080/v1"
212+
213+
[model.extra_body]
214+
thinking_budget_tokens = 1024
215+
```
216+
201217
## Paste Safety
202218

203219
`ut` captures Sway context at:

src/config.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
use crate::context::AppContext;
22
use anyhow::{Context, Result};
33
use serde::{Deserialize, Serialize};
4+
use serde_json::{Map, Value};
45
use std::collections::BTreeMap;
56
use std::env;
67
use std::fs;
78
use std::path::PathBuf;
89

10+
pub const RESERVED_CHAT_COMPLETION_BODY_KEYS: &[&str] = &["model", "messages", "temperature"];
11+
912
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1013
#[serde(default)]
1114
pub struct Config {
@@ -48,6 +51,7 @@ impl Config {
4851
if self.model.timeout_seconds == 0 {
4952
anyhow::bail!("model.timeout_seconds must be greater than 0");
5053
}
54+
self.model.validate_extra_body()?;
5155
if self.status_ui.width == 0 {
5256
anyhow::bail!("status_ui.width must be greater than 0");
5357
}
@@ -123,6 +127,7 @@ pub struct ModelConfig {
123127
pub timeout_seconds: u64,
124128
pub api_key: Option<String>,
125129
pub api_key_env: Option<String>,
130+
pub extra_body: Map<String, Value>,
126131
}
127132

128133
impl Default for ModelConfig {
@@ -133,6 +138,7 @@ impl Default for ModelConfig {
133138
timeout_seconds: 60,
134139
api_key: None,
135140
api_key_env: None,
141+
extra_body: Map::new(),
136142
}
137143
}
138144
}
@@ -150,6 +156,20 @@ impl ModelConfig {
150156
.filter(|value| !value.is_empty())
151157
})
152158
}
159+
160+
pub fn validate_extra_body(&self) -> Result<()> {
161+
if let Some(key) = self
162+
.extra_body
163+
.keys()
164+
.find(|key| RESERVED_CHAT_COMPLETION_BODY_KEYS.contains(&key.as_str()))
165+
{
166+
anyhow::bail!(
167+
"model.extra_body must not override reserved chat-completion field {key:?}"
168+
);
169+
}
170+
171+
Ok(())
172+
}
153173
}
154174

155175
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -280,6 +300,7 @@ mod tests {
280300
assert_eq!(config.model.url, "http://127.0.0.1:11434/v1");
281301
assert_eq!(config.model.api_key, None);
282302
assert_eq!(config.model.api_key_env, None);
303+
assert!(config.model.extra_body.is_empty());
283304
}
284305

285306
#[test]
@@ -322,6 +343,39 @@ mod tests {
322343
assert!(config.validate().is_err());
323344
}
324345

346+
#[test]
347+
fn validate_rejects_reserved_model_extra_body_key() {
348+
let mut config = Config::default();
349+
config.model.extra_body.insert(
350+
"messages".to_string(),
351+
Value::String("not allowed".to_string()),
352+
);
353+
354+
let error = config.validate().expect_err("config should be rejected");
355+
assert!(error
356+
.to_string()
357+
.contains("model.extra_body must not override reserved chat-completion field"));
358+
}
359+
360+
#[test]
361+
fn parses_model_extra_body_table() {
362+
let config: Config = toml::from_str(
363+
r#"
364+
[model]
365+
model = "llama"
366+
367+
[model.extra_body]
368+
thinking_budget_tokens = 1024
369+
"#,
370+
)
371+
.expect("config should parse");
372+
373+
assert_eq!(
374+
config.model.extra_body.get("thinking_budget_tokens"),
375+
Some(&Value::from(1024))
376+
);
377+
}
378+
325379
#[test]
326380
fn validate_rejects_zero_status_ui_dimensions() {
327381
let mut config = Config::default();

src/dictation.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use crate::audio::{encode_wav_bytes, AudioPayload};
2-
use crate::config::ModelConfig;
2+
use crate::config::{ModelConfig, RESERVED_CHAT_COMPLETION_BODY_KEYS};
33
use anyhow::{Context, Result};
44
use async_trait::async_trait;
55
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
66
use reqwest::Client;
77
use serde::Deserialize;
88
use serde::Serialize;
9-
use serde_json::Value;
9+
use serde_json::{Map, Value};
1010
use std::time::Duration;
1111

1212
#[derive(Debug, Clone)]
@@ -57,7 +57,7 @@ impl HttpDictationClient {
5757
temperature: Some(0.0),
5858
};
5959

60-
let request_body = serde_json::to_vec(&body)?;
60+
let request_body = build_request_body(&body, &self.model_config.extra_body)?;
6161
let response = send_http_request(
6262
&self.endpoint,
6363
&request_body,
@@ -164,6 +164,34 @@ struct ChatMessageResponse {
164164
content: Option<Value>,
165165
}
166166

167+
fn build_request_body(
168+
body: &ChatCompletionRequest,
169+
extra_body: &Map<String, Value>,
170+
) -> Result<Vec<u8>> {
171+
let mut request_body = serde_json::to_value(body)?
172+
.as_object()
173+
.cloned()
174+
.context("chat completion request body must serialize as a JSON object")?;
175+
merge_extra_body(&mut request_body, extra_body)?;
176+
serde_json::to_vec(&request_body).context("failed to serialize chat completion request body")
177+
}
178+
179+
fn merge_extra_body(
180+
request_body: &mut Map<String, Value>,
181+
extra_body: &Map<String, Value>,
182+
) -> Result<()> {
183+
for (key, value) in extra_body {
184+
if RESERVED_CHAT_COMPLETION_BODY_KEYS.contains(&key.as_str()) {
185+
anyhow::bail!(
186+
"model.extra_body must not override reserved chat-completion field {key:?}"
187+
);
188+
}
189+
request_body.insert(key.clone(), value.clone());
190+
}
191+
192+
Ok(())
193+
}
194+
167195
fn extract_text(response: &ChatCompletionResponse) -> Result<String> {
168196
let choice = response
169197
.choices
@@ -315,4 +343,44 @@ mod tests {
315343
"https://api.openai.com/v1/chat/completions"
316344
);
317345
}
346+
347+
#[test]
348+
fn request_body_merges_model_extra_body() {
349+
let body = ChatCompletionRequest {
350+
model: "llama".to_string(),
351+
messages: vec![ChatMessage::system("prompt".to_string())],
352+
temperature: Some(0.0),
353+
};
354+
let mut extra_body = Map::new();
355+
extra_body.insert("thinking_budget_tokens".to_string(), Value::from(1024));
356+
357+
let request_body = build_request_body(&body, &extra_body).expect("request should build");
358+
let request_json: Value =
359+
serde_json::from_slice(&request_body).expect("request body should be valid json");
360+
361+
assert_eq!(
362+
request_json.get("model"),
363+
Some(&Value::String("llama".to_string()))
364+
);
365+
assert_eq!(
366+
request_json.get("thinking_budget_tokens"),
367+
Some(&Value::from(1024))
368+
);
369+
}
370+
371+
#[test]
372+
fn request_body_rejects_reserved_model_extra_body_keys() {
373+
let body = ChatCompletionRequest {
374+
model: "llama".to_string(),
375+
messages: vec![ChatMessage::system("prompt".to_string())],
376+
temperature: Some(0.0),
377+
};
378+
let mut extra_body = Map::new();
379+
extra_body.insert("messages".to_string(), Value::Array(Vec::new()));
380+
381+
let error = build_request_body(&body, &extra_body).expect_err("merge should fail");
382+
assert!(error
383+
.to_string()
384+
.contains("model.extra_body must not override reserved chat-completion field"));
385+
}
318386
}

0 commit comments

Comments
 (0)