Skip to content

Commit 1feeb38

Browse files
committed
fix: wire persisted provider configuration into runtime
1 parent 08106b0 commit 1feeb38

5 files changed

Lines changed: 485 additions & 47 deletions

File tree

‎rust/crates/api/src/client.rs‎

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1657
impl 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 {
155278
mod 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()

‎rust/crates/api/src/lib.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod client;
2+
23
mod error;
34
mod http_client;
45
mod prompt_cache;
@@ -8,7 +9,8 @@ mod types;
89

910
pub use client::{
1011
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
11-
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
12+
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
13+
ProviderConfigKind,
1214
};
1315
pub use error::ApiError;
1416
pub use http_client::{

‎rust/crates/api/src/providers/openai_compat.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,7 +1027,7 @@ fn url_host(url: &str) -> &str {
10271027
host_port.split(':').next().unwrap_or("")
10281028
}
10291029

1030-
fn is_local_openai_compatible_base_url(url: &str) -> bool {
1030+
pub(crate) fn is_local_openai_compatible_base_url(url: &str) -> bool {
10311031
let host = url_host(url.trim());
10321032
if host.eq_ignore_ascii_case("localhost") || host == "::1" {
10331033
return true;

‎rust/crates/runtime/src/config.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ pub struct RuntimeProviderConfig {
204204
}
205205

206206
impl RuntimeProviderConfig {
207+
#[must_use]
208+
pub fn is_configured(&self) -> bool {
209+
self.kind.is_some()
210+
}
211+
207212
#[must_use]
208213
pub fn kind(&self) -> Option<&str> {
209214
self.kind.as_deref()
@@ -2611,6 +2616,42 @@ mod tests {
26112616
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
26122617
}
26132618

2619+
#[test]
2620+
fn loads_persisted_provider_configuration_from_settings() {
2621+
let root = temp_dir();
2622+
let cwd = root.join("project");
2623+
let home = root.join("home").join(".claw");
2624+
fs::create_dir_all(&home).expect("home config dir");
2625+
fs::create_dir_all(&cwd).expect("project dir");
2626+
2627+
fs::write(
2628+
home.join("settings.json"),
2629+
r#"{
2630+
"provider": {
2631+
"kind": "dashscope",
2632+
"apiKey": "persisted-test-key",
2633+
"baseUrl": "https://dashscope.example/v1",
2634+
"model": "qwen-plus"
2635+
}
2636+
}"#,
2637+
)
2638+
.expect("write settings");
2639+
2640+
let loaded = ConfigLoader::new(&cwd, &home)
2641+
.load()
2642+
.expect("provider configuration should load");
2643+
2644+
let provider = loaded.provider();
2645+
2646+
assert_eq!(provider.kind(), Some("dashscope"));
2647+
assert_eq!(provider.api_key(), Some("persisted-test-key"));
2648+
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
2649+
assert_eq!(provider.model(), Some("qwen-plus"));
2650+
assert!(provider.is_configured());
2651+
2652+
fs::remove_dir_all(root).expect("cleanup temp dir");
2653+
}
2654+
26142655
#[test]
26152656
fn rejects_non_object_settings_files() {
26162657
let root = temp_dir();

0 commit comments

Comments
 (0)