@@ -13,7 +13,130 @@ pub enum ProviderClient {
1313 OpenAi ( OpenAiCompatClient ) ,
1414}
1515
16+ /// Provider selected explicitly by persisted/runtime configuration.
17+ ///
18+ /// This is intentionally separate from `ProviderKind`: DashScope speaks the
19+ /// OpenAI-compatible wire protocol but remains a distinct configured provider.
20+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
21+ pub enum ProviderConfigKind {
22+ Anthropic ,
23+ Xai ,
24+ OpenAi ,
25+ DashScope ,
26+ }
27+
28+ /// Explicit provider configuration supplied by the runtime layer.
29+ ///
30+ /// This keeps the API crate independent of runtime configuration types.
31+ /// Credentials are redacted from Debug output.
32+ #[ derive( Clone ) ]
33+ pub struct ProviderConfig {
34+ pub kind : ProviderConfigKind ,
35+ pub model : String ,
36+ pub api_key : Option < String > ,
37+ pub base_url : Option < String > ,
38+ }
39+
40+ impl std:: fmt:: Debug for ProviderConfig {
41+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
42+ f. debug_struct ( "ProviderConfig" )
43+ . field ( "kind" , & self . kind )
44+ . field ( "model" , & self . model )
45+ . field ( "api_key" , & self . api_key . as_ref ( ) . map ( |_| "<redacted>" ) )
46+ . field ( "base_url" , & self . base_url )
47+ . finish ( )
48+ }
49+ }
50+
51+ fn read_env_non_empty ( key : & str ) -> Option < String > {
52+ std:: env:: var ( key)
53+ . ok ( )
54+ . filter ( |value| !value. trim ( ) . is_empty ( ) )
55+ }
56+
1657impl ProviderClient {
58+ /// Construct a client from an explicit provider configuration.
59+ ///
60+ /// The persisted provider kind is authoritative for provider selection,
61+ /// while environment credentials and base URLs retain precedence over
62+ /// persisted values.
63+ pub fn from_config ( config : & ProviderConfig ) -> Result < Self , ApiError > {
64+ match config. kind {
65+ ProviderConfigKind :: Anthropic => {
66+ let api_key = read_env_non_empty ( "ANTHROPIC_API_KEY" )
67+ . or_else ( || config. api_key . clone ( ) )
68+ . ok_or_else ( || {
69+ ApiError :: missing_credentials (
70+ "Anthropic" ,
71+ & [ "ANTHROPIC_API_KEY" , "ANTHROPIC_AUTH_TOKEN" ] ,
72+ )
73+ } ) ?;
74+
75+ let mut client = AnthropicClient :: new ( api_key) ;
76+ let base_url = read_env_non_empty ( "ANTHROPIC_BASE_URL" )
77+ . or_else ( || config. base_url . clone ( ) )
78+ . unwrap_or_else ( anthropic:: read_base_url) ;
79+ client = client. with_base_url ( base_url) ;
80+ Ok ( Self :: Anthropic ( client) )
81+ }
82+ ProviderConfigKind :: Xai => {
83+ let compat = OpenAiCompatConfig :: xai ( ) ;
84+ let api_key =
85+ read_env_non_empty ( compat. api_key_env ) . or_else ( || config. api_key . clone ( ) ) ;
86+
87+ let mut client = match api_key {
88+ Some ( api_key) => OpenAiCompatClient :: new ( api_key, compat) ,
89+ None => OpenAiCompatClient :: from_env ( compat) ?,
90+ } ;
91+
92+ let base_url = read_env_non_empty ( compat. base_url_env )
93+ . or_else ( || config. base_url . clone ( ) )
94+ . unwrap_or_else ( || compat. default_base_url . to_string ( ) ) ;
95+ client = client. with_base_url ( base_url) ;
96+
97+ Ok ( Self :: Xai ( client) )
98+ }
99+ ProviderConfigKind :: OpenAi | ProviderConfigKind :: DashScope => {
100+ let compat = match config. kind {
101+ ProviderConfigKind :: OpenAi => OpenAiCompatConfig :: openai ( ) ,
102+ ProviderConfigKind :: DashScope => OpenAiCompatConfig :: dashscope ( ) ,
103+ _ => unreachable ! ( "non-OpenAI provider reached compatibility path" ) ,
104+ } ;
105+
106+ if config. kind == ProviderConfigKind :: OpenAi
107+ && read_env_non_empty ( "OLLAMA_HOST" ) . is_some ( )
108+ && config. api_key . is_none ( )
109+ && config. base_url . is_none ( )
110+ {
111+ return Ok ( Self :: OpenAi (
112+ openai_compat:: OpenAiCompatClient :: from_ollama_env ( )
113+ . expect ( "from_ollama_env always returns Some" ) ,
114+ ) ) ;
115+ }
116+
117+ let api_key =
118+ read_env_non_empty ( compat. api_key_env ) . or_else ( || config. api_key . clone ( ) ) ;
119+ let persisted_base_url = config. base_url . clone ( ) ;
120+ let mut client = match api_key {
121+ Some ( api_key) => OpenAiCompatClient :: new ( api_key, compat) ,
122+ None if config. kind == ProviderConfigKind :: OpenAi
123+ && persisted_base_url
124+ . as_deref ( )
125+ . is_some_and ( openai_compat:: is_local_openai_compatible_base_url) =>
126+ {
127+ OpenAiCompatClient :: new ( "local-dev-token" , compat)
128+ }
129+ None => OpenAiCompatClient :: from_env ( compat) ?,
130+ } ;
131+ let base_url = read_env_non_empty ( compat. base_url_env )
132+ . or ( persisted_base_url)
133+ . unwrap_or_else ( || compat. default_base_url . to_string ( ) ) ;
134+ client = client. with_base_url ( base_url) ;
135+
136+ Ok ( Self :: OpenAi ( client) )
137+ }
138+ }
139+ }
17140 pub fn from_model ( model : & str ) -> Result < Self , ApiError > {
18141 Self :: from_model_with_anthropic_auth ( model, None )
19142 }
@@ -34,7 +157,7 @@ impl ProviderClient {
34157 ProviderKind :: OpenAi => {
35158 // OLLAMA_HOST takes priority: local Ollama needs no API key
36159 // and ignores DashScope/OpenAI env-based dispatch.
37- if std :: env :: var_os ( "OLLAMA_HOST" ) . is_some ( ) {
160+ if read_env_non_empty ( "OLLAMA_HOST" ) . is_some ( ) {
38161 Ok ( Self :: OpenAi (
39162 openai_compat:: OpenAiCompatClient :: from_ollama_env ( )
40163 . expect ( "from_ollama_env always returns Some" ) ,
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
155278mod tests {
156279 use std:: sync:: { Mutex , OnceLock } ;
157280
158- use super :: ProviderClient ;
281+ use super :: { ProviderClient , ProviderConfig , ProviderConfigKind } ;
159282 use crate :: providers:: { detect_provider_kind, resolve_model_alias, ProviderKind } ;
160283
161284 /// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,72 @@ mod tests {
213336 }
214337 }
215338
339+ #[ test]
340+ fn persisted_provider_kind_overrides_model_provider_detection ( ) {
341+ let _lock = env_lock ( ) ;
342+ let _anthropic = EnvVarGuard :: set ( "ANTHROPIC_API_KEY" , Some ( "test-anthropic-key" ) ) ;
343+ let _xai = EnvVarGuard :: set ( "XAI_API_KEY" , Some ( "test-xai-key" ) ) ;
344+
345+ let config = ProviderConfig {
346+ kind : ProviderConfigKind :: Xai ,
347+ model : "claude-sonnet-4-6" . to_string ( ) ,
348+ api_key : None ,
349+ base_url : None ,
350+ } ;
351+
352+ match ProviderClient :: from_config ( & config) . expect ( "explicit xAI config should succeed" ) {
353+ ProviderClient :: Xai ( client) => {
354+ assert ! ( client. base_url( ) . contains( "api.x.ai" ) ) ;
355+ }
356+ other => panic ! ( "Expected explicit xAI provider, got: {other:?}" ) ,
357+ }
358+ }
359+
360+ #[ test]
361+ fn persisted_dashscope_kind_overrides_qwen_model_routing ( ) {
362+ let _lock = env_lock ( ) ;
363+ let _dashscope = EnvVarGuard :: set ( "DASHSCOPE_API_KEY" , Some ( "test-dashscope-key" ) ) ;
364+ let _openai = EnvVarGuard :: set ( "OPENAI_API_KEY" , Some ( "test-openai-key" ) ) ;
365+
366+ let config = ProviderConfig {
367+ kind : ProviderConfigKind :: DashScope ,
368+ model : "gpt-5" . to_string ( ) ,
369+ api_key : None ,
370+ base_url : None ,
371+ } ;
372+
373+ match ProviderClient :: from_config ( & config)
374+ . expect ( "explicit DashScope config should succeed" )
375+ {
376+ ProviderClient :: OpenAi ( client) => {
377+ assert ! ( client. base_url( ) . contains( "dashscope.aliyuncs.com" ) ) ;
378+ }
379+ other => panic ! ( "Expected explicit DashScope provider, got: {other:?}" ) ,
380+ }
381+ }
382+
383+ #[ test]
384+ fn persisted_openai_kind_does_not_become_dashscope_from_model ( ) {
385+ let _lock = env_lock ( ) ;
386+ let _openai = EnvVarGuard :: set ( "OPENAI_API_KEY" , Some ( "test-openai-key" ) ) ;
387+ let _dashscope = EnvVarGuard :: set ( "DASHSCOPE_API_KEY" , Some ( "test-dashscope-key" ) ) ;
388+
389+ let config = ProviderConfig {
390+ kind : ProviderConfigKind :: OpenAi ,
391+ model : "qwen-plus" . to_string ( ) ,
392+ api_key : None ,
393+ base_url : None ,
394+ } ;
395+
396+ match ProviderClient :: from_config ( & config) . expect ( "explicit OpenAI config should succeed" ) {
397+ ProviderClient :: OpenAi ( client) => {
398+ assert ! ( client. base_url( ) . contains( "api.openai.com" ) ) ;
399+ assert ! ( !client. base_url( ) . contains( "dashscope.aliyuncs.com" ) ) ;
400+ }
401+ other => panic ! ( "Expected explicit OpenAI provider, got: {other:?}" ) ,
402+ }
403+ }
404+
216405 #[ test]
217406 fn dashscope_model_uses_dashscope_config_not_openai ( ) {
218407 // Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
0 commit comments