1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Provider registry construction.
//!
//! Builds the [`ProviderRegistry`] from config and environment variables.
use std::{collections::HashMap, sync::Arc};
use tracing::info;
use rsclaw_config::runtime::RuntimeConfig;
use crate::{
LlmProvider,
anthropic::{self as anthropic, AnthropicProvider},
gemini::{self as gemini, GeminiProvider},
openai::OpenAiProvider,
registry::ProviderRegistry,
rsclaw::RsclawProvider,
};
pub fn build_providers(config: &RuntimeConfig) -> ProviderRegistry {
let mut registry = ProviderRegistry::new();
if let Some(models_cfg) = &config.model.models {
for (name, provider_cfg) in &models_cfg.providers {
// `load_json5` runs `expand_env_vars` at parse time, so a
// value like `"${RSCLAW_API_KEY}"` becomes the env var's
// actual contents — UNLESS the variable isn't set, in
// which case the placeholder is left verbatim. Without
// this filter, an unset env var would silently turn into
// `Authorization: Bearer ${RSCLAW_API_KEY}` on the wire and
// surface as a baffling "invalid api key" upstream. We
// also remember WHETHER the user explicitly set `apiKey:`
// — that determines whether the post-fallback empty state
// is "they wanted a key and we couldn't find it" (disable
// the provider) versus "they're targeting an unauth'd
// local endpoint" (let it through, current behaviour).
let user_specified_key = provider_cfg.api_key.is_some();
let mut unresolved_placeholder: Option<String> = None;
let api_key = provider_cfg
.api_key
.as_ref()
.and_then(|k| k.as_plain().map(str::to_owned))
.filter(|s| {
if s.contains("${") && s.contains('}') {
unresolved_placeholder = Some(s.clone());
false
} else {
true
}
})
.or_else(|| std::env::var(format!("{}_API_KEY", name.to_uppercase())).ok());
// Hard-fail providers that REQUIRE a bearer (anthropic /
// openai / gemini / etc. anything not explicitly opt-in to
// running without auth). Detection: user wrote
// `apiKey: "${VAR}"`, var was unset, and the `{NAME}_API_KEY`
// env fallback also yielded nothing. Marking disabled at
// registry level is louder than registering with an empty
// key (current behaviour); failed routing returns the
// disable reason instead of a request-time 401.
if user_specified_key && api_key.as_deref().is_none_or(str::is_empty) {
let hint = match &unresolved_placeholder {
Some(s) if s.contains("${") => {
let var_name = s.trim_start_matches("${").trim_end_matches('}').to_owned();
format!(
"set {var_name} in your shell (then `rsclaw env sync`) \
or edit {}/.env directly",
std::env::var("RSCLAW_BASE_DIR")
.unwrap_or_else(|_| "~/.rsclaw".to_owned())
)
}
_ => format!(
"configure `models.providers.{name}.apiKey` with a real value \
or set {}_API_KEY in env",
name.to_uppercase()
),
};
let reason = format!("apiKey unresolved — {hint}");
tracing::error!(
provider = %name,
placeholder = ?unresolved_placeholder.as_deref(),
"provider disabled — apiKey unresolved",
);
registry.disable(name.clone(), reason);
continue;
}
let base_url = provider_cfg.base_url.clone().or_else(|| {
// Fall back to the single source of truth — defaults.toml then
// the hardcoded builtin table — which covers EVERY known
// provider (agnes, doubao, …), not just a hand-maintained
// subset. Without this, a provider configured with only an
// `apiKey` (e.g. agnes via `rsclaw setup`) resolved to a
// `None` base_url and the openai-completions arm fell back to
// `api.openai.com`, sending that provider's key to OpenAI and
// surfacing a baffling 401.
let (url, _auth) = crate::defaults::resolve_base_url(name);
(!url.is_empty()).then_some(url)
});
// Resolve User-Agent: provider > gateway > built-in default.
let user_agent = provider_cfg
.user_agent
.clone()
.or_else(|| config.gateway.user_agent.clone());
// Determine API format: explicit `api` field > name-based inference.
let api_format = provider_cfg.api.clone().unwrap_or_else(|| {
use rsclaw_config::schema::ApiFormat;
match name.as_str() {
"anthropic" => ApiFormat::Anthropic,
"gemini" => ApiFormat::Gemini,
"doubao" | "bytedance" => ApiFormat::OpenAiResponses,
"ollama" => ApiFormat::Ollama,
// Bare `rsclaw` name implies the stateful session
// protocol — same shortcut as `anthropic`/`gemini`
// above. Users who name their provider differently
// (e.g. a self-hosted worker called `local-llm`)
// must set `api: "rsclaw"` explicitly.
"rsclaw" => ApiFormat::Rsclaw,
_ => ApiFormat::OpenAiCompletions,
}
});
let provider: Arc<dyn LlmProvider> = match (name.as_str(), &api_format) {
("anthropic", _)
| (_, &rsclaw_config::schema::ApiFormat::Anthropic)
| (_, &rsclaw_config::schema::ApiFormat::AnthropicMessages) => {
let key = api_key
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.unwrap_or_default();
let url = base_url.unwrap_or_else(|| anthropic::ANTHROPIC_API_BASE.to_owned());
Arc::new(AnthropicProvider::with_user_agent(key, url, user_agent))
}
("gemini", _) => {
let key = api_key
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
.unwrap_or_default();
let url = base_url.unwrap_or_else(|| gemini::GEMINI_API_BASE.to_owned());
Arc::new(GeminiProvider::with_user_agent(key, url, user_agent))
}
(_, &rsclaw_config::schema::ApiFormat::Ollama) => {
// Ollama backend: reasoning models use native /api/chat
let key = api_key.or_else(|| std::env::var("OPENAI_API_KEY").ok());
let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_owned());
Arc::new(OpenAiProvider::ollama_with_ua(url, key, user_agent))
}
(_, &rsclaw_config::schema::ApiFormat::OpenAiResponses) => {
let key = api_key.or_else(|| std::env::var("OPENAI_API_KEY").ok());
let url = base_url
.unwrap_or_else(|| crate::openai::OPENAI_API_BASE.to_owned());
Arc::new(OpenAiProvider::responses_with_ua(url, key, user_agent))
}
(_, &rsclaw_config::schema::ApiFormat::Rsclaw) => {
// rsclaw stateful session protocol (kvCacheMode=2).
// `baseUrl` should point at either rsclaw-server
// (e.g. `https://api.rsclaw.ai/v1/agent`) or a
// direct rsclaw-llm worker (e.g.
// `http://localhost:9999`). `apiKey` is the bearer
// token; falls back to RSCLAW_KEY env, then to
// None (acceptable when targeting an unauth'd
// local worker).
let key = api_key
.or_else(|| std::env::var("RSCLAW_KEY").ok())
.or_else(|| std::env::var("RSCLAW_SERVER_KEY").ok())
.filter(|s| !s.is_empty());
let url = base_url
.unwrap_or_else(|| crate::rsclaw::RSCLAW_DEFAULT_BASE.to_owned());
let provider = crate::rsclaw::RsclawProvider::new(url, key);
let provider = match provider_cfg.prefix_id.clone() {
Some(pid) => provider.with_prefix_id(pid),
None => provider,
};
let provider = match provider_cfg.compact_timeout_secs {
Some(secs) => provider.with_compact_timeout_secs(secs),
None => provider,
};
let provider = match provider_cfg.constrain_tool_calls {
Some(enabled) => provider.with_constrain_tool_calls(enabled),
None => provider,
};
Arc::new(provider)
}
_ => {
// OpenAI-compatible (covers openai-completions,
// llama.cpp, vLLM, SGLang, etc.)
let key = api_key.or_else(|| std::env::var("OPENAI_API_KEY").ok());
if let Some(url) = base_url {
Arc::new(OpenAiProvider::with_user_agent(url, key, user_agent))
} else {
Arc::new(OpenAiProvider::with_user_agent(
crate::openai::OPENAI_API_BASE,
key,
user_agent,
))
}
}
};
tracing::info!(name=%name, api=?api_format, "provider registered");
registry.register(name.clone(), provider);
}
}
// Auto-register from environment variables.
if !registry.names().contains(&"anthropic")
&& let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
{
registry.register("anthropic", Arc::new(AnthropicProvider::new(key)));
}
if !registry.names().contains(&"openai")
&& let Ok(key) = std::env::var("OPENAI_API_KEY")
{
registry.register("openai", Arc::new(OpenAiProvider::new(key)));
}
if !registry.names().contains(&"gemini")
&& let Ok(key) = std::env::var("GEMINI_API_KEY")
{
registry.register("gemini", Arc::new(GeminiProvider::new(key)));
}
// Auto-register OpenAI-compatible providers from env vars.
let compat_providers = [
// --- International ---
("agnes", "https://apihub.agnes-ai.com/v1", "AGNES_API_KEY"),
("groq", "https://api.groq.com/openai/v1", "GROQ_API_KEY"),
(
"deepseek",
"https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY",
),
("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY"),
(
"together",
"https://api.together.xyz/v1",
"TOGETHER_API_KEY",
),
(
"openrouter",
"https://openrouter.ai/api/v1",
"OPENROUTER_API_KEY",
),
("xai", "https://api.x.ai/v1", "XAI_API_KEY"),
("cerebras", "https://api.cerebras.ai/v1", "CEREBRAS_API_KEY"),
(
"fireworks",
"https://api.fireworks.ai/inference/v1",
"FIREWORKS_API_KEY",
),
(
"perplexity",
"https://api.perplexity.ai",
"PERPLEXITY_API_KEY",
),
("cohere", "https://api.cohere.com/v2", "COHERE_API_KEY"),
(
"huggingface",
"https://api-inference.huggingface.co/v1",
"HF_API_KEY",
),
// --- China ---
(
"siliconflow",
"https://api.siliconflow.cn/v1",
"SILICONFLOW_API_KEY",
),
(
"qwen",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"DASHSCOPE_API_KEY",
),
("kimi", "https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"), // Kimi = Moonshot
("moonshot", "https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"),
(
"zhipu",
"https://open.bigmodel.cn/api/paas/v4",
"ZHIPU_API_KEY",
),
(
"baichuan",
"https://api.baichuan-ai.com/v1",
"BAICHUAN_API_KEY",
),
("minimax", "https://api.minimaxi.com/v1", "MINIMAX_API_KEY"),
("stepfun", "https://api.stepfun.com/v1", "STEPFUN_API_KEY"),
("lingyi", "https://api.lingyiwanwu.com/v1", "LINGYI_API_KEY"),
(
"baidu",
"https://qianfan.baidubce.com/v2",
"QIANFAN_API_KEY",
),
(
"gaterouter",
"https://api.gaterouter.ai/openai/v1",
"GATEROUTER_API_KEY",
),
(
"infini",
"https://cloud.infini-ai.com/maas/v1",
"INFINI_API_KEY",
),
];
for (name, base_url, env_key) in compat_providers {
if !registry.names().contains(&name)
&& let Ok(key) = std::env::var(env_key)
{
registry.register(
name,
Arc::new(OpenAiProvider::with_base_url(base_url, Some(key))),
);
}
}
// Doubao / ByteDance — uses OpenAI Responses API format.
if !registry.names().contains(&"doubao") {
if let Ok(key) = std::env::var("ARK_API_KEY").or_else(|_| std::env::var("DOUBAO_API_KEY")) {
registry.register(
"doubao",
Arc::new(OpenAiProvider::responses(
"https://ark.cn-beijing.volces.com/api/v3",
Some(key),
)),
);
}
}
if !registry.names().contains(&"bytedance") {
if let Ok(key) = std::env::var("ARK_API_KEY").or_else(|_| std::env::var("DOUBAO_API_KEY")) {
registry.register(
"bytedance",
Arc::new(OpenAiProvider::responses(
"https://ark.cn-beijing.volces.com/api/v3",
Some(key),
)),
);
}
}
// Ollama (no API key needed).
if !registry.names().contains(&"ollama") {
registry.register(
"ollama",
Arc::new(OpenAiProvider::with_base_url(
"http://localhost:11434",
None,
)),
);
}
// rsclaw-server — internal multi-provider gateway. Speaks OpenAI Chat
// Completions; clients send `Authorization: Bearer <client_key>` and
// upstream routing happens server-side.
//
// RSCLAW_SERVER_KEY — required, matches a `[[client_keys]]` entry
// in rsclaw-server's config.toml
// RSCLAW_SERVER_URL — optional override (default: http://localhost:8090/v1)
//
// `nonempty_env` (not `std::env::var(...).ok()`) so a placeholder
// `RSCLAW_SERVER_KEY=` in a dotenv template doesn't register the
// provider with an empty bearer (silent 401s upstream), and a blank
// `RSCLAW_SERVER_URL=` doesn't defeat the localhost default by
// returning `Ok("")` and falling through to register an
// unparseable empty base URL. Same rationale as the rsclaw block
// below.
if !registry.names().contains(&"rsclaw_server")
&& let Some(key) = nonempty_env("RSCLAW_SERVER_KEY")
{
let url = nonempty_env("RSCLAW_SERVER_URL")
.unwrap_or_else(|| "http://localhost:8090/v1".to_string());
registry.register(
"rsclaw_server",
Arc::new(OpenAiProvider::with_base_url(url, Some(key))),
);
}
// rsclaw — kvCacheMode=2 incremental session protocol (rsclaw-protocol.md).
// Distinct from `rsclaw_server` above: that one speaks OpenAI Chat for
// mode 0/1 traffic; this one speaks the stateful session protocol and
// rejects requests with kv_cache_mode != 2.
//
// RSCLAW_KEY — bearer token (optional if rsclaw-server has auth disabled)
// RSCLAW_URL — full base URL including the `/v1/agent` mount
// (default: http://localhost:8090/v1/agent)
if !registry.names().contains(&"rsclaw") {
// `std::env::var(...).ok()` returns `Some("")` when an env var
// is *set but blank* (e.g. `RSCLAW_KEY=` in a dotenv file used
// as a placeholder/template). `Some("")` is truthy in
// `.or_else(...)`, so the fallback chain
// `RSCLAW_KEY` → `RSCLAW_SERVER_KEY` short-circuits on the
// empty earlier value and never reaches the populated later
// one — leaving the gateway with no bearer despite the user
// having set one. `nonempty_env` skips the empty-set case so
// the chain composes correctly.
let key = nonempty_env("RSCLAW_KEY").or_else(|| nonempty_env("RSCLAW_SERVER_KEY"));
let url = nonempty_env("RSCLAW_URL")
.unwrap_or_else(|| crate::rsclaw::RSCLAW_DEFAULT_BASE.to_string());
registry.register("rsclaw", Arc::new(RsclawProvider::new(url, key)));
}
// Wire up model aliases from agents.defaults.models.
if let Some(models) = &config.agents.defaults.models {
let mut aliases = HashMap::new();
for (model_key, alias_def) in models {
if let Some(ref target) = alias_def.alias {
aliases.insert(model_key.clone(), target.clone());
} else if let Some(ref target_model) = alias_def.model {
// model field: "deepseek/deepseek-v3" -> provider is first segment
if let Some((prov, _)) = target_model.split_once('/') {
aliases.insert(model_key.clone(), prov.to_owned());
}
}
}
if !aliases.is_empty() {
info!("{} model alias(es) configured", aliases.len());
registry.set_model_aliases(aliases);
}
}
registry
}
/// Read an env var and treat both *unset* and *set-but-blank* as
/// absent. Mirrors `std::env::var(name).ok()` for the absent case but
/// adds whitespace-aware blank detection so callers can chain
/// fallbacks (`A → B → C`) without an empty earlier value vetoing the
/// rest. See the rsclaw block above for the concrete short-circuit
/// scenario this prevents.
fn nonempty_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonempty_env_skips_unset_set_blank_and_whitespace_only() {
// Use unique names so parallel tests don't race each other.
// The point of the test is the *behaviour shape*, not the
// env-var lookup itself — exercise via SAFETY-ed set/remove.
let unset = "RSCLAW_TEST_NONEMPTY_UNSET_8d2f1c";
let blank = "RSCLAW_TEST_NONEMPTY_BLANK_8d2f1c";
let spaces = "RSCLAW_TEST_NONEMPTY_SPACES_8d2f1c";
let real = "RSCLAW_TEST_NONEMPTY_REAL_8d2f1c";
// SAFETY: env mutation is process-global. Names are unique so
// no other test in this crate observes them.
unsafe {
std::env::remove_var(unset);
std::env::set_var(blank, "");
std::env::set_var(spaces, " \t\n");
std::env::set_var(real, " sk-real ");
}
assert_eq!(nonempty_env(unset), None, "unset");
assert_eq!(nonempty_env(blank), None, "set-blank");
assert_eq!(nonempty_env(spaces), None, "whitespace-only");
// Trimming is the *provider's* job (provider/rsclaw.rs), not
// ours — return the raw string so the caller can decide. We
// only filter on the trim *result* to detect blank.
assert_eq!(nonempty_env(real).as_deref(), Some(" sk-real "));
unsafe {
std::env::remove_var(blank);
std::env::remove_var(spaces);
std::env::remove_var(real);
}
}
}