mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
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:
Generated
+29
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}")})
|
||||
|
||||
@@ -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'; },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("")))
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user