@@ -5,10 +5,12 @@ use std::pin::Pin;
55use serde:: Serialize ;
66
77use crate :: error:: ApiError ;
8- use crate :: types:: { MessageRequest , MessageResponse } ;
8+ use crate :: types:: { MessageRequest , MessageResponse , ReasoningEffort } ;
9+ use crate :: providers:: reasoning:: reasoning_levels;
910
1011pub mod anthropic;
1112pub mod openai_compat;
13+ pub mod reasoning;
1214
1315#[ allow( dead_code) ]
1416pub type ProviderFuture < ' a , T > = Pin < Box < dyn Future < Output = Result < T , ApiError > > + Send + ' a > > ;
@@ -242,7 +244,45 @@ pub fn is_local_inference() -> bool {
242244 false
243245}
244246
247+ /// Fail-fast reasoning-effort validation: reject a level the resolved model
248+ /// does not support, or a level string that is not a recognised level, before
249+ /// the request leaves the process. Mirrors the dsh `resolveReasoningLevel`
250+ /// posture — a stale or mistyped level fails here instead of being silently
251+ /// ignored by the backend.
252+ ///
253+ /// `None` (no level requested) always passes: the provider's own server
254+ /// default applies (the `reasoning_effort` field is omitted from the wire).
255+ fn validate_reasoning_effort_for_request ( request : & MessageRequest ) -> Result < ( ) , ApiError > {
256+ let Some ( level_str) = request. reasoning_effort . as_deref ( ) else {
257+ return Ok ( ( ) ) ;
258+ } ;
259+ let canonical = resolve_model_alias ( & request. model ) ;
260+ let provider = detect_provider_kind ( & canonical) ;
261+ let supported = reasoning_levels ( provider, & canonical) ;
262+ let supported_names: Vec < String > = supported
263+ . iter ( )
264+ . map ( |level| level. as_str ( ) . to_string ( ) )
265+ . collect ( ) ;
266+ let level = ReasoningEffort :: from_name ( level_str) . ok_or_else ( || {
267+ ApiError :: UnsupportedReasoningEffort {
268+ model : canonical. clone ( ) ,
269+ level : level_str. to_string ( ) ,
270+ supported : supported_names. clone ( ) ,
271+ }
272+ } ) ?;
273+ if supported. contains ( & level) {
274+ Ok ( ( ) )
275+ } else {
276+ Err ( ApiError :: UnsupportedReasoningEffort {
277+ model : canonical,
278+ level : level_str. to_string ( ) ,
279+ supported : supported_names,
280+ } )
281+ }
282+ }
283+
245284pub fn preflight_message_request ( request : & MessageRequest ) -> Result < ( ) , ApiError > {
285+ validate_reasoning_effort_for_request ( request) ?;
246286 let Some ( limit) = model_token_limit ( & request. model ) else {
247287 return Ok ( ( ) ) ;
248288 } ;
@@ -648,6 +688,72 @@ mod tests {
648688 . expect ( "models without context metadata should skip the guarded preflight" ) ;
649689 }
650690
691+ #[ test]
692+ fn preflight_rejects_unsupported_reasoning_effort ( ) {
693+ // `max` is not a level native OpenAI exposes (`off/low/medium/high`
694+ // only), so it must fail before any network I/O. The `openai/` prefix
695+ // makes provider detection environment-independent.
696+ let request = MessageRequest {
697+ model : "openai/o4-mini" . to_string ( ) ,
698+ max_tokens : 1024 ,
699+ messages : Arc :: new ( vec ! [ InputMessage :: user_text( "think" ) ] ) ,
700+ reasoning_effort : Some ( "max" . to_string ( ) ) ,
701+ ..Default :: default ( )
702+ } ;
703+ let err = preflight_message_request ( & request)
704+ . expect_err ( "max must be rejected for native OpenAI reasoning models" ) ;
705+ assert ! ( err. to_string( ) . contains( "o4-mini" ) ) ;
706+ assert ! ( err. to_string( ) . contains( "max" ) ) ;
707+ assert ! ( err. to_string( ) . contains( "off, low, medium, high" ) ) ;
708+ }
709+
710+ #[ test]
711+ fn preflight_rejects_high_against_non_reasoning_model ( ) {
712+ // A non-reasoning model exposes only `off`; `high` must fail fast.
713+ let request = MessageRequest {
714+ model : "gpt-4o" . to_string ( ) ,
715+ max_tokens : 1024 ,
716+ messages : Arc :: new ( vec ! [ InputMessage :: user_text( "hi" ) ] ) ,
717+ reasoning_effort : Some ( "high" . to_string ( ) ) ,
718+ ..Default :: default ( )
719+ } ;
720+ let err = preflight_message_request ( & request)
721+ . expect_err ( "high must be rejected for non-reasoning models" ) ;
722+ assert ! ( err. to_string( ) . contains( "gpt-4o" ) ) ;
723+ assert ! ( err. to_string( ) . contains( "high" ) ) ;
724+ }
725+
726+ #[ test]
727+ fn preflight_rejects_unrecognised_level_string ( ) {
728+ let request = MessageRequest {
729+ model : "o4-mini" . to_string ( ) ,
730+ max_tokens : 1024 ,
731+ messages : Arc :: new ( vec ! [ InputMessage :: user_text( "hi" ) ] ) ,
732+ reasoning_effort : Some ( "turbo" . to_string ( ) ) ,
733+ ..Default :: default ( )
734+ } ;
735+ let err = preflight_message_request ( & request)
736+ . expect_err ( "an unrecognised level string must fail fast" ) ;
737+ assert ! ( err. to_string( ) . contains( "turbo" ) ) ;
738+ }
739+
740+ #[ test]
741+ fn preflight_accepts_off_for_every_model ( ) {
742+ let reasoning = |model : & str | MessageRequest {
743+ model : model. to_string ( ) ,
744+ max_tokens : 1024 ,
745+ messages : Arc :: new ( vec ! [ InputMessage :: user_text( "hi" ) ] ) ,
746+ reasoning_effort : Some ( "off" . to_string ( ) ) ,
747+ ..Default :: default ( )
748+ } ;
749+ preflight_message_request ( & reasoning ( "gpt-4o" ) )
750+ . expect ( "off is always supported" ) ;
751+ preflight_message_request ( & reasoning ( "o4-mini" ) )
752+ . expect ( "off is always supported" ) ;
753+ preflight_message_request ( & reasoning ( "claude-sonnet-4-6" ) )
754+ . expect ( "off is always supported" ) ;
755+ }
756+
651757 #[ test]
652758 fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix ( ) {
653759 // given
0 commit comments