11use crate :: context:: AppContext ;
22use anyhow:: { Context , Result } ;
33use serde:: { Deserialize , Serialize } ;
4+ use serde_json:: { Map , Value } ;
45use std:: collections:: BTreeMap ;
56use std:: env;
67use std:: fs;
78use 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 ) ]
1114pub 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
128133impl 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 ( ) ;
0 commit comments