@@ -27,6 +27,17 @@ pub enum ProviderEngine {
2727 Anthropic ,
2828}
2929
30+ #[ derive( Debug , Clone , Serialize , Deserialize , ToSchema ) ]
31+ pub struct EnvVarConfig {
32+ pub name : String ,
33+ #[ serde( default ) ]
34+ pub required : bool ,
35+ #[ serde( default ) ]
36+ pub secret : bool ,
37+ pub description : Option < String > ,
38+ pub default : Option < String > ,
39+ }
40+
3041#[ derive( Debug , Clone , Serialize , Deserialize , ToSchema ) ]
3142pub struct DeclarativeProviderConfig {
3243 pub name : String ,
@@ -42,6 +53,10 @@ pub struct DeclarativeProviderConfig {
4253 pub supports_streaming : Option < bool > ,
4354 #[ serde( default = "default_requires_auth" ) ]
4455 pub requires_auth : bool ,
56+ #[ serde( default ) ]
57+ pub env_vars : Option < Vec < EnvVarConfig > > ,
58+ #[ serde( default ) ]
59+ pub dynamic_models : Option < bool > ,
4560}
4661
4762fn default_requires_auth ( ) -> bool {
@@ -62,6 +77,40 @@ impl DeclarativeProviderConfig {
6277 }
6378}
6479
80+ /// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs.
81+ /// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`.
82+ /// Returns an error if a `required` var is missing.
83+ pub fn expand_env_vars ( template : & str , env_vars : & [ EnvVarConfig ] ) -> Result < String > {
84+ let config = Config :: global ( ) ;
85+ let mut result = template. to_string ( ) ;
86+ for var in env_vars {
87+ let placeholder = format ! ( "${{{}}}" , var. name) ;
88+ if !result. contains ( & placeholder) {
89+ continue ;
90+ }
91+ let value = if var. secret {
92+ config. get_secret :: < String > ( & var. name ) . ok ( )
93+ } else {
94+ config. get_param :: < String > ( & var. name ) . ok ( )
95+ } ;
96+ let value = match value {
97+ Some ( v) => v,
98+ None => match & var. default {
99+ Some ( d) => d. clone ( ) ,
100+ None if var. required => {
101+ return Err ( anyhow:: anyhow!(
102+ "Required environment variable {} is not set" ,
103+ var. name
104+ ) ) ;
105+ }
106+ None => continue ,
107+ } ,
108+ } ;
109+ result = result. replace ( & placeholder, & value) ;
110+ }
111+ Ok ( result)
112+ }
113+
65114#[ derive( Debug , Clone , Serialize , Deserialize , ToSchema ) ]
66115pub struct LoadedProvider {
67116 pub config : DeclarativeProviderConfig ,
@@ -154,6 +203,8 @@ pub fn create_custom_provider(
154203 timeout_seconds : None ,
155204 supports_streaming : params. supports_streaming ,
156205 requires_auth : params. requires_auth ,
206+ env_vars : None ,
207+ dynamic_models : None ,
157208 } ;
158209
159210 let custom_providers_dir = custom_providers_dir ( ) ;
@@ -211,6 +262,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
211262 timeout_seconds : existing_config. timeout_seconds ,
212263 supports_streaming : params. supports_streaming ,
213264 requires_auth : params. requires_auth ,
265+ env_vars : existing_config. env_vars ,
266+ dynamic_models : existing_config. dynamic_models ,
214267 } ;
215268
216269 let file_path = custom_providers_dir ( ) . join ( format ! ( "{}.json" , updated_config. name) ) ;
@@ -351,3 +404,133 @@ pub fn register_declarative_provider(
351404 }
352405 }
353406}
407+
408+ #[ cfg( test) ]
409+ mod tests {
410+ use super :: * ;
411+
412+ #[ test]
413+ fn test_tanzu_json_deserializes ( ) {
414+ let json = include_str ! ( "../providers/declarative/tanzu.json" ) ;
415+ let config: DeclarativeProviderConfig =
416+ serde_json:: from_str ( json) . expect ( "tanzu.json should parse" ) ;
417+ assert_eq ! ( config. name, "tanzu_ai" ) ;
418+ assert_eq ! ( config. display_name, "Tanzu AI Services" ) ;
419+ assert ! ( matches!( config. engine, ProviderEngine :: OpenAI ) ) ;
420+ assert_eq ! ( config. api_key_env, "TANZU_AI_API_KEY" ) ;
421+ assert_eq ! (
422+ config. base_url,
423+ "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
424+ ) ;
425+ assert_eq ! ( config. dynamic_models, Some ( true ) ) ;
426+ assert_eq ! ( config. supports_streaming, Some ( false ) ) ;
427+
428+ let env_vars = config. env_vars . as_ref ( ) . expect ( "env_vars should be set" ) ;
429+ assert_eq ! ( env_vars. len( ) , 1 ) ;
430+ assert_eq ! ( env_vars[ 0 ] . name, "TANZU_AI_ENDPOINT" ) ;
431+ assert ! ( env_vars[ 0 ] . required) ;
432+ assert ! ( !env_vars[ 0 ] . secret) ;
433+
434+ assert_eq ! ( config. models. len( ) , 1 ) ;
435+ assert_eq ! ( config. models[ 0 ] . name, "openai/gpt-oss-120b" ) ;
436+ }
437+
438+ #[ test]
439+ fn test_existing_json_files_still_deserialize_without_new_fields ( ) {
440+ let json = include_str ! ( "../providers/declarative/groq.json" ) ;
441+ let config: DeclarativeProviderConfig =
442+ serde_json:: from_str ( json) . expect ( "groq.json should parse without env_vars" ) ;
443+ assert ! ( config. env_vars. is_none( ) ) ;
444+ assert ! ( config. dynamic_models. is_none( ) ) ;
445+ }
446+
447+ #[ test]
448+ fn test_expand_env_vars_replaces_placeholder ( ) {
449+ let _guard = env_lock:: lock_env ( [ ( "TEST_EXPAND_HOST" , Some ( "https://example.com/api" ) ) ] ) ;
450+
451+ let env_vars = vec ! [ EnvVarConfig {
452+ name: "TEST_EXPAND_HOST" . to_string( ) ,
453+ required: true ,
454+ secret: false ,
455+ description: None ,
456+ default : None ,
457+ } ] ;
458+
459+ let result = expand_env_vars ( "${TEST_EXPAND_HOST}/v1/chat/completions" , & env_vars) . unwrap ( ) ;
460+ assert_eq ! ( result, "https://example.com/api/v1/chat/completions" ) ;
461+ }
462+
463+ #[ test]
464+ fn test_expand_env_vars_required_missing_errors ( ) {
465+ let _guard = env_lock:: lock_env ( [ ( "TEST_EXPAND_MISSING" , None :: < & str > ) ] ) ;
466+
467+ let env_vars = vec ! [ EnvVarConfig {
468+ name: "TEST_EXPAND_MISSING" . to_string( ) ,
469+ required: true ,
470+ secret: false ,
471+ description: None ,
472+ default : None ,
473+ } ] ;
474+
475+ let result = expand_env_vars ( "${TEST_EXPAND_MISSING}/path" , & env_vars) ;
476+ assert ! ( result. is_err( ) ) ;
477+ assert ! ( result
478+ . unwrap_err( )
479+ . to_string( )
480+ . contains( "TEST_EXPAND_MISSING" ) ) ;
481+ }
482+
483+ #[ test]
484+ fn test_expand_env_vars_uses_default_when_missing ( ) {
485+ let _guard = env_lock:: lock_env ( [ ( "TEST_EXPAND_DEFAULT" , None :: < & str > ) ] ) ;
486+
487+ let env_vars = vec ! [ EnvVarConfig {
488+ name: "TEST_EXPAND_DEFAULT" . to_string( ) ,
489+ required: false ,
490+ secret: false ,
491+ description: None ,
492+ default : Some ( "https://fallback.example.com" . to_string( ) ) ,
493+ } ] ;
494+
495+ let result =
496+ expand_env_vars ( "${TEST_EXPAND_DEFAULT}/v1/chat/completions" , & env_vars) . unwrap ( ) ;
497+ assert_eq ! ( result, "https://fallback.example.com/v1/chat/completions" ) ;
498+ }
499+
500+ #[ test]
501+ fn test_expand_env_vars_no_placeholders_passthrough ( ) {
502+ let env_vars = vec ! [ EnvVarConfig {
503+ name: "UNUSED_VAR" . to_string( ) ,
504+ required: true ,
505+ secret: false ,
506+ description: None ,
507+ default : None ,
508+ } ] ;
509+
510+ let result =
511+ expand_env_vars ( "https://static.example.com/v1/chat/completions" , & env_vars) . unwrap ( ) ;
512+ assert_eq ! ( result, "https://static.example.com/v1/chat/completions" ) ;
513+ }
514+
515+ #[ test]
516+ fn test_expand_env_vars_empty_slice_passthrough ( ) {
517+ let result = expand_env_vars ( "${WHATEVER}/path" , & [ ] ) . unwrap ( ) ;
518+ assert_eq ! ( result, "${WHATEVER}/path" ) ;
519+ }
520+
521+ #[ test]
522+ fn test_expand_env_vars_env_value_overrides_default ( ) {
523+ let _guard = env_lock:: lock_env ( [ ( "TEST_EXPAND_OVERRIDE" , Some ( "https://from-env.com" ) ) ] ) ;
524+
525+ let env_vars = vec ! [ EnvVarConfig {
526+ name: "TEST_EXPAND_OVERRIDE" . to_string( ) ,
527+ required: false ,
528+ secret: false ,
529+ description: None ,
530+ default : Some ( "https://from-default.com" . to_string( ) ) ,
531+ } ] ;
532+
533+ let result = expand_env_vars ( "${TEST_EXPAND_OVERRIDE}/path" , & env_vars) . unwrap ( ) ;
534+ assert_eq ! ( result, "https://from-env.com/path" ) ;
535+ }
536+ }
0 commit comments