community fixes

Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
This commit is contained in:
jaberjaber23
2026-03-09 21:19:50 +03:00
parent 385aee8e56
commit ad10aa5e80
39 changed files with 1196 additions and 256 deletions
Generated
+29
View File
@@ -747,6 +747,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]]
name = "chumsky"
version = "0.9.3"
@@ -3953,6 +3963,7 @@ version = "0.3.34"
dependencies = [
"async-trait",
"chrono",
"chrono-tz",
"cron",
"crossbeam",
"dashmap",
@@ -4369,6 +4380,15 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf_codegen"
version = "0.8.0"
@@ -4473,6 +4493,15 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
+2 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.34"
version = "0.3.35"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
+10 -1
View File
@@ -648,7 +648,16 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
self.kernel
.set_agent_model(agent_id, model)
.map_err(|e| format!("{e}"))?;
Ok(format!("Model switched to: {model}"))
// Read back resolved model+provider from registry
let entry = self
.kernel
.registry
.get(agent_id)
.ok_or_else(|| "Agent not found after model switch".to_string())?;
Ok(format!(
"Model switched to: {} (provider: {})",
entry.manifest.model.model, entry.manifest.model.provider
))
}
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
+65 -28
View File
@@ -36,6 +36,10 @@ pub struct AppState {
/// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes.
/// Maps cache key → (fetched_at, response_json) with 120s TTL.
pub clawhub_cache: DashMap<String, (Instant, serde_json::Value)>,
/// Probe cache for local provider health checks (ollama/vllm/lmstudio).
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
/// unreachable local services. 60-second TTL.
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
}
/// POST /api/agents — Spawn a new agent.
@@ -5531,6 +5535,10 @@ pub async fn get_model(
///
/// For local providers (ollama, vllm, lmstudio), also probes reachability and
/// discovers available models via their health endpoints.
///
/// Probes run **concurrently** and results are **cached for 60 seconds** so the
/// endpoint responds instantly on repeated dashboard loads even when local
/// providers are unreachable (fixes #474).
pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let provider_list: Vec<openfang_types::model_catalog::ProviderInfo> = {
let catalog = state
@@ -5541,9 +5549,34 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
catalog.list_providers().to_vec()
};
// Collect local providers that need probing
let local_providers: Vec<(usize, String, String)> = provider_list
.iter()
.enumerate()
.filter(|(_, p)| !p.key_required && !p.base_url.is_empty())
.map(|(i, p)| (i, p.id.clone(), p.base_url.clone()))
.collect();
// Fire all probes concurrently (cached results return instantly)
let cache = &state.provider_probe_cache;
let probe_futures: Vec<_> = local_providers
.iter()
.map(|(_, id, url)| {
openfang_runtime::provider_health::probe_provider_cached(id, url, cache)
})
.collect();
let probe_results = futures::future::join_all(probe_futures).await;
// Index probe results by provider list position for O(1) lookup
let mut probe_map: HashMap<usize, openfang_runtime::provider_health::ProbeResult> =
HashMap::with_capacity(local_providers.len());
for ((idx, _, _), result) in local_providers.iter().zip(probe_results.into_iter()) {
probe_map.insert(*idx, result);
}
let mut providers: Vec<serde_json::Value> = Vec::with_capacity(provider_list.len());
for p in &provider_list {
for (i, p) in provider_list.iter().enumerate() {
let mut entry = serde_json::json!({
"id": p.id,
"display_name": p.display_name,
@@ -5554,10 +5587,9 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
"base_url": p.base_url,
});
// For local providers, add reachability info via health probe
if !p.key_required {
// For local providers, attach the probe result
if let Some(probe) = probe_map.remove(&i) {
entry["is_local"] = serde_json::json!(true);
let probe = openfang_runtime::provider_health::probe_provider(&p.id, &p.base_url).await;
entry["reachable"] = serde_json::json!(probe.reachable);
entry["latency_ms"] = serde_json::json!(probe.latency_ms);
if !probe.discovered_models.is_empty() {
@@ -5570,6 +5602,9 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
if let Some(err) = &probe.error {
entry["error"] = serde_json::json!(err);
}
} else if !p.key_required {
// Local provider with empty base_url (e.g. claude-code) — skip probing
entry["is_local"] = serde_json::json!(true);
}
providers.push(entry);
@@ -6370,16 +6405,18 @@ pub async fn set_model(
};
match state.kernel.set_agent_model(agent_id, model) {
Ok(()) => {
// Return the resolved provider so frontend can update its state
let provider = state
// Return the resolved model+provider so frontend stays in sync.
// The model name may have been normalized (provider prefix stripped),
// so we read it back from the registry instead of echoing the raw input.
let (resolved_model, resolved_provider) = state
.kernel
.registry
.get(agent_id)
.map(|e| e.manifest.model.provider.clone())
.unwrap_or_default();
.map(|e| (e.manifest.model.model.clone(), e.manifest.model.provider.clone()))
.unwrap_or_else(|| (model.to_string(), String::new()));
(
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "model": model, "provider": provider})),
Json(serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider})),
)
}
Err(e) => (
@@ -8129,11 +8166,15 @@ pub async fn patch_agent_config(
}
}
// Update model/provider
// Update model/provider — use set_agent_model for catalog-based provider
// resolution when provider is not explicitly provided (fixes #387/#466:
// changing model from another provider without specifying provider now
// auto-resolves the correct provider from the model catalog).
if let Some(ref new_model) = req.model {
if !new_model.is_empty() {
if let Some(ref new_provider) = req.provider {
if !new_provider.is_empty() {
// Explicit provider given — use it directly
if state
.kernel
.registry
@@ -8149,27 +8190,23 @@ pub async fn patch_agent_config(
Json(serde_json::json!({"error": "Agent not found"})),
);
}
} else if state
.kernel
.registry
.update_model(agent_id, new_model.clone())
.is_err()
{
} else {
// Provider is empty string — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
} else {
// No provider field at all — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model) {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
} else if state
.kernel
.registry
.update_model(agent_id, new_model.clone())
.is_err()
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
}
}
+1
View File
@@ -50,6 +50,7 @@ pub async fn build_router(
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
// CORS: allow localhost origins by default. If API key is set, the API
+16 -2
View File
@@ -140,9 +140,23 @@ impl StreamChunker {
}
/// Find the last occurrence of a pattern within a byte range.
///
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
/// char boundary so that slicing never panics on multi-byte content.
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
let search_text = &text[range.start..range.end.min(text.len())];
search_text.rfind(pattern).map(|pos| range.start + pos)
let len = text.len();
// Clamp end to text length and walk back to a char boundary
let mut end = range.end.min(len);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
// Walk start forward to the nearest char boundary (never past end)
let mut start = range.start.min(end);
while start < end && !text.is_char_boundary(start) {
start += 1;
}
let search_text = &text[start..end];
search_text.rfind(pattern).map(|pos| start + pos)
}
#[cfg(test)]
+12 -5
View File
@@ -809,12 +809,19 @@ async fn handle_command(
} else {
match state.kernel.set_agent_model(agent_id, args) {
Ok(()) => {
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
if let Some(entry) = state.kernel.registry.get(agent_id) {
let model = &entry.manifest.model.model;
let provider = &entry.manifest.model.provider;
serde_json::json!({
"type": "command_result",
"command": cmd,
"message": format!("Model switched to: {model} (provider: {provider})"),
"model": model,
"provider": provider
})
} else {
format!("Model switched to: {args}")
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
}
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
+7 -7
View File
@@ -3372,11 +3372,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Network tab -->
<div x-show="tab === 'network'" x-data="{
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
async discoverA2a() {
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
this.a2aDiscovering = false;
}
}" x-init="loadNetStatus(); loadA2aAgents()">
@@ -3464,8 +3464,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
this.budgetLoading = true;
try {
let [b, a] = await Promise.all([
fetch('/api/budget').then(r => r.json()),
fetch('/api/budget/agents').then(r => r.json())
OpenFangAPI.get('/api/budget'),
OpenFangAPI.get('/api/budget/agents')
]);
this.budgetData = b;
this.agentRanking = a.agents || [];
@@ -3488,10 +3488,10 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
let alertVal = (+this.editAlert) / 100;
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
await OpenFangAPI.put('/api/budget', body);
this.editMode = false;
await this.loadBudget();
} catch(e) { alert('Failed to save: ' + e); }
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
this.saving = false;
},
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
+11
View File
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
return fetch(BASE + path, opts).then(function(r) {
if (_connectionState !== 'connected') setConnectionState('connected');
if (!r.ok) {
// On 401, auto-show auth prompt so the user can re-enter their key
if (r.status === 401 && typeof Alpine !== 'undefined') {
try {
var store = Alpine.store('app');
if (store && !store.showAuthPrompt) {
_authToken = '';
localStorage.removeItem('openfang-api-key');
store.showAuthPrompt = true;
}
} catch(e2) { /* ignore Alpine errors */ }
}
return r.text().then(function(text) {
var msg = '';
try {
@@ -411,7 +411,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +420,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -597,8 +597,9 @@ function agentsPage() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
OpenFangToast.success('Model changed (memory reset)');
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
this.editingModel = false;
await Alpine.store('app').refreshAgents();
// Refresh detailAgent
+10 -6
View File
@@ -264,9 +264,10 @@ function chatPage() {
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
self.currentAgent.model_name = model.id;
self.currentAgent.model_provider = model.provider;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
@@ -421,9 +422,12 @@ function chatPage() {
if (self.currentAgent) {
if (cmdArgs) {
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
self.currentAgent.model_name = cmdArgs;
if (resp && resp.provider) { self.currentAgent.model_provider = resp.provider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`' + (resp && resp.provider ? ' (provider: `' + resp.provider + '`)' : ''), meta: '', tools: [] });
// Use server-resolved model/provider (fixes #387/#466)
var resolvedModel = (resp && resp.model) || cmdArgs;
var resolvedProvider = (resp && resp.provider) || '';
self.currentAgent.model_name = resolvedModel;
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
self.scrollToBottom();
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
} else {
@@ -469,7 +469,7 @@ function wizardPage() {
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
openrouter: 'openrouter/google/gemini-2.5-flash',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
@@ -77,6 +77,7 @@ async fn start_test_server_with_provider(
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
@@ -705,6 +706,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let api_key_state = state.kernel.config.api_key.clone();
@@ -114,6 +114,7 @@ async fn test_full_daemon_lifecycle() {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
@@ -238,6 +239,7 @@ async fn test_server_immediate_responsiveness() {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
+1
View File
@@ -58,6 +58,7 @@ async fn start_test_server() -> TestServer {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
+1 -1
View File
@@ -1340,7 +1340,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
(
"openrouter",
"OPENROUTER_API_KEY",
"openrouter/anthropic/claude-sonnet-4",
"openrouter/google/gemini-2.5-flash",
"OpenRouter",
),
]
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
name: "openrouter",
display: "OpenRouter",
env_var: "OPENROUTER_API_KEY",
default_model: "openrouter/anthropic/claude-sonnet-4",
default_model: "openrouter/google/gemini-2.5-flash",
needs_key: true,
hint: "",
},
@@ -317,7 +317,10 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|a| {
let id_short = if a.id.len() > 12 {
format!("{}\u{2026}", &a.id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&a.id, 12)
)
} else {
a.id.clone()
};
@@ -405,7 +408,10 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|kv| {
let val_display = if kv.value.len() > 40 {
format!("{}\u{2026}", &kv.value[..39])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&kv.value, 39)
)
} else {
kv.value.clone()
};
+4 -1
View File
@@ -149,7 +149,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
.iter()
.map(|p| {
let id_short = if p.node_id.len() > 12 {
format!("{}\u{2026}", &p.node_id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&p.node_id, 12)
)
} else {
p.node_id.clone()
};
@@ -251,7 +251,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
.map(|&idx| {
let s = &state.sessions[idx];
let id_short = if s.id.len() > 12 {
format!("{}\u{2026}", &s.id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&s.id, 12)
)
} else {
s.id.clone()
};
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "openrouter",
env_var: "OPENROUTER_API_KEY",
default_model: "anthropic/claude-sonnet-4-20250514",
default_model: "google/gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
+1
View File
@@ -23,6 +23,7 @@ crossbeam = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
@@ -241,6 +241,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
plan.hot_actions.push(HotAction::ReloadProviderUrls);
}
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
plan.noop_changes.push("provider_api_keys changed (takes effect on next driver init)".to_string());
}
// ----- No-op fields -----
if old.log_level != new.log_level {
+324 -6
View File
@@ -216,6 +216,46 @@ impl CronScheduler {
self.jobs.iter().map(|r| r.value().job.clone()).collect()
}
/// Reassign all cron jobs from `old_agent_id` to `new_agent_id`.
///
/// Used when a hand agent is respawned (e.g. after daemon restart) and
/// gets a new UUID. Without this, persisted cron jobs would reference
/// the stale old agent ID and fail silently.
///
/// Returns the number of jobs reassigned.
pub fn reassign_agent_jobs(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
let mut count = 0;
for mut entry in self.jobs.iter_mut() {
if entry.value().job.agent_id == old_agent_id {
entry.value_mut().job.agent_id = new_agent_id;
// Reset consecutive errors so the job gets a fresh start
// with the new agent.
entry.value_mut().consecutive_errors = 0;
if !entry.value().job.enabled {
// Re-enable jobs that were auto-disabled due to the stale
// agent ID causing repeated failures.
if entry.value().last_status.as_deref().is_some_and(|s| {
s.contains("not found") || s.contains("No such agent")
}) {
entry.value_mut().job.enabled = true;
entry.value_mut().job.next_run =
Some(compute_next_run(&entry.value().job.schedule));
}
}
count += 1;
}
}
if count > 0 {
info!(
old_agent = %old_agent_id,
new_agent = %new_agent_id,
count,
"Reassigned cron jobs to new agent"
);
}
count
}
/// Total number of tracked jobs.
pub fn total_jobs(&self) -> usize {
self.jobs.len()
@@ -324,7 +364,7 @@ pub fn compute_next_run_after(
match schedule {
CronSchedule::At { at } => *at,
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
CronSchedule::Cron { expr, tz: _ } => {
CronSchedule::Cron { expr, tz } => {
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
// Standard 5-field: min hour dom month dow
// 6-field: sec min hour dom month dow
@@ -341,10 +381,33 @@ pub fn compute_next_run_after(
let base = after + Duration::seconds(1);
match seven_field.parse::<cron::Schedule>() {
Ok(sched) => sched
.after(&base)
.next()
.unwrap_or_else(|| after + Duration::hours(1)),
Ok(sched) => {
// If a timezone is specified, compute the next fire time in
// that timezone so DST and local offsets are respected, then
// convert back to UTC for storage.
let next_utc = match tz.as_deref() {
Some(tz_str) if !tz_str.is_empty() && tz_str != "UTC" => {
match tz_str.parse::<chrono_tz::Tz>() {
Ok(timezone) => {
let base_local = base.with_timezone(&timezone);
sched
.after(&base_local)
.next()
.map(|dt| dt.with_timezone(&Utc))
}
Err(_) => {
warn!(
"Invalid timezone '{}' in cron job, falling back to UTC",
tz_str
);
sched.after(&base).next()
}
}
}
_ => sched.after(&base).next(),
};
next_utc.unwrap_or_else(|| after + Duration::hours(1))
}
Err(e) => {
warn!("Failed to parse cron expression '{}': {}", expr, e);
after + Duration::hours(1)
@@ -361,7 +424,7 @@ pub fn compute_next_run_after(
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use chrono::{Duration, Timelike};
use openfang_types::scheduler::{CronAction, CronDelivery};
/// Build a minimal valid `CronJob` with an `Every` schedule.
@@ -787,4 +850,259 @@ mod tests {
status.len()
);
}
// -- timezone-aware cron (#473) -----------------------------------------
#[test]
fn test_cron_tz_shifts_next_run() {
// "0 9 * * *" in America/New_York (UTC-5 or UTC-4 depending on DST).
// The next fire time in UTC should differ from a plain UTC "0 9 * * *".
let schedule_utc = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let schedule_ny = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/New_York".into()),
};
let now = Utc::now();
let next_utc = compute_next_run_after(&schedule_utc, now);
let next_ny = compute_next_run_after(&schedule_ny, now);
// The New York schedule should fire at 09:00 Eastern, which is 13:00
// or 14:00 UTC (depending on DST). In either case, it should NOT
// equal the plain UTC 09:00 result.
assert_ne!(
next_utc, next_ny,
"Timezone-aware schedule should produce a different UTC time"
);
// Verify the New York result, when converted to ET, shows hour 09.
let ny_tz: chrono_tz::Tz = "America/New_York".parse().unwrap();
let next_ny_local = next_ny.with_timezone(&ny_tz);
assert_eq!(
next_ny_local.hour(),
9,
"Expected 09:00 in America/New_York, got {:02}:{:02}",
next_ny_local.hour(),
next_ny_local.minute()
);
}
#[test]
fn test_cron_tz_none_defaults_to_utc() {
// tz: None should behave identically to tz: Some("UTC").
let schedule_none = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: None,
};
let schedule_utc = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: Some("UTC".into()),
};
let now = Utc::now();
let next_none = compute_next_run_after(&schedule_none, now);
let next_utc = compute_next_run_after(&schedule_utc, now);
assert_eq!(next_none, next_utc);
}
#[test]
fn test_cron_tz_empty_string_defaults_to_utc() {
let schedule_empty = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: Some(String::new()),
};
let schedule_none = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: None,
};
let now = Utc::now();
assert_eq!(
compute_next_run_after(&schedule_empty, now),
compute_next_run_after(&schedule_none, now)
);
}
#[test]
fn test_cron_tz_invalid_falls_back_to_utc() {
// An invalid timezone string should fall back to UTC, not panic.
let schedule_bad = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("Not/A_Timezone".into()),
};
let schedule_utc = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let now = Utc::now();
let next_bad = compute_next_run_after(&schedule_bad, now);
let next_utc = compute_next_run_after(&schedule_utc, now);
// Invalid tz falls back to UTC computation — same result.
assert_eq!(next_bad, next_utc);
}
#[test]
fn test_cron_tz_asia_shanghai() {
// "0 8 * * *" in Asia/Shanghai (UTC+8) should fire at 00:00 UTC.
let schedule = CronSchedule::Cron {
expr: "0 8 * * *".into(),
tz: Some("Asia/Shanghai".into()),
};
let now = Utc::now();
let next = compute_next_run_after(&schedule, now);
let shanghai_tz: chrono_tz::Tz = "Asia/Shanghai".parse().unwrap();
let local = next.with_timezone(&shanghai_tz);
assert_eq!(local.hour(), 8);
assert_eq!(local.minute(), 0);
// In UTC, 08:00 Shanghai = 00:00 UTC.
assert_eq!(next.hour(), 0, "08:00 CST should be 00:00 UTC");
}
// -- reassign_agent_jobs (#461) -----------------------------------------
#[test]
fn test_reassign_agent_jobs_basic() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let mut j1 = make_job(old_agent);
j1.name = "cron-a".into();
let mut j2 = make_job(old_agent);
j2.name = "cron-b".into();
let id1 = sched.add_job(j1, false).unwrap();
let id2 = sched.add_job(j2, false).unwrap();
let count = sched.reassign_agent_jobs(old_agent, new_agent);
assert_eq!(count, 2);
// Both jobs should now belong to the new agent
let job1 = sched.get_job(id1).unwrap();
assert_eq!(job1.agent_id, new_agent);
let job2 = sched.get_job(id2).unwrap();
assert_eq!(job2.agent_id, new_agent);
// Old agent should have zero jobs
assert!(sched.list_jobs(old_agent).is_empty());
// New agent should have both
assert_eq!(sched.list_jobs(new_agent).len(), 2);
}
#[test]
fn test_reassign_agent_jobs_does_not_touch_other_agents() {
let (sched, _tmp) = make_scheduler(100);
let agent_a = AgentId::new();
let agent_b = AgentId::new();
let agent_c = AgentId::new();
let mut ja = make_job(agent_a);
ja.name = "job-a".into();
let mut jb = make_job(agent_b);
jb.name = "job-b".into();
let _id_a = sched.add_job(ja, false).unwrap();
let id_b = sched.add_job(jb, false).unwrap();
// Reassign agent_a -> agent_c
let count = sched.reassign_agent_jobs(agent_a, agent_c);
assert_eq!(count, 1);
// agent_b's job should be untouched
let job_b = sched.get_job(id_b).unwrap();
assert_eq!(job_b.agent_id, agent_b);
}
#[test]
fn test_reassign_agent_jobs_no_match_returns_zero() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let other = AgentId::new();
let job = make_job(agent);
sched.add_job(job, false).unwrap();
// Reassign a non-existent agent
let count = sched.reassign_agent_jobs(AgentId::new(), other);
assert_eq!(count, 0);
}
#[test]
fn test_reassign_agent_jobs_resets_consecutive_errors() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
// Simulate some failures
sched.record_failure(id, "agent not found");
sched.record_failure(id, "agent not found");
let meta = sched.get_meta(id).unwrap();
assert_eq!(meta.consecutive_errors, 2);
// Reassign
sched.reassign_agent_jobs(old_agent, new_agent);
// Errors should be reset
let meta = sched.get_meta(id).unwrap();
assert_eq!(meta.consecutive_errors, 0);
assert_eq!(meta.job.agent_id, new_agent);
}
#[test]
fn test_reassign_agent_jobs_reenables_disabled_stale_jobs() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
// Simulate enough failures to auto-disable (with "not found" message)
for _ in 0..MAX_CONSECUTIVE_ERRORS {
sched.record_failure(id, "No such agent");
}
let meta = sched.get_meta(id).unwrap();
assert!(!meta.job.enabled, "Job should be auto-disabled");
// Reassign should re-enable it
sched.reassign_agent_jobs(old_agent, new_agent);
let meta = sched.get_meta(id).unwrap();
assert!(meta.job.enabled, "Job should be re-enabled after reassignment");
assert_eq!(meta.consecutive_errors, 0);
assert_eq!(meta.job.agent_id, new_agent);
}
#[test]
fn test_reassign_agent_jobs_persists_after_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
// Create scheduler, add job, reassign, persist
let id = {
let sched = CronScheduler::new(tmp.path(), 100);
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
sched.reassign_agent_jobs(old_agent, new_agent);
sched.persist().unwrap();
id
};
// Load from disk and verify the agent_id was persisted
{
let sched = CronScheduler::new(tmp.path(), 100);
sched.load().unwrap();
let job = sched.get_job(id).unwrap();
assert_eq!(job.agent_id, new_agent);
assert!(sched.list_jobs(old_agent).is_empty());
}
}
}
+48 -28
View File
@@ -546,14 +546,20 @@ impl OpenFangKernel {
.map_err(|e| KernelError::BootFailed(format!("Memory init failed: {e}")))?,
);
// Create LLM driver
// Create LLM driver.
// For the API key, try: 1) explicit api_key_env from config, 2) provider_api_keys
// mapping, 3) auth profiles, 4) convention {PROVIDER}_API_KEY. This ensures
// custom providers (e.g. nvidia, azure) work without hardcoded env var names.
let default_api_key = if !config.default_model.api_key_env.is_empty() {
std::env::var(&config.default_model.api_key_env).ok()
} else {
// api_key_env not set — resolve using provider_api_keys / convention
let env_var = config.resolve_api_key_env(&config.default_model.provider);
std::env::var(&env_var).ok()
};
let driver_config = DriverConfig {
provider: config.default_model.provider.clone(),
api_key: if config.default_model.api_key_env.is_empty() {
None
} else {
std::env::var(&config.default_model.api_key_env).ok()
},
api_key: default_api_key,
base_url: config
.default_model
.base_url
@@ -609,13 +615,16 @@ impl OpenFangKernel {
model_chain.push((d.clone(), String::new()));
}
for fb in &config.fallback_providers {
let fb_api_key = if !fb.api_key_env.is_empty() {
std::env::var(&fb.api_key_env).ok()
} else {
// Resolve using provider_api_keys / convention for custom providers
let env_var = config.resolve_api_key_env(&fb.provider);
std::env::var(&env_var).ok()
};
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key: if fb.api_key_env.is_empty() {
None
} else {
std::env::var(&fb.api_key_env).ok()
},
api_key: fb_api_key,
base_url: fb
.base_url
.clone()
@@ -812,7 +821,8 @@ impl OpenFangKernel {
} else {
configured_model.as_str()
};
match create_embedding_driver("openai", model, "OPENAI_API_KEY", None) {
let openai_url = config.provider_urls.get("openai").map(|s| s.as_str());
match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) {
Ok(d) => {
info!("Embedding driver auto-detected: OpenAI");
Some(Arc::from(d))
@@ -3020,6 +3030,7 @@ impl OpenFangKernel {
// If an agent with this hand's name already exists, remove it first
let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name);
let old_agent_id = existing.as_ref().map(|e| e.id);
if let Some(old) = existing {
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
let _ = self.kill_agent(old.id);
@@ -3028,6 +3039,18 @@ impl OpenFangKernel {
// Spawn the agent
let agent_id = self.spawn_agent(manifest)?;
// Migrate cron jobs from old agent to new agent so they survive restarts.
// Without this, persisted cron jobs would reference the stale old UUID
// and fail silently (issue #461).
if let Some(old_id) = old_agent_id {
let migrated = self.cron_scheduler.reassign_agent_jobs(old_id, agent_id);
if migrated > 0 {
if let Err(e) = self.cron_scheduler.persist() {
warn!("Failed to persist cron jobs after agent migration: {e}");
}
}
}
// Link agent to instance
self.hand_registry
.set_agent(instance.instance_id, agent_id)
@@ -3944,18 +3967,11 @@ impl OpenFangKernel {
// Same provider — use default key
std::env::var(&self.config.default_model.api_key_env).ok()
} else {
// Different provider — check auth profiles first, then let
// create_driver() look up the correct env var automatically.
if let Some(profiles) = self.config.auth_profiles.get(agent_provider.as_str()) {
let mut sorted: Vec<_> = profiles.iter().collect();
sorted.sort_by_key(|p| p.priority);
sorted
.first()
.and_then(|best| std::env::var(&best.api_key_env).ok())
} else {
// Pass None — create_driver() has per-provider env var lookups
None
}
// Different provider — check auth profiles, provider_api_keys,
// and convention-based env var. For custom providers (not in the
// hardcoded list), this is the primary path for API key resolution.
let env_var = self.config.resolve_api_key_env(agent_provider);
std::env::var(&env_var).ok()
};
// Don't inherit default provider's base_url when switching providers
@@ -3989,12 +4005,16 @@ impl OpenFangKernel {
let mut chain: Vec<(std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>, String)> =
vec![(primary.clone(), String::new())];
for fb in &manifest.fallback_models {
let fb_api_key = if let Some(env) = &fb.api_key_env {
std::env::var(env).ok()
} else {
// Resolve using provider_api_keys / convention for custom providers
let env_var = self.config.resolve_api_key_env(&fb.provider);
std::env::var(&env_var).ok()
};
let config = DriverConfig {
provider: fb.provider.clone(),
api_key: fb
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok()),
api_key: fb_api_key,
base_url: fb
.base_url
.clone()
+8 -5
View File
@@ -382,16 +382,19 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── Zhipu / GLM ─────────────────────────────────────────────
if model.contains("glm-5") {
return (2.00, 8.00);
return (1.00, 3.20);
}
if model.contains("glm-4.7") {
return (1.50, 5.00);
return (0.60, 2.20);
}
if model.contains("glm-4-flash") {
return (0.10, 0.10);
if model.contains("glm-4-flash") || model.contains("glm-4.5-flash") {
return (0.0, 0.0); // free tier
}
if model.contains("glm-4.5") {
return (0.60, 2.20);
}
if model.contains("glm") {
return (1.50, 5.00);
return (0.60, 2.20);
}
if model.contains("codegeex") {
return (0.10, 0.10);
+1 -2
View File
@@ -54,8 +54,7 @@ const MAX_HISTORY_MESSAGES: usize = 20;
/// Strip a provider prefix from a model ID before sending to the API.
///
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
/// but the upstream API expects just `org/model`. This also handles special routers
/// like `openrouter/auto` → `auto`.
/// but the upstream API expects just `org/model` (e.g. `google/gemini-2.5-flash`).
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
let slash_prefix = format!("{}/", provider);
let colon_prefix = format!("{}:", provider);
+66 -34
View File
@@ -65,22 +65,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
}
// Find last newline before the cap to break cleanly (char-boundary safe)
let safe_cap = if content.is_char_boundary(cap) {
cap
} else {
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
let search_start = safe_cap.saturating_sub(200);
let break_point = content[search_start..safe_cap]
let mut safe_cap = cap.min(content.len());
while safe_cap > 0 && !content.is_char_boundary(safe_cap) {
safe_cap -= 1;
}
let mut search_start = safe_cap.saturating_sub(200);
// Ensure search_start is a valid char boundary
while search_start > 0 && !content.is_char_boundary(search_start) {
search_start -= 1;
}
let mut break_point = content[search_start..safe_cap]
.rfind('\n')
.map(|pos| search_start + pos)
.unwrap_or(safe_cap.saturating_sub(100));
// Ensure break_point is also a char boundary
let break_point = if content.is_char_boundary(break_point) {
break_point
} else {
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
while break_point > 0 && !content.is_char_boundary(break_point) {
break_point -= 1;
}
format!(
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
@@ -201,28 +202,16 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
if content.len() <= max_chars {
return content.to_string();
}
let keep = max_chars.saturating_sub(80).min(content.len());
// Ensure keep is a valid char boundary
let keep = if content.is_char_boundary(keep) {
keep
} else {
content[..keep]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
};
let search_start = keep.saturating_sub(100);
// Ensure search_start is a valid char boundary
let search_start = if content.is_char_boundary(search_start) {
search_start
} else {
content[..search_start]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
};
let mut keep = max_chars.saturating_sub(80).min(content.len());
// Walk back to a valid char boundary
while keep > 0 && !content.is_char_boundary(keep) {
keep -= 1;
}
let mut search_start = keep.saturating_sub(100);
// Walk back to a valid char boundary
while search_start > 0 && !content.is_char_boundary(search_start) {
search_start -= 1;
}
// Try to break at newline
let break_point = content[search_start..keep]
.rfind('\n')
@@ -319,4 +308,47 @@ mod tests {
}
}
}
#[test]
fn test_truncate_tool_result_multibyte_chinese() {
// Tiny budget: cap = 30% of 100 * 2.0 = 60 bytes
let budget = ContextBudget::new(100);
// Each Chinese char is 3 bytes in UTF-8; 100 chars = 300 bytes
let content: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(25);
assert_eq!(content.len(), 300);
// Must not panic on multi-byte content
let result = truncate_tool_result_dynamic(&content, &budget);
assert!(result.contains("[TRUNCATED:"));
// The visible portion must be valid UTF-8 (implicit: no panic)
assert!(result.is_char_boundary(0));
}
#[test]
fn test_truncate_to_multibyte_emoji() {
// Each emoji is 4 bytes; 200 emojis = 800 bytes
let content: String = "\u{1f600}".repeat(200);
let result = truncate_to(&content, 100);
assert!(result.contains("[COMPACTED:"));
// Must not panic and must produce valid UTF-8
assert!(result.is_char_boundary(0));
}
#[test]
fn test_context_guard_multibyte_tool_results() {
let budget = ContextBudget::new(100);
// Chinese text: 500 chars * 3 bytes = 1500 bytes
let big_chinese: String = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}\u{6570}\u{636e}".repeat(83);
let mut messages = vec![Message {
role: openfang_types::message::Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: String::new(),
content: big_chinese,
is_error: false,
}]),
}];
// Must not panic on multi-byte content
let compacted = apply_context_guard(&mut messages, &budget, &[]);
assert!(compacted > 0);
}
}
@@ -102,13 +102,11 @@ pub fn recover_from_overflow(
for block in blocks.iter_mut() {
if let ContentBlock::ToolResult { content, .. } = block {
if content.len() > tool_truncation_limit {
let keep = tool_truncation_limit.saturating_sub(80);
// Find a valid char boundary at or before `keep`
let safe_keep = if content.is_char_boundary(keep) {
keep
} else {
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
let mut safe_keep = tool_truncation_limit.saturating_sub(80);
// Walk back to a valid char boundary
while safe_keep > 0 && !content.is_char_boundary(safe_keep) {
safe_keep -= 1;
}
*content = format!(
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
&content[..safe_keep],
@@ -244,4 +242,26 @@ mod tests {
// we should cascade through stages
assert_ne!(stage, RecoveryStage::None);
}
#[test]
fn test_stage3_multibyte_tool_truncation() {
// Chinese text (3 bytes per char) in tool results must not panic
let chinese_result: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(1250); // 5000 chars, 15000 bytes
let mut msgs = vec![
Message::user("hi"),
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: String::new(),
content: chinese_result,
is_error: false,
}]),
},
];
// Tiny context window to force stage 3 tool truncation
let stage = recover_from_overflow(&mut msgs, "system", &[], 500);
// Must not panic — the truncation at byte boundaries could split a 3-byte char
assert_ne!(stage, RecoveryStage::None);
}
}
@@ -201,21 +201,23 @@ pub async fn exec_in_sandbox(
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
// Truncate large outputs
// Truncate large outputs (char-boundary safe to avoid UTF-8 panics)
let max_output = 50_000;
let stdout = if stdout.len() > max_output {
let safe_end = crate::str_utils::safe_truncate_str(&stdout, max_output);
format!(
"{}... [truncated, {} total bytes]",
&stdout[..max_output],
safe_end,
stdout.len()
)
} else {
stdout
};
let stderr = if stderr.len() > max_output {
let safe_end = crate::str_utils::safe_truncate_str(&stderr, max_output);
format!(
"{}... [truncated, {} total bytes]",
&stderr[..max_output],
safe_end,
stderr.len()
)
} else {
+85 -3
View File
@@ -341,15 +341,40 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
}
// Unknown provider — if base_url is set, treat as custom OpenAI-compatible
// Unknown provider — if base_url is set, treat as custom OpenAI-compatible.
// For custom providers, try the convention {PROVIDER_UPPER}_API_KEY as env var
// when no explicit api_key was passed. This lets users just set e.g. NVIDIA_API_KEY
// in their environment and use provider = "nvidia" without extra config.
if let Some(ref base_url) = config.base_url {
let api_key = config.api_key.clone().unwrap_or_default();
let api_key = config.api_key.clone().unwrap_or_else(|| {
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
std::env::var(&env_var).unwrap_or_default()
});
return Ok(Arc::new(openai::OpenAIDriver::new(
api_key,
base_url.clone(),
)));
}
// No base_url either — last resort: check if the user set an API key env var
// using the convention {PROVIDER_UPPER}_API_KEY. If found, use OpenAI-compatible
// driver with a default base URL derived from common patterns.
{
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
if let Ok(api_key) = std::env::var(&env_var) {
if !api_key.is_empty() {
return Err(LlmError::Api {
status: 0,
message: format!(
"Provider '{}' has API key ({} is set) but no base_url configured. \
Add base_url to your [default_model] config or set it in [provider_urls].",
provider, env_var
),
});
}
}
}
Err(LlmError::Api {
status: 0,
message: format!(
@@ -374,7 +399,7 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat
("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"),
("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"),
("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"),
("openrouter", "openrouter/anthropic/claude-sonnet-4", "OPENROUTER_API_KEY"),
("openrouter", "openrouter/google/gemini-2.5-flash", "OPENROUTER_API_KEY"),
("mistral", "mistral-large-latest", "MISTRAL_API_KEY"),
("together", "meta-llama/Llama-3-70b-chat-hf", "TOGETHER_API_KEY"),
("fireworks", "accounts/fireworks/models/llama-v3p1-70b-instruct", "FIREWORKS_API_KEY"),
@@ -564,4 +589,61 @@ mod tests {
assert_eq!(d.api_key_env, "HF_API_KEY");
assert!(d.key_required);
}
#[test]
fn test_custom_provider_convention_env_var() {
// Set NVIDIA_API_KEY env var, then create a custom "nvidia" provider with base_url.
// The driver should pick up the key automatically via convention.
let unique_key = "test-nvidia-key-12345";
std::env::set_var("NVIDIA_API_KEY", unique_key);
let config = DriverConfig {
provider: "nvidia".to_string(),
api_key: None, // not explicitly passed
base_url: Some("https://integrate.api.nvidia.com/v1".to_string()),
};
let driver = create_driver(&config);
assert!(driver.is_ok(), "Custom provider with env var convention should succeed");
std::env::remove_var("NVIDIA_API_KEY");
}
#[test]
fn test_custom_provider_no_key_no_url_errors() {
// Custom provider with neither API key nor base_url should error.
let config = DriverConfig {
provider: "nvidia".to_string(),
api_key: None,
base_url: None,
};
let driver = create_driver(&config);
assert!(driver.is_err());
}
#[test]
fn test_custom_provider_key_no_url_helpful_error() {
// Custom provider with key set (via env) but no base_url should give helpful error.
let unique_key = "test-nvidia-key-67890";
std::env::set_var("NVIDIA_API_KEY", unique_key);
let config = DriverConfig {
provider: "nvidia".to_string(),
api_key: None,
base_url: None,
};
let result = create_driver(&config);
assert!(result.is_err());
let err = result.err().unwrap().to_string();
assert!(err.contains("base_url"), "Error should mention base_url: {}", err);
std::env::remove_var("NVIDIA_API_KEY");
}
#[test]
fn test_custom_provider_explicit_key_with_url() {
// When api_key is explicitly passed, it should be used regardless of env var.
let config = DriverConfig {
provider: "my-custom-provider".to_string(),
api_key: Some("explicit-key".to_string()),
base_url: Some("https://api.example.com/v1".to_string()),
};
let driver = create_driver(&config);
assert!(driver.is_ok());
}
}
+16 -2
View File
@@ -275,10 +275,19 @@ impl LlmDriver for OpenAIDriver {
_ => {}
}
}
let has_tool_calls = !tool_calls.is_empty();
oai_messages.push(OaiMessage {
role: "assistant".to_string(),
// ZHIPU (GLM) rejects assistant messages where content is
// null or omitted when tool_calls are present (error 1214).
// Always send an empty string so every OpenAI-compat
// provider gets a valid payload.
content: if text_parts.is_empty() {
None
if has_tool_calls {
Some(OaiMessageContent::Text(String::new()))
} else {
None
}
} else {
Some(OaiMessageContent::Text(text_parts.join("")))
},
@@ -615,10 +624,15 @@ impl LlmDriver for OpenAIDriver {
_ => {}
}
}
let has_tool_calls = !tool_calls_out.is_empty();
oai_messages.push(OaiMessage {
role: "assistant".to_string(),
content: if text_parts.is_empty() {
None
if has_tool_calls {
Some(OaiMessageContent::Text(String::new()))
} else {
None
}
} else {
Some(OaiMessageContent::Text(text_parts.join("")))
},
+58 -1
View File
@@ -189,7 +189,28 @@ pub fn create_embedding_driver(
let base_url = custom_base_url
.filter(|u| !u.is_empty())
.map(|u| u.to_string())
.map(|u| {
let trimmed = u.trim_end_matches('/');
// All OpenAI-compatible embedding providers need /v1 in the path.
// If the user supplied a bare host URL (e.g. "http://192.168.0.1:11434"),
// append /v1 so the final request hits {base}/v1/embeddings.
let needs_v1 = matches!(
provider,
"openai"
| "groq"
| "together"
| "fireworks"
| "mistral"
| "ollama"
| "vllm"
| "lmstudio"
);
if needs_v1 && !trimmed.ends_with("/v1") {
format!("{trimmed}/v1")
} else {
trimmed.to_string()
}
})
.unwrap_or_else(|| match provider {
"openai" => OPENAI_BASE_URL.to_string(),
"groq" => GROQ_BASE_URL.to_string(),
@@ -359,4 +380,40 @@ mod tests {
assert!(driver.is_ok());
assert_eq!(driver.unwrap().dimensions(), 384);
}
#[test]
fn test_create_embedding_driver_custom_url_with_v1() {
// Custom URL already containing /v1 should be used as-is
let driver = create_embedding_driver(
"ollama",
"nomic-embed-text",
"",
Some("http://192.168.0.1:11434/v1"),
);
assert!(driver.is_ok());
}
#[test]
fn test_create_embedding_driver_custom_url_without_v1() {
// Custom URL missing /v1 should get it appended for known providers
let driver = create_embedding_driver(
"ollama",
"nomic-embed-text",
"",
Some("http://192.168.0.1:11434"),
);
assert!(driver.is_ok());
}
#[test]
fn test_create_embedding_driver_custom_url_trailing_slash() {
// Trailing slash should be trimmed before appending /v1
let driver = create_embedding_driver(
"ollama",
"nomic-embed-text",
"",
Some("http://192.168.0.1:11434/"),
);
assert!(driver.is_ok());
}
}
+84 -56
View File
@@ -1477,17 +1477,17 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenRouter (11)
// OpenRouter (10) — pass-through models using real upstream IDs
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "openrouter/openai/gpt-4o".into(),
display_name: "GPT-4o (OpenRouter)".into(),
id: "openrouter/google/gemini-2.5-flash".into(),
display_name: "Gemini 2.5 Flash (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 16_384,
input_cost_per_m: 2.5,
output_cost_per_m: 10.0,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
@@ -1508,50 +1508,92 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/google/gemini-2.5-flash".into(),
display_name: "Gemini 2.5 Flash (OpenRouter)".into(),
id: "openrouter/openai/gpt-4o".into(),
display_name: "GPT-4o (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
context_window: 128_000,
max_output_tokens: 16_384,
input_cost_per_m: 2.5,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/meta-llama/llama-3.3-70b-instruct".into(),
display_name: "Llama 3.3 70B (OpenRouter, free)".into(),
id: "openrouter/deepseek/deepseek-chat".into(),
display_name: "DeepSeek V3 (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
input_cost_per_m: 0.14,
output_cost_per_m: 0.28,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/mistralai/mistral-7b-instruct".into(),
display_name: "Mistral 7B (OpenRouter, free)".into(),
id: "openrouter/meta-llama/llama-3.3-70b-instruct".into(),
display_name: "Llama 3.3 70B (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 32_768,
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.39,
output_cost_per_m: 0.39,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/qwen/qwen-2.5-72b-instruct".into(),
display_name: "Qwen 2.5 72B (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.36,
output_cost_per_m: 0.36,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/google/gemini-2.5-pro".into(),
display_name: "Gemini 2.5 Pro (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 1.25,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/mistralai/mistral-large-latest".into(),
display_name: "Mistral Large (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
input_cost_per_m: 2.0,
output_cost_per_m: 6.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/google/gemma-2-9b-it".into(),
display_name: "Gemma 2 9B (OpenRouter, free)".into(),
display_name: "Gemma 2 9B (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 8_192,
@@ -1564,29 +1606,15 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/qwen/qwen-2.5-72b-instruct".into(),
display_name: "Qwen 2.5 72B (OpenRouter, free)".into(),
id: "openrouter/deepseek/deepseek-r1".into(),
display_name: "DeepSeek R1 (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
tier: ModelTier::Frontier,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/deepseek/deepseek-chat-v3-0324".into(),
display_name: "DeepSeek V3 0324 (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.14,
output_cost_per_m: 0.28,
supports_tools: true,
input_cost_per_m: 0.55,
output_cost_per_m: 2.19,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
@@ -2730,8 +2758,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 1.50,
output_cost_per_m: 5.00,
input_cost_per_m: 0.60,
output_cost_per_m: 2.20,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
@@ -2744,8 +2772,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
tier: ModelTier::Fast,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.10,
output_cost_per_m: 0.10,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
@@ -2758,8 +2786,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
tier: ModelTier::Smart,
context_window: 8_192,
max_output_tokens: 4_096,
input_cost_per_m: 2.00,
output_cost_per_m: 5.00,
input_cost_per_m: 0.60,
output_cost_per_m: 2.20,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
@@ -2786,8 +2814,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
tier: ModelTier::Frontier,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 2.00,
output_cost_per_m: 8.00,
input_cost_per_m: 1.00,
output_cost_per_m: 3.20,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
@@ -2800,8 +2828,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 1.50,
output_cost_per_m: 5.00,
input_cost_per_m: 0.60,
output_cost_per_m: 2.20,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
+111 -5
View File
@@ -2,8 +2,13 @@
//!
//! Probes local providers (Ollama, vLLM, LM Studio) for reachability and
//! dynamically discovers which models they currently serve.
//!
//! Includes a [`ProbeCache`] with configurable TTL so that the `/api/providers`
//! endpoint returns instantly on repeated dashboard loads instead of blocking
//! on TCP connect timeouts to unreachable local services.
use std::time::Instant;
use dashmap::DashMap;
use std::time::{Duration, Instant};
/// Result of probing a provider endpoint.
#[derive(Debug, Clone, Default)]
@@ -28,8 +33,61 @@ pub fn is_local_provider(provider: &str) -> bool {
)
}
/// Probe timeout for local provider health checks.
const PROBE_TIMEOUT_SECS: u64 = 5;
/// Overall request timeout for local provider health probes (connect + response).
const PROBE_TIMEOUT_SECS: u64 = 2;
/// TCP connect timeout — fail fast when the local port is not listening.
const PROBE_CONNECT_TIMEOUT_SECS: u64 = 1;
/// Default TTL for cached probe results (seconds).
const PROBE_CACHE_TTL_SECS: u64 = 60;
// ── Probe cache ──────────────────────────────────────────────────────────
/// Thread-safe cache for provider probe results.
///
/// Entries expire after [`PROBE_CACHE_TTL_SECS`] seconds. The cache is
/// designed to be stored once in `AppState` and shared across requests.
pub struct ProbeCache {
inner: DashMap<String, (Instant, ProbeResult)>,
ttl: Duration,
}
impl ProbeCache {
/// Create a new cache with the default 60-second TTL.
pub fn new() -> Self {
Self {
inner: DashMap::new(),
ttl: Duration::from_secs(PROBE_CACHE_TTL_SECS),
}
}
/// Look up a cached probe result. Returns `None` if missing or expired.
pub fn get(&self, provider_id: &str) -> Option<ProbeResult> {
if let Some(entry) = self.inner.get(provider_id) {
let (ts, ref result) = *entry;
if ts.elapsed() < self.ttl {
return Some(result.clone());
}
// Expired — drop the read guard before removing
drop(entry);
self.inner.remove(provider_id);
}
None
}
/// Store a probe result.
pub fn insert(&self, provider_id: &str, result: ProbeResult) {
self.inner
.insert(provider_id.to_string(), (Instant::now(), result));
}
}
impl Default for ProbeCache {
fn default() -> Self {
Self::new()
}
}
/// Probe a provider's health by hitting its model listing endpoint.
///
@@ -42,7 +100,8 @@ pub async fn probe_provider(provider: &str, base_url: &str) -> ProbeResult {
let start = Instant::now();
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(PROBE_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(PROBE_CONNECT_TIMEOUT_SECS))
.timeout(Duration::from_secs(PROBE_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
@@ -138,6 +197,23 @@ pub async fn probe_provider(provider: &str, base_url: &str) -> ProbeResult {
}
}
/// Probe a provider, returning a cached result when available.
///
/// If the cache contains a non-expired entry the HTTP request is skipped
/// entirely, making repeated `/api/providers` calls instantaneous.
pub async fn probe_provider_cached(
provider: &str,
base_url: &str,
cache: &ProbeCache,
) -> ProbeResult {
if let Some(cached) = cache.get(provider) {
return cached;
}
let result = probe_provider(provider, base_url).await;
cache.insert(provider, result.clone());
result
}
/// Lightweight model probe -- sends a minimal completion request to verify a model is responsive.
///
/// Unlike `probe_provider` which checks the listing endpoint, this actually sends
@@ -230,7 +306,8 @@ mod tests {
#[test]
fn test_probe_timeout_value() {
assert_eq!(PROBE_TIMEOUT_SECS, 5);
assert_eq!(PROBE_TIMEOUT_SECS, 2);
assert_eq!(PROBE_CONNECT_TIMEOUT_SECS, 1);
}
#[test]
@@ -254,4 +331,33 @@ mod tests {
let result = probe_model("test", "http://127.0.0.1:19998/v1", "test-model", None).await;
assert!(result.is_err());
}
#[test]
fn test_probe_cache_miss_returns_none() {
let cache = ProbeCache::new();
assert!(cache.get("ollama").is_none());
}
#[test]
fn test_probe_cache_hit_returns_result() {
let cache = ProbeCache::new();
let result = ProbeResult {
reachable: true,
latency_ms: 42,
discovered_models: vec!["llama3".into()],
error: None,
};
cache.insert("ollama", result.clone());
let cached = cache.get("ollama").expect("should be cached");
assert!(cached.reachable);
assert_eq!(cached.latency_ms, 42);
assert_eq!(cached.discovered_models, vec!["llama3".to_string()]);
}
#[test]
fn test_probe_cache_default() {
let cache = ProbeCache::default();
assert!(cache.get("anything").is_none());
assert_eq!(cache.ttl, Duration::from_secs(PROBE_CACHE_TTL_SECS));
}
}
+105
View File
@@ -1062,6 +1062,12 @@ pub struct KernelConfig {
/// e.g. `ollama = "http://192.168.1.100:11434/v1"`
#[serde(default)]
pub provider_urls: HashMap<String, String>,
/// Provider API key env var overrides (provider ID → env var name).
/// For custom/unknown providers, maps the provider name to the environment
/// variable holding the API key. e.g. `nvidia = "NVIDIA_API_KEY"`.
/// If not set, the convention `{PROVIDER_UPPER}_API_KEY` is used automatically.
#[serde(default)]
pub provider_api_keys: HashMap<String, String>,
/// OAuth client ID overrides for PKCE flows.
#[serde(default)]
pub oauth: OAuthConfig,
@@ -1235,6 +1241,7 @@ impl Default for KernelConfig {
thinking: None,
budget: BudgetConfig::default(),
provider_urls: HashMap::new(),
provider_api_keys: HashMap::new(),
oauth: OAuthConfig::default(),
}
}
@@ -1247,6 +1254,27 @@ impl KernelConfig {
.clone()
.unwrap_or_else(|| self.home_dir.join("workspaces"))
}
/// Resolve the API key env var name for a provider.
///
/// Checks: 1) explicit `provider_api_keys` mapping, 2) `auth_profiles` first entry,
/// 3) convention `{PROVIDER_UPPER}_API_KEY`.
pub fn resolve_api_key_env(&self, provider: &str) -> String {
// 1. Explicit mapping in [provider_api_keys]
if let Some(env_var) = self.provider_api_keys.get(provider) {
return env_var.clone();
}
// 2. Auth profiles (first profile by priority)
if let Some(profiles) = self.auth_profiles.get(provider) {
let mut sorted: Vec<_> = profiles.iter().collect();
sorted.sort_by_key(|p| p.priority);
if let Some(best) = sorted.first() {
return best.api_key_env.clone();
}
}
// 3. Convention: NVIDIA → NVIDIA_API_KEY
format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"))
}
}
/// SECURITY: Custom Debug impl redacts sensitive fields (api_key).
@@ -1328,6 +1356,10 @@ impl std::fmt::Debug for KernelConfig {
&format!("{} provider(s)", self.auth_profiles.len()),
)
.field("thinking", &self.thinking.is_some())
.field(
"provider_api_keys",
&format!("{} mapping(s)", self.provider_api_keys.len()),
)
.finish()
}
}
@@ -3641,4 +3673,77 @@ mod tests {
assert_eq!(config.web.fetch.max_response_bytes, fetch_bytes);
assert_eq!(config.web.fetch.timeout_secs, fetch_timeout);
}
#[test]
fn test_resolve_api_key_env_convention() {
let config = KernelConfig::default();
// Unknown provider falls back to convention
assert_eq!(config.resolve_api_key_env("nvidia"), "NVIDIA_API_KEY");
assert_eq!(config.resolve_api_key_env("my-custom"), "MY_CUSTOM_API_KEY");
}
#[test]
fn test_resolve_api_key_env_explicit_mapping() {
let mut config = KernelConfig::default();
config
.provider_api_keys
.insert("nvidia".to_string(), "NIM_KEY".to_string());
// Explicit mapping takes precedence over convention
assert_eq!(config.resolve_api_key_env("nvidia"), "NIM_KEY");
}
#[test]
fn test_resolve_api_key_env_auth_profiles() {
let mut config = KernelConfig::default();
config.auth_profiles.insert(
"nvidia".to_string(),
vec![AuthProfile {
name: "primary".to_string(),
api_key_env: "NVIDIA_PRIMARY_KEY".to_string(),
priority: 0,
}],
);
// Auth profiles take precedence over convention (but not explicit mapping)
assert_eq!(
config.resolve_api_key_env("nvidia"),
"NVIDIA_PRIMARY_KEY"
);
}
#[test]
fn test_resolve_api_key_env_explicit_over_auth_profile() {
let mut config = KernelConfig::default();
config
.provider_api_keys
.insert("nvidia".to_string(), "NIM_KEY".to_string());
config.auth_profiles.insert(
"nvidia".to_string(),
vec![AuthProfile {
name: "primary".to_string(),
api_key_env: "NVIDIA_PRIMARY_KEY".to_string(),
priority: 0,
}],
);
// Explicit mapping wins over auth profiles
assert_eq!(config.resolve_api_key_env("nvidia"), "NIM_KEY");
}
#[test]
fn test_provider_api_keys_toml_roundtrip() {
let toml_str = r#"
[provider_api_keys]
nvidia = "NVIDIA_NIM_KEY"
azure = "AZURE_OPENAI_KEY"
"#;
let config: KernelConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.provider_api_keys.len(), 2);
assert_eq!(
config.provider_api_keys.get("nvidia").unwrap(),
"NVIDIA_NIM_KEY"
);
assert_eq!(
config.provider_api_keys.get("azure").unwrap(),
"AZURE_OPENAI_KEY"
);
}
}
+1 -1
View File
@@ -1343,7 +1343,7 @@ The following providers are recognized by `openfang config set-key` and `openfan
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |
| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
| OpenAI | `OPENAI_API_KEY` | `gpt-4o` |
| OpenRouter | `OPENROUTER_API_KEY` | `openrouter/auto` |
| OpenRouter | `OPENROUTER_API_KEY` | `openrouter/google/gemini-2.5-flash` |
| Together | `TOGETHER_API_KEY` | -- |
| Mistral | `MISTRAL_API_KEY` | -- |
| Fireworks | `FIREWORKS_API_KEY` | -- |
+47 -33
View File
@@ -185,19 +185,26 @@ For Gemini specifically, either `GEMINI_API_KEY` or `GOOGLE_API_KEY` will work.
| **Key Required** | Yes |
| **Free Tier** | Yes (limited credits for some models) |
| **Auth** | `Authorization: Bearer` header |
| **Models** | 3 |
| **Models** | 10 |
**Available Models:**
- `openrouter/auto` (Smart) -- auto-selects best model
- `openrouter/optimus` (Balanced) -- cost-optimized
- `openrouter/nitro` (Fast) -- speed-optimized
- `openrouter/google/gemini-2.5-flash` (Smart) -- cheap, fast, 1M context (default)
- `openrouter/anthropic/claude-sonnet-4` (Smart) -- strong reasoning + tools
- `openrouter/openai/gpt-4o` (Smart) -- GPT-4o via OpenRouter
- `openrouter/deepseek/deepseek-chat` (Smart) -- DeepSeek V3
- `openrouter/meta-llama/llama-3.3-70b-instruct` (Balanced) -- Llama 3.3 70B
- `openrouter/qwen/qwen-2.5-72b-instruct` (Balanced) -- Qwen 2.5 72B
- `openrouter/google/gemini-2.5-pro` (Frontier) -- Gemini 2.5 Pro
- `openrouter/mistralai/mistral-large-latest` (Smart) -- Mistral Large
- `openrouter/google/gemma-2-9b-it` (Fast) -- Gemma 2 9B, free
- `openrouter/deepseek/deepseek-r1` (Frontier) -- DeepSeek R1 reasoning
**Setup:**
1. Sign up at [openrouter.ai](https://openrouter.ai)
2. Create an API key under Keys
3. `export OPENROUTER_API_KEY="sk-or-..."`
**Notes:** OpenRouter is a unified gateway to 200+ models from many providers. The three builtin entries are OpenRouter's smart-routing endpoints. You can also use any model ID from their catalog directly by specifying the full OpenRouter model path.
**Notes:** OpenRouter is a unified gateway to 200+ models from many providers. Model IDs use the upstream format (e.g. `google/gemini-2.5-flash`). You can use any model from OpenRouter's catalog by specifying the full model path with the `openrouter/` prefix.
---
@@ -566,34 +573,41 @@ The complete catalog of all 51 builtin models, sorted by provider. Pricing is pe
| 16 | `mixtral-8x7b-32768` | Mixtral 8x7B | groq | Balanced | 32,768 | 4,096 | $0.024 | $0.024 | Yes | No |
| 17 | `llama-3.1-8b-instant` | Llama 3.1 8B | groq | Fast | 128,000 | 8,192 | $0.05 | $0.08 | Yes | No |
| 18 | `gemma2-9b-it` | Gemma 2 9B | groq | Fast | 8,192 | 4,096 | $0.02 | $0.02 | No | No |
| 19 | `openrouter/auto` | OpenRouter Auto | openrouter | Smart | 200,000 | 32,000 | $1.00 | $3.00 | Yes | Yes |
| 20 | `openrouter/optimus` | OpenRouter Optimus | openrouter | Balanced | 200,000 | 32,000 | $0.50 | $1.50 | Yes | No |
| 21 | `openrouter/nitro` | OpenRouter Nitro | openrouter | Fast | 128,000 | 16,000 | $0.20 | $0.60 | Yes | No |
| 22 | `mistral-large-latest` | Mistral Large | mistral | Smart | 128,000 | 8,192 | $2.00 | $6.00 | Yes | No |
| 23 | `codestral-latest` | Codestral | mistral | Smart | 32,000 | 8,192 | $0.30 | $0.90 | Yes | No |
| 24 | `mistral-small-latest` | Mistral Small | mistral | Fast | 128,000 | 8,192 | $0.10 | $0.30 | Yes | No |
| 25 | `meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo` | Llama 3.1 405B (Together) | together | Frontier | 130,000 | 4,096 | $3.50 | $3.50 | Yes | No |
| 26 | `Qwen/Qwen2.5-72B-Instruct-Turbo` | Qwen 2.5 72B (Together) | together | Smart | 32,768 | 4,096 | $0.20 | $0.60 | Yes | No |
| 27 | `mistralai/Mixtral-8x22B-Instruct-v0.1` | Mixtral 8x22B (Together) | together | Balanced | 65,536 | 4,096 | $0.60 | $0.60 | Yes | No |
| 28 | `accounts/fireworks/models/llama-v3p1-405b-instruct` | Llama 3.1 405B (Fireworks) | fireworks | Frontier | 131,072 | 16,384 | $3.00 | $3.00 | Yes | No |
| 29 | `accounts/fireworks/models/mixtral-8x22b-instruct` | Mixtral 8x22B (Fireworks) | fireworks | Balanced | 65,536 | 4,096 | $0.90 | $0.90 | Yes | No |
| 30 | `llama3.2` | Llama 3.2 (Ollama) | ollama | Local | 128,000 | 4,096 | $0.00 | $0.00 | Yes | No |
| 31 | `mistral:latest` | Mistral (Ollama) | ollama | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 32 | `phi3` | Phi-3 (Ollama) | ollama | Local | 128,000 | 4,096 | $0.00 | $0.00 | No | No |
| 33 | `vllm-local` | vLLM Local Model | vllm | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 34 | `lmstudio-local` | LM Studio Local Model | lmstudio | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 35 | `sonar-pro` | Sonar Pro | perplexity | Smart | 200,000 | 8,192 | $3.00 | $15.00 | No | No |
| 36 | `sonar` | Sonar | perplexity | Balanced | 128,000 | 8,192 | $1.00 | $5.00 | No | No |
| 37 | `command-r-plus` | Command R+ | cohere | Smart | 128,000 | 4,096 | $2.50 | $10.00 | Yes | No |
| 38 | `command-r` | Command R | cohere | Balanced | 128,000 | 4,096 | $0.15 | $0.60 | Yes | No |
| 39 | `jamba-1.5-large` | Jamba 1.5 Large | ai21 | Smart | 256,000 | 4,096 | $2.00 | $8.00 | Yes | No |
| 40 | `cerebras/llama3.3-70b` | Llama 3.3 70B (Cerebras) | cerebras | Balanced | 128,000 | 8,192 | $0.06 | $0.06 | Yes | No |
| 41 | `cerebras/llama3.1-8b` | Llama 3.1 8B (Cerebras) | cerebras | Fast | 128,000 | 8,192 | $0.01 | $0.01 | Yes | No |
| 42 | `sambanova/llama-3.3-70b` | Llama 3.3 70B (SambaNova) | sambanova | Balanced | 128,000 | 8,192 | $0.06 | $0.06 | Yes | No |
| 43 | `grok-2` | Grok 2 | xai | Smart | 131,072 | 32,768 | $2.00 | $10.00 | Yes | Yes |
| 44 | `grok-2-mini` | Grok 2 Mini | xai | Fast | 131,072 | 32,768 | $0.30 | $0.50 | Yes | No |
| 45 | `hf/meta-llama/Llama-3.3-70B-Instruct` | Llama 3.3 70B (HF) | huggingface | Balanced | 128,000 | 4,096 | $0.30 | $0.30 | No | No |
| 46 | `replicate/meta-llama-3.3-70b-instruct` | Llama 3.3 70B (Replicate) | replicate | Balanced | 128,000 | 4,096 | $0.40 | $0.40 | No | No |
| 19 | `openrouter/google/gemini-2.5-flash` | Gemini 2.5 Flash (OpenRouter) | openrouter | Smart | 1,048,576 | 65,536 | $0.15 | $0.60 | Yes | Yes |
| 20 | `openrouter/anthropic/claude-sonnet-4` | Claude Sonnet 4 (OpenRouter) | openrouter | Smart | 200,000 | 64,000 | $3.00 | $15.00 | Yes | Yes |
| 21 | `openrouter/openai/gpt-4o` | GPT-4o (OpenRouter) | openrouter | Smart | 128,000 | 16,384 | $2.50 | $10.00 | Yes | Yes |
| 22 | `openrouter/deepseek/deepseek-chat` | DeepSeek V3 (OpenRouter) | openrouter | Smart | 128,000 | 32,768 | $0.14 | $0.28 | Yes | No |
| 23 | `openrouter/meta-llama/llama-3.3-70b-instruct` | Llama 3.3 70B (OpenRouter) | openrouter | Balanced | 128,000 | 32,768 | $0.39 | $0.39 | Yes | No |
| 24 | `openrouter/qwen/qwen-2.5-72b-instruct` | Qwen 2.5 72B (OpenRouter) | openrouter | Balanced | 128,000 | 32,768 | $0.36 | $0.36 | Yes | No |
| 25 | `openrouter/google/gemini-2.5-pro` | Gemini 2.5 Pro (OpenRouter) | openrouter | Frontier | 1,048,576 | 65,536 | $1.25 | $10.00 | Yes | Yes |
| 26 | `openrouter/mistralai/mistral-large-latest` | Mistral Large (OpenRouter) | openrouter | Smart | 128,000 | 8,192 | $2.00 | $6.00 | Yes | No |
| 27 | `openrouter/google/gemma-2-9b-it` | Gemma 2 9B (OpenRouter) | openrouter | Fast | 8,192 | 4,096 | $0.00 | $0.00 | No | No |
| 28 | `openrouter/deepseek/deepseek-r1` | DeepSeek R1 (OpenRouter) | openrouter | Frontier | 128,000 | 32,768 | $0.55 | $2.19 | No | No |
| 29 | `mistral-large-latest` | Mistral Large | mistral | Smart | 128,000 | 8,192 | $2.00 | $6.00 | Yes | No |
| 30 | `codestral-latest` | Codestral | mistral | Smart | 32,000 | 8,192 | $0.30 | $0.90 | Yes | No |
| 31 | `mistral-small-latest` | Mistral Small | mistral | Fast | 128,000 | 8,192 | $0.10 | $0.30 | Yes | No |
| 32 | `meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo` | Llama 3.1 405B (Together) | together | Frontier | 130,000 | 4,096 | $3.50 | $3.50 | Yes | No |
| 33 | `Qwen/Qwen2.5-72B-Instruct-Turbo` | Qwen 2.5 72B (Together) | together | Smart | 32,768 | 4,096 | $0.20 | $0.60 | Yes | No |
| 34 | `mistralai/Mixtral-8x22B-Instruct-v0.1` | Mixtral 8x22B (Together) | together | Balanced | 65,536 | 4,096 | $0.60 | $0.60 | Yes | No |
| 35 | `accounts/fireworks/models/llama-v3p1-405b-instruct` | Llama 3.1 405B (Fireworks) | fireworks | Frontier | 131,072 | 16,384 | $3.00 | $3.00 | Yes | No |
| 36 | `accounts/fireworks/models/mixtral-8x22b-instruct` | Mixtral 8x22B (Fireworks) | fireworks | Balanced | 65,536 | 4,096 | $0.90 | $0.90 | Yes | No |
| 37 | `llama3.2` | Llama 3.2 (Ollama) | ollama | Local | 128,000 | 4,096 | $0.00 | $0.00 | Yes | No |
| 38 | `mistral:latest` | Mistral (Ollama) | ollama | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 39 | `phi3` | Phi-3 (Ollama) | ollama | Local | 128,000 | 4,096 | $0.00 | $0.00 | No | No |
| 40 | `vllm-local` | vLLM Local Model | vllm | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 41 | `lmstudio-local` | LM Studio Local Model | lmstudio | Local | 32,768 | 4,096 | $0.00 | $0.00 | Yes | No |
| 42 | `sonar-pro` | Sonar Pro | perplexity | Smart | 200,000 | 8,192 | $3.00 | $15.00 | No | No |
| 43 | `sonar` | Sonar | perplexity | Balanced | 128,000 | 8,192 | $1.00 | $5.00 | No | No |
| 44 | `command-r-plus` | Command R+ | cohere | Smart | 128,000 | 4,096 | $2.50 | $10.00 | Yes | No |
| 45 | `command-r` | Command R | cohere | Balanced | 128,000 | 4,096 | $0.15 | $0.60 | Yes | No |
| 46 | `jamba-1.5-large` | Jamba 1.5 Large | ai21 | Smart | 256,000 | 4,096 | $2.00 | $8.00 | Yes | No |
| 47 | `cerebras/llama3.3-70b` | Llama 3.3 70B (Cerebras) | cerebras | Balanced | 128,000 | 8,192 | $0.06 | $0.06 | Yes | No |
| 48 | `cerebras/llama3.1-8b` | Llama 3.1 8B (Cerebras) | cerebras | Fast | 128,000 | 8,192 | $0.01 | $0.01 | Yes | No |
| 49 | `sambanova/llama-3.3-70b` | Llama 3.3 70B (SambaNova) | sambanova | Balanced | 128,000 | 8,192 | $0.06 | $0.06 | Yes | No |
| 50 | `grok-2` | Grok 2 | xai | Smart | 131,072 | 32,768 | $2.00 | $10.00 | Yes | Yes |
| 51 | `grok-2-mini` | Grok 2 Mini | xai | Fast | 131,072 | 32,768 | $0.30 | $0.50 | Yes | No |
| 52 | `hf/meta-llama/Llama-3.3-70B-Instruct` | Llama 3.3 70B (HF) | huggingface | Balanced | 128,000 | 4,096 | $0.30 | $0.30 | No | No |
| 53 | `replicate/meta-llama-3.3-70b-instruct` | Llama 3.3 70B (Replicate) | replicate | Balanced | 128,000 | 4,096 | $0.40 | $0.40 | No | No |
**Model Tiers:**
+12 -8
View File
@@ -145,21 +145,25 @@ install() {
*/bash) SHELL_RC="$HOME/.bashrc" ;;
*/fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
esac
# Also check for config files if shell detection failed
# Also check for config files if shell detection failed.
# Check bash/zsh first (more common defaults), fish last — avoids
# writing to config.fish for users who merely have Fish installed.
if [ -z "$SHELL_RC" ]; then
if [ -f "$HOME/.config/fish/config.fish" ]; then
SHELL_RC="$HOME/.config/fish/config.fish"
USER_SHELL="/usr/bin/fish"
if [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
elif [ -f "$HOME/.zshrc" ]; then
SHELL_RC="$HOME/.zshrc"
elif [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
elif [ -f "$HOME/.config/fish/config.fish" ]; then
SHELL_RC="$HOME/.config/fish/config.fish"
fi
fi
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
case "$USER_SHELL" in
*/fish)
# Determine syntax from the TARGET FILE, not $USER_SHELL — this
# prevents Bash syntax from ever being written to config.fish even
# when shell detection mis-identifies the user's shell.
case "$SHELL_RC" in
*/config.fish)
mkdir -p "$(dirname "$SHELL_RC")"
echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC"
;;