From ad10aa5e805772d5e66439fd96aca9beecdc7ccd Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Mon, 9 Mar 2026 21:19:50 +0300 Subject: [PATCH] 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. --- Cargo.lock | 29 ++ Cargo.toml | 3 +- crates/openfang-api/src/channel_bridge.rs | 11 +- crates/openfang-api/src/routes.rs | 93 +++-- crates/openfang-api/src/server.rs | 1 + crates/openfang-api/src/stream_chunker.rs | 18 +- crates/openfang-api/src/ws.rs | 17 +- crates/openfang-api/static/index_body.html | 14 +- crates/openfang-api/static/js/api.js | 11 + crates/openfang-api/static/js/pages/agents.js | 9 +- crates/openfang-api/static/js/pages/chat.js | 16 +- crates/openfang-api/static/js/pages/wizard.js | 2 +- .../tests/api_integration_test.rs | 2 + .../tests/daemon_lifecycle_test.rs | 2 + crates/openfang-api/tests/load_test.rs | 1 + crates/openfang-cli/src/main.rs | 2 +- .../src/tui/screens/init_wizard.rs | 2 +- crates/openfang-cli/src/tui/screens/memory.rs | 10 +- crates/openfang-cli/src/tui/screens/peers.rs | 5 +- .../openfang-cli/src/tui/screens/sessions.rs | 5 +- crates/openfang-cli/src/tui/screens/wizard.rs | 2 +- crates/openfang-kernel/Cargo.toml | 1 + crates/openfang-kernel/src/config_reload.rs | 4 + crates/openfang-kernel/src/cron.rs | 330 +++++++++++++++++- crates/openfang-kernel/src/kernel.rs | 76 ++-- crates/openfang-kernel/src/metering.rs | 13 +- crates/openfang-runtime/src/agent_loop.rs | 3 +- crates/openfang-runtime/src/context_budget.rs | 100 ++++-- .../openfang-runtime/src/context_overflow.rs | 34 +- crates/openfang-runtime/src/docker_sandbox.rs | 8 +- crates/openfang-runtime/src/drivers/mod.rs | 88 ++++- crates/openfang-runtime/src/drivers/openai.rs | 18 +- crates/openfang-runtime/src/embedding.rs | 59 +++- crates/openfang-runtime/src/model_catalog.rs | 140 +++++--- .../openfang-runtime/src/provider_health.rs | 116 +++++- crates/openfang-types/src/config.rs | 105 ++++++ docs/cli-reference.md | 2 +- docs/providers.md | 80 +++-- scripts/install.sh | 20 +- 39 files changed, 1196 insertions(+), 256 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7eba1e66..c03087ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8dfcf481..7802c64f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index dc85fa0b..9abfc743 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -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 { diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 99994ab5..0c4f73a8 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -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, + /// 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>) -> impl IntoResponse { let provider_list: Vec = { let catalog = state @@ -5541,9 +5549,34 @@ pub async fn list_providers(State(state): State>) -> 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 = + 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 = 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>) -> 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>) -> 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"})), - ); } } } diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a87b2519..87e78fa8 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -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 diff --git a/crates/openfang-api/src/stream_chunker.rs b/crates/openfang-api/src/stream_chunker.rs index e63f980d..c8cdf7b8 100644 --- a/crates/openfang-api/src/stream_chunker.rs +++ b/crates/openfang-api/src/stream_chunker.rs @@ -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) -> Option { - 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)] diff --git a/crates/openfang-api/src/ws.rs b/crates/openfang-api/src/ws.rs index a516ba8e..ebdd8183 100644 --- a/crates/openfang-api/src/ws.rs +++ b/crates/openfang-api/src/ws.rs @@ -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}")}) diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index 03b92424..a09ad358 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -3372,11 +3372,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
@@ -3464,8 +3464,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"] 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"] 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'; }, diff --git a/crates/openfang-api/static/js/api.js b/crates/openfang-api/static/js/api.js index 5a828059..08d6a44f 100644 --- a/crates/openfang-api/static/js/api.js +++ b/crates/openfang-api/static/js/api.js @@ -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 { diff --git a/crates/openfang-api/static/js/pages/agents.js b/crates/openfang-api/static/js/pages/agents.js index 2313ee30..041b13a4 100644 --- a/crates/openfang-api/static/js/pages/agents.js +++ b/crates/openfang-api/static/js/pages/agents.js @@ -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 diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js index 31b54e14..c5555dbb 100644 --- a/crates/openfang-api/static/js/pages/chat.js +++ b/crates/openfang-api/static/js/pages/chat.js @@ -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 { diff --git a/crates/openfang-api/static/js/pages/wizard.js b/crates/openfang-api/static/js/pages/wizard.js index 4ba59e9d..2f785e9a 100644 --- a/crates/openfang-api/static/js/pages/wizard.js +++ b/crates/openfang-api/static/js/pages/wizard.js @@ -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', diff --git a/crates/openfang-api/tests/api_integration_test.rs b/crates/openfang-api/tests/api_integration_test.rs index cef9cd90..a168985a 100644 --- a/crates/openfang-api/tests/api_integration_test.rs +++ b/crates/openfang-api/tests/api_integration_test.rs @@ -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(); diff --git a/crates/openfang-api/tests/daemon_lifecycle_test.rs b/crates/openfang-api/tests/daemon_lifecycle_test.rs index 3db1e27b..cbe27d41 100644 --- a/crates/openfang-api/tests/daemon_lifecycle_test.rs +++ b/crates/openfang-api/tests/daemon_lifecycle_test.rs @@ -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() diff --git a/crates/openfang-api/tests/load_test.rs b/crates/openfang-api/tests/load_test.rs index 27ebbf46..89080db7 100644 --- a/crates/openfang-api/tests/load_test.rs +++ b/crates/openfang-api/tests/load_test.rs @@ -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() diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 78cfdbec..004d3d38 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -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", ), ] diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index fb73578e..6c678aba 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -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: "", }, diff --git a/crates/openfang-cli/src/tui/screens/memory.rs b/crates/openfang-cli/src/tui/screens/memory.rs index e8bea52d..644e27d9 100644 --- a/crates/openfang-cli/src/tui/screens/memory.rs +++ b/crates/openfang-cli/src/tui/screens/memory.rs @@ -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() }; diff --git a/crates/openfang-cli/src/tui/screens/peers.rs b/crates/openfang-cli/src/tui/screens/peers.rs index d9b3a95c..fb063140 100644 --- a/crates/openfang-cli/src/tui/screens/peers.rs +++ b/crates/openfang-cli/src/tui/screens/peers.rs @@ -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() }; diff --git a/crates/openfang-cli/src/tui/screens/sessions.rs b/crates/openfang-cli/src/tui/screens/sessions.rs index 1c47893a..e40940f3 100644 --- a/crates/openfang-cli/src/tui/screens/sessions.rs +++ b/crates/openfang-cli/src/tui/screens/sessions.rs @@ -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() }; diff --git a/crates/openfang-cli/src/tui/screens/wizard.rs b/crates/openfang-cli/src/tui/screens/wizard.rs index 4ca4a136..2028a37a 100644 --- a/crates/openfang-cli/src/tui/screens/wizard.rs +++ b/crates/openfang-cli/src/tui/screens/wizard.rs @@ -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 { diff --git a/crates/openfang-kernel/Cargo.toml b/crates/openfang-kernel/Cargo.toml index b7074a85..dbbb27da 100644 --- a/crates/openfang-kernel/Cargo.toml +++ b/crates/openfang-kernel/Cargo.toml @@ -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 } diff --git a/crates/openfang-kernel/src/config_reload.rs b/crates/openfang-kernel/src/config_reload.rs index b194db45..eec9d327 100644 --- a/crates/openfang-kernel/src/config_reload.rs +++ b/crates/openfang-kernel/src/config_reload.rs @@ -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 { diff --git a/crates/openfang-kernel/src/cron.rs b/crates/openfang-kernel/src/cron.rs index d304190a..de10524b 100644 --- a/crates/openfang-kernel/src/cron.rs +++ b/crates/openfang-kernel/src/cron.rs @@ -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::() { - 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::() { + 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()); + } + } } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index dca5331c..40efa62b 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -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, 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() diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index 8a87a057..a42ca616 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -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); diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 327f9d64..4a84c040 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -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); diff --git a/crates/openfang-runtime/src/context_budget.rs b/crates/openfang-runtime/src/context_budget.rs index cbf84490..d11f69bd 100644 --- a/crates/openfang-runtime/src/context_budget.rs +++ b/crates/openfang-runtime/src/context_budget.rs @@ -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); + } } diff --git a/crates/openfang-runtime/src/context_overflow.rs b/crates/openfang-runtime/src/context_overflow.rs index 69ce02f7..eb5ad1ab 100644 --- a/crates/openfang-runtime/src/context_overflow.rs +++ b/crates/openfang-runtime/src/context_overflow.rs @@ -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); + } } diff --git a/crates/openfang-runtime/src/docker_sandbox.rs b/crates/openfang-runtime/src/docker_sandbox.rs index 95eb2480..bdb73ccd 100644 --- a/crates/openfang-runtime/src/docker_sandbox.rs +++ b/crates/openfang-runtime/src/docker_sandbox.rs @@ -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 { diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 9285a3aa..be20ee26 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -341,15 +341,40 @@ pub fn create_driver(config: &DriverConfig) -> Result, 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()); + } } diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 58c64299..77beed68 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -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(""))) }, diff --git a/crates/openfang-runtime/src/embedding.rs b/crates/openfang-runtime/src/embedding.rs index fea4857c..c3245d87 100644 --- a/crates/openfang-runtime/src/embedding.rs +++ b/crates/openfang-runtime/src/embedding.rs @@ -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()); + } } diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 1df4eb3c..67dd66fd 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -1477,17 +1477,17 @@ fn builtin_models() -> Vec { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, diff --git a/crates/openfang-runtime/src/provider_health.rs b/crates/openfang-runtime/src/provider_health.rs index 144f9b3d..b9990bbe 100644 --- a/crates/openfang-runtime/src/provider_health.rs +++ b/crates/openfang-runtime/src/provider_health.rs @@ -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, + 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 { + 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)); + } } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 7285835c..9d2e25ee 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1062,6 +1062,12 @@ pub struct KernelConfig { /// e.g. `ollama = "http://192.168.1.100:11434/v1"` #[serde(default)] pub provider_urls: HashMap, + /// 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, /// 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" + ); + } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e2a5f4f0..8cff5c75 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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` | -- | diff --git a/docs/providers.md b/docs/providers.md index 631bda5d..9b3ccb59 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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:** diff --git a/scripts/install.sh b/scripts/install.sh index 439a3fec..787b8311 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" ;;