mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c1e154d9 | ||
|
|
036cc14ad5 | ||
|
|
b866ae7055 | ||
|
|
15a859b7f9 | ||
|
|
eda88ba5a2 |
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -4842,6 +4842,7 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
|
||||
"model_count": p.model_count,
|
||||
"key_required": p.key_required,
|
||||
"api_key_env": p.api_key_env,
|
||||
"base_url": p.base_url,
|
||||
});
|
||||
|
||||
// For local providers, add reachability info via health probe
|
||||
@@ -5899,6 +5900,122 @@ pub async fn test_provider(
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/providers/{name}/url — Set a custom base URL for a provider.
|
||||
pub async fn set_provider_url(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate provider exists
|
||||
let provider_exists = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.get_provider(&name).is_some()
|
||||
};
|
||||
if !provider_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
|
||||
);
|
||||
}
|
||||
|
||||
let base_url = match body["base_url"].as_str() {
|
||||
Some(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate URL scheme
|
||||
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "base_url must start with http:// or https://"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Update catalog in memory
|
||||
{
|
||||
let mut catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.set_provider_url(&name, &base_url);
|
||||
}
|
||||
|
||||
// Persist to config.toml [provider_urls] section
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
|
||||
);
|
||||
}
|
||||
|
||||
// Probe reachability at the new URL
|
||||
let probe =
|
||||
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "saved",
|
||||
"provider": name,
|
||||
"base_url": base_url,
|
||||
"reachable": probe.reachable,
|
||||
"latency_ms": probe.latency_ms,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Upsert a provider URL in the `[provider_urls]` section of config.toml.
|
||||
fn upsert_provider_url(
|
||||
config_path: &std::path::Path,
|
||||
provider: &str,
|
||||
url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let content = if config_path.exists() {
|
||||
std::fs::read_to_string(config_path)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut doc: toml::Value = if content.trim().is_empty() {
|
||||
toml::Value::Table(toml::map::Map::new())
|
||||
} else {
|
||||
toml::from_str(&content)?
|
||||
};
|
||||
|
||||
let root = doc.as_table_mut().ok_or("Config is not a TOML table")?;
|
||||
|
||||
if !root.contains_key("provider_urls") {
|
||||
root.insert(
|
||||
"provider_urls".to_string(),
|
||||
toml::Value::Table(toml::map::Map::new()),
|
||||
);
|
||||
}
|
||||
let urls_table = root
|
||||
.get_mut("provider_urls")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
.ok_or("provider_urls is not a table")?;
|
||||
|
||||
urls_table.insert(provider.to_string(), toml::Value::String(url.to_string()));
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
std::fs::write(config_path, toml::to_string_pretty(&doc)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/skills/create — Create a local prompt-only skill.
|
||||
pub async fn create_skill(
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
||||
@@ -460,6 +460,10 @@ pub async fn build_router(
|
||||
"/api/providers/{name}/test",
|
||||
axum::routing::post(routes::test_provider),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/url",
|
||||
axum::routing::put(routes::set_provider_url),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/create",
|
||||
axum::routing::post(routes::create_skill),
|
||||
|
||||
@@ -1111,6 +1111,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
"Model unavailable. Use /model to see options.".to_string()
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2886,6 +2886,19 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="!p.api_key_env || p.key_required === false">
|
||||
<div class="text-xs mt-2" style="color:var(--success)" x-show="p.auth_status !== 'configured' && p.auth_status !== 'not_set' && p.auth_status !== 'missing'">No API key needed — runs locally or is free</div>
|
||||
</template>
|
||||
<!-- Base URL editor for local providers -->
|
||||
<template x-if="p.is_local">
|
||||
<div class="mt-3" style="border-top:1px solid var(--border);padding-top:8px">
|
||||
<div class="text-xs text-dim mb-1">Base URL</div>
|
||||
<div class="key-input-group">
|
||||
<input type="text" :placeholder="'http://localhost:...'" x-model="providerUrlInputs[p.id]" style="font-size:12px">
|
||||
<button class="btn btn-primary btn-sm" @click="saveProviderUrl(p)" :disabled="providerUrlSaving[p.id]">
|
||||
<span x-show="!providerUrlSaving[p.id]">Save</span>
|
||||
<span x-show="providerUrlSaving[p.id]" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ function settingsPage() {
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
loading: true,
|
||||
@@ -208,6 +210,12 @@ function settingsPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
for (var i = 0; i < this.providers.length; i++) {
|
||||
var p = this.providers[i];
|
||||
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url;
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -378,6 +386,28 @@ function settingsPage() {
|
||||
this.providerTesting[provider.id] = false;
|
||||
},
|
||||
|
||||
async saveProviderUrl(provider) {
|
||||
var url = this.providerUrlInputs[provider.id];
|
||||
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
url = url.trim();
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.providerUrlSaving[provider.id] = true;
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
|
||||
if (result.reachable) {
|
||||
OpenFangToast.success(provider.display_name + ' URL saved — reachable (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
|
||||
}
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save URL: ' + e.message);
|
||||
}
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -41,6 +41,8 @@ pub enum HotAction {
|
||||
ReloadA2aConfig,
|
||||
/// Fallback provider chain changed.
|
||||
ReloadFallbackProviders,
|
||||
/// Provider base URL overrides changed.
|
||||
ReloadProviderUrls,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -235,6 +237,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
plan.hot_actions.push(HotAction::ReloadFallbackProviders);
|
||||
}
|
||||
|
||||
if field_changed(&old.provider_urls, &new.provider_urls) {
|
||||
plan.hot_actions.push(HotAction::ReloadProviderUrls);
|
||||
}
|
||||
|
||||
// ----- No-op fields -----
|
||||
|
||||
if old.log_level != new.log_level {
|
||||
@@ -461,6 +467,17 @@ mod tests {
|
||||
assert!(plan.hot_actions.contains(&HotAction::ReloadExtensions));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_urls_hot_reload() {
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.provider_urls
|
||||
.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string());
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(!plan.restart_required);
|
||||
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mixed changes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -590,9 +590,16 @@ impl OpenFangKernel {
|
||||
info!("RBAC enabled with {} users", auth.user_count());
|
||||
}
|
||||
|
||||
// Initialize model catalog and detect provider auth
|
||||
// Initialize model catalog, detect provider auth, and apply URL overrides
|
||||
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
||||
model_catalog.detect_auth();
|
||||
if !config.provider_urls.is_empty() {
|
||||
model_catalog.apply_url_overrides(&config.provider_urls);
|
||||
info!(
|
||||
"applied {} provider URL override(s)",
|
||||
config.provider_urls.len()
|
||||
);
|
||||
}
|
||||
let available_count = model_catalog.available_models().len();
|
||||
let total_count = model_catalog.list_models().len();
|
||||
let local_count = model_catalog
|
||||
@@ -2762,6 +2769,14 @@ impl OpenFangKernel {
|
||||
self.cron_scheduler
|
||||
.set_max_total_jobs(new_config.max_cron_jobs);
|
||||
}
|
||||
HotAction::ReloadProviderUrls => {
|
||||
info!("Hot-reload: applying provider URL overrides");
|
||||
let mut catalog = self
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.apply_url_overrides(&new_config.provider_urls);
|
||||
}
|
||||
_ => {
|
||||
// Other hot actions (channels, web, browser, extensions, etc.)
|
||||
// are logged but not applied here — they require subsystem-specific
|
||||
@@ -4050,7 +4065,18 @@ impl OpenFangKernel {
|
||||
tool_names.join(", ")
|
||||
));
|
||||
}
|
||||
summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.");
|
||||
summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n");
|
||||
// Add filesystem-specific guidance when a filesystem MCP server is connected
|
||||
let has_filesystem = servers.keys().any(|s| s.contains("filesystem"));
|
||||
if has_filesystem {
|
||||
summary.push_str(
|
||||
"IMPORTANT: For accessing files OUTSIDE your workspace directory, you MUST use \
|
||||
the MCP filesystem tools (e.g. mcp_filesystem_read_file, mcp_filesystem_list_directory) \
|
||||
instead of the built-in file_read/file_list/file_write tools, which are restricted to \
|
||||
the workspace. The MCP filesystem server has been granted access to specific directories \
|
||||
by the user.",
|
||||
);
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
|
||||
@@ -148,20 +148,33 @@ impl MeteringEngine {
|
||||
/// | Model Family | Input $/M | Output $/M |
|
||||
/// |-----------------------|-----------|------------|
|
||||
/// | claude-haiku | 0.25 | 1.25 |
|
||||
/// | claude-sonnet | 3.00 | 15.00 |
|
||||
/// | claude-opus | 15.00 | 75.00 |
|
||||
/// | claude-sonnet-4-6 | 3.00 | 15.00 |
|
||||
/// | claude-opus-4-6 | 5.00 | 25.00 |
|
||||
/// | claude-opus (legacy) | 15.00 | 75.00 |
|
||||
/// | gpt-5.2(-pro) | 1.75 | 14.00 |
|
||||
/// | gpt-5(.1) | 1.25 | 10.00 |
|
||||
/// | gpt-5-mini | 0.25 | 2.00 |
|
||||
/// | gpt-5-nano | 0.05 | 0.40 |
|
||||
/// | gpt-4o | 2.50 | 10.00 |
|
||||
/// | gpt-4o-mini | 0.15 | 0.60 |
|
||||
/// | gpt-4.1 | 2.00 | 8.00 |
|
||||
/// | gpt-4.1-mini | 0.40 | 1.60 |
|
||||
/// | gpt-4.1-nano | 0.10 | 0.40 |
|
||||
/// | o3-mini | 1.10 | 4.40 |
|
||||
/// | gemini-2.0-flash | 0.10 | 0.40 |
|
||||
/// | gemini-3.1 | 2.50 | 15.00 |
|
||||
/// | gemini-3 | 0.50 | 3.00 |
|
||||
/// | gemini-2.5-flash-lite | 0.04 | 0.15 |
|
||||
/// | gemini-2.5-pro | 1.25 | 10.00 |
|
||||
/// | gemini-2.5-flash | 0.15 | 0.60 |
|
||||
/// | gemini-2.0-flash | 0.10 | 0.40 |
|
||||
/// | deepseek-chat/v3 | 0.27 | 1.10 |
|
||||
/// | deepseek-reasoner/r1 | 0.55 | 2.19 |
|
||||
/// | llama-4-maverick | 0.50 | 0.77 |
|
||||
/// | llama-4-scout | 0.11 | 0.34 |
|
||||
/// | llama/mixtral (groq) | 0.05 | 0.10 |
|
||||
/// | grok-4.1 | 0.20 | 0.50 |
|
||||
/// | grok-4 | 3.00 | 15.00 |
|
||||
/// | grok-3 | 3.00 | 15.00 |
|
||||
/// | qwen | 0.20 | 0.60 |
|
||||
/// | mistral-large | 2.00 | 6.00 |
|
||||
/// | mistral-small | 0.10 | 0.30 |
|
||||
@@ -222,14 +235,38 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
if model.contains("haiku") {
|
||||
return (0.25, 1.25);
|
||||
}
|
||||
if model.contains("opus-4-6") || model.contains("claude-opus-4-6") {
|
||||
return (5.0, 25.0);
|
||||
}
|
||||
if model.contains("opus") {
|
||||
return (15.0, 75.0);
|
||||
}
|
||||
if model.contains("sonnet-4-6") || model.contains("claude-sonnet-4-6") {
|
||||
return (3.0, 15.0);
|
||||
}
|
||||
if model.contains("sonnet") {
|
||||
return (3.0, 15.0);
|
||||
}
|
||||
|
||||
// ── OpenAI ─────────────────────────────────────────────────
|
||||
if model.contains("gpt-5.2-pro") {
|
||||
return (1.75, 14.0);
|
||||
}
|
||||
if model.contains("gpt-5.2") {
|
||||
return (1.75, 14.0);
|
||||
}
|
||||
if model.contains("gpt-5.1") {
|
||||
return (1.25, 10.0);
|
||||
}
|
||||
if model.contains("gpt-5-nano") {
|
||||
return (0.05, 0.40);
|
||||
}
|
||||
if model.contains("gpt-5-mini") {
|
||||
return (0.25, 2.0);
|
||||
}
|
||||
if model.contains("gpt-5") {
|
||||
return (1.25, 10.0);
|
||||
}
|
||||
if model.contains("gpt-4o-mini") {
|
||||
return (0.15, 0.60);
|
||||
}
|
||||
@@ -260,6 +297,15 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── Google Gemini ──────────────────────────────────────────
|
||||
if model.contains("gemini-3.1") {
|
||||
return (2.50, 15.0);
|
||||
}
|
||||
if model.contains("gemini-3") {
|
||||
return (0.50, 3.0);
|
||||
}
|
||||
if model.contains("gemini-2.5-flash-lite") {
|
||||
return (0.04, 0.15);
|
||||
}
|
||||
if model.contains("gemini-2.5-pro") {
|
||||
return (1.25, 10.0);
|
||||
}
|
||||
@@ -298,6 +344,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
}
|
||||
if model.contains("llama-4-scout") {
|
||||
return (0.11, 0.34);
|
||||
}
|
||||
if model.contains("llama") || model.contains("mixtral") {
|
||||
return (0.05, 0.10);
|
||||
}
|
||||
@@ -374,6 +426,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── xAI / Grok ──────────────────────────────────────────────
|
||||
if model.contains("grok-4.1") {
|
||||
return (0.20, 0.50);
|
||||
}
|
||||
if model.contains("grok-4") {
|
||||
return (3.0, 15.0);
|
||||
}
|
||||
if model.contains("grok-3-mini") || model.contains("grok-2-mini") || model.contains("grok-mini")
|
||||
{
|
||||
return (0.30, 0.50);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! 3. Minimal fallback without LLM (when summarization is unavailable)
|
||||
|
||||
use crate::llm_driver::{CompletionRequest, LlmDriver};
|
||||
use crate::str_utils::safe_truncate_str;
|
||||
use openfang_memory::session::Session;
|
||||
use openfang_types::message::{ContentBlock, Message, MessageContent, Role};
|
||||
use openfang_types::tool::ToolDefinition;
|
||||
@@ -342,7 +343,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
if oversized {
|
||||
let limit = config.max_chunk_chars / 4;
|
||||
let truncated = if s.len() > limit {
|
||||
format!("{}...[truncated from {} chars]", &s[..limit], s.len())
|
||||
format!("{}...[truncated from {} chars]", safe_truncate_str(s, limit), s.len())
|
||||
} else {
|
||||
s.clone()
|
||||
};
|
||||
@@ -361,7 +362,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
let limit = config.max_chunk_chars / 4;
|
||||
conversation_text.push_str(&format!(
|
||||
"{role_label}: {}...[truncated from {} chars]\n\n",
|
||||
&text[..limit],
|
||||
safe_truncate_str(text, limit),
|
||||
text.len()
|
||||
));
|
||||
} else {
|
||||
@@ -373,7 +374,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
let input_str = serde_json::to_string(input).unwrap_or_default();
|
||||
let input_preview = if input_str.len() > 200 {
|
||||
format!("{}...", &input_str[..200])
|
||||
format!("{}...", safe_truncate_str(&input_str, 200))
|
||||
} else {
|
||||
input_str
|
||||
};
|
||||
@@ -388,7 +389,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
// Strip base64 blobs and injection markers before compaction
|
||||
let cleaned = crate::session_repair::strip_tool_result_details(content);
|
||||
let preview = if cleaned.len() > 2000 {
|
||||
format!("{}...", &cleaned[..2000])
|
||||
format!("{}...", safe_truncate_str(&cleaned, 2000))
|
||||
} else {
|
||||
cleaned
|
||||
};
|
||||
@@ -886,7 +887,7 @@ mod tests {
|
||||
assert!(input_str.len() > 200);
|
||||
// Just verify the truncation logic works correctly
|
||||
let preview = if input_str.len() > 200 {
|
||||
format!("{}...", &input_str[..200])
|
||||
format!("{}...", safe_truncate_str(&input_str, 200))
|
||||
} else {
|
||||
input_str.clone()
|
||||
};
|
||||
|
||||
@@ -45,11 +45,7 @@ pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult,
|
||||
let status = response.status();
|
||||
let error_body = response.text().await.unwrap_or_default();
|
||||
// SECURITY: don't include full error body which might contain key info
|
||||
let truncated = if error_body.len() > 500 {
|
||||
&error_body[..500]
|
||||
} else {
|
||||
&error_body
|
||||
};
|
||||
let truncated = crate::str_utils::safe_truncate_str(&error_body, 500);
|
||||
return Err(format!(
|
||||
"Image generation failed (HTTP {}): {}",
|
||||
status, truncated
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod routing;
|
||||
pub mod sandbox;
|
||||
pub mod session_repair;
|
||||
pub mod shell_bleed;
|
||||
pub mod str_utils;
|
||||
pub mod subprocess_sandbox;
|
||||
pub mod tool_policy;
|
||||
pub mod tool_runner;
|
||||
|
||||
@@ -331,15 +331,20 @@ pub fn sanitize_for_user(category: LlmErrorCategory, _raw: &str) -> String {
|
||||
"The conversation is too long for the model's context window."
|
||||
}
|
||||
LlmErrorCategory::Format => {
|
||||
"Invalid request format. This may be a bug \u{2014} please report it."
|
||||
"LLM request failed. Check your API key and model configuration in Settings."
|
||||
}
|
||||
LlmErrorCategory::ModelNotFound => {
|
||||
"The requested model was not found. Check the model name."
|
||||
}
|
||||
};
|
||||
// Cap at 200 chars (all built-in messages are under 200, but defensive).
|
||||
if msg.len() > 200 {
|
||||
format!("{}...", &msg[..197])
|
||||
if msg.chars().count() > 200 {
|
||||
let end = msg
|
||||
.char_indices()
|
||||
.nth(197)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(msg.len());
|
||||
format!("{}...", &msg[..end])
|
||||
} else {
|
||||
msg.to_string()
|
||||
}
|
||||
|
||||
@@ -520,11 +520,7 @@ impl LoopGuard {
|
||||
let params_str = serde_json::to_string(params).unwrap_or_default();
|
||||
hasher.update(params_str.as_bytes());
|
||||
hasher.update(b"|");
|
||||
let truncated = if result.len() > 1000 {
|
||||
&result[..1000]
|
||||
} else {
|
||||
result
|
||||
};
|
||||
let truncated = crate::str_utils::safe_truncate_str(result, 1000);
|
||||
hasher.update(truncated.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
@@ -128,6 +128,28 @@ impl ModelCatalog {
|
||||
&self.aliases
|
||||
}
|
||||
|
||||
/// Set a custom base URL for a provider, overriding the default.
|
||||
///
|
||||
/// Returns `true` if the provider was found and updated.
|
||||
pub fn set_provider_url(&mut self, provider: &str, url: &str) -> bool {
|
||||
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
|
||||
p.base_url = url.to_string();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of provider URL overrides from config.
|
||||
///
|
||||
/// Each entry maps a provider ID to a custom base URL.
|
||||
/// Unknown providers are silently skipped.
|
||||
pub fn apply_url_overrides(&mut self, overrides: &HashMap<String, String>) {
|
||||
for (provider, url) in overrides {
|
||||
self.set_provider_url(provider, url);
|
||||
}
|
||||
}
|
||||
|
||||
/// List models filtered by tier.
|
||||
pub fn models_by_tier(&self, tier: ModelTier) -> Vec<&ModelCatalogEntry> {
|
||||
self.models.iter().filter(|m| m.tier == tier).collect()
|
||||
@@ -446,18 +468,20 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
|
||||
fn builtin_aliases() -> HashMap<String, String> {
|
||||
let pairs = [
|
||||
("sonnet", "claude-sonnet-4-20250514"),
|
||||
("claude-sonnet", "claude-sonnet-4-20250514"),
|
||||
("sonnet", "claude-sonnet-4-6"),
|
||||
("claude-sonnet", "claude-sonnet-4-6"),
|
||||
("haiku", "claude-haiku-4-5-20251001"),
|
||||
("claude-haiku", "claude-haiku-4-5-20251001"),
|
||||
("opus", "claude-opus-4-20250514"),
|
||||
("claude-opus", "claude-opus-4-20250514"),
|
||||
("opus", "claude-opus-4-6"),
|
||||
("claude-opus", "claude-opus-4-6"),
|
||||
("gpt4", "gpt-4o"),
|
||||
("gpt4o", "gpt-4o"),
|
||||
("gpt4-mini", "gpt-4o-mini"),
|
||||
("flash", "gemini-2.5-flash"),
|
||||
("gemini-flash", "gemini-2.5-flash"),
|
||||
("gemini-pro", "gemini-2.5-pro"),
|
||||
("gpt5", "gpt-5.2"),
|
||||
("gpt5-mini", "gpt-5-mini"),
|
||||
("flash", "gemini-3-flash"),
|
||||
("gemini-flash", "gemini-3-flash"),
|
||||
("gemini-pro", "gemini-3.1-pro"),
|
||||
("deepseek", "deepseek-chat"),
|
||||
("llama", "llama-3.3-70b-versatile"),
|
||||
("llama-70b", "llama-3.3-70b-versatile"),
|
||||
@@ -471,9 +495,10 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("mistral-nemo", "open-mistral-nemo"),
|
||||
("pixtral", "pixtral-large-latest"),
|
||||
// xAI aliases
|
||||
("grok", "grok-2"),
|
||||
("grok", "grok-4"),
|
||||
("grok-mini", "grok-2-mini"),
|
||||
("grok3", "grok-3"),
|
||||
("grok-fast", "grok-4.1-fast"),
|
||||
// Perplexity alias
|
||||
("sonar", "sonar-pro"),
|
||||
// AI21 aliases
|
||||
@@ -503,8 +528,36 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
vec![
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Anthropic (5)
|
||||
// Anthropic (7)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "claude-opus-4-6".into(),
|
||||
display_name: "Claude Opus 4.6".into(),
|
||||
provider: "anthropic".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 5.0,
|
||||
output_cost_per_m: 25.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["opus".into(), "claude-opus".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-sonnet-4-6".into(),
|
||||
display_name: "Claude Sonnet 4.6".into(),
|
||||
provider: "anthropic".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
input_cost_per_m: 3.0,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["sonnet".into(), "claude-sonnet".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-opus-4-20250514".into(),
|
||||
display_name: "Claude Opus 4".into(),
|
||||
@@ -517,7 +570,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["opus".into(), "claude-opus".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-sonnet-4-20250514".into(),
|
||||
@@ -531,7 +584,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["sonnet".into(), "claude-sonnet".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-haiku-4-5-20251001".into(),
|
||||
@@ -576,7 +629,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenAI (10)
|
||||
// OpenAI (16)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "gpt-4o".into(),
|
||||
@@ -718,9 +771,149 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gpt-5".into(),
|
||||
display_name: "GPT-5".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
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: "gpt-5-mini".into(),
|
||||
display_name: "GPT-5 Mini".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 0.25,
|
||||
output_cost_per_m: 2.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gpt5-mini".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gpt-5-nano".into(),
|
||||
display_name: "GPT-5 Nano".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 0.05,
|
||||
output_cost_per_m: 0.40,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gpt-5.1".into(),
|
||||
display_name: "GPT-5.1".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
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: "gpt-5.2".into(),
|
||||
display_name: "GPT-5.2".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 1.75,
|
||||
output_cost_per_m: 14.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gpt5".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gpt-5.2-pro".into(),
|
||||
display_name: "GPT-5.2 Pro".into(),
|
||||
provider: "openai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 400_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 1.75,
|
||||
output_cost_per_m: 14.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Google Gemini (6)
|
||||
// Google Gemini (10)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3.1-pro".into(),
|
||||
display_name: "Gemini 3.1 Pro".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 2.50,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gemini-pro".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3-flash".into(),
|
||||
display_name: "Gemini 3 Flash".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 0.50,
|
||||
output_cost_per_m: 3.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["flash".into(), "gemini-flash".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3-deep-think".into(),
|
||||
display_name: "Gemini 3 Deep Think".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 2.50,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-2.5-flash-lite".into(),
|
||||
display_name: "Gemini 2.5 Flash Lite".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.04,
|
||||
output_cost_per_m: 0.15,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-2.5-pro".into(),
|
||||
display_name: "Gemini 2.5 Pro".into(),
|
||||
@@ -733,7 +926,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gemini-pro".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-2.5-flash".into(),
|
||||
@@ -747,7 +940,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["flash".into(), "gemini-flash".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-2.0-flash".into(),
|
||||
@@ -865,7 +1058,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Groq (10)
|
||||
// Groq (11)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "llama-3.3-70b-versatile".into(),
|
||||
@@ -1007,6 +1200,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "meta-llama/llama-4-scout-17b-16e-instruct".into(),
|
||||
display_name: "Llama 4 Scout 17B".into(),
|
||||
provider: "groq".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.11,
|
||||
output_cost_per_m: 0.34,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenRouter (5)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -1744,8 +1951,36 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// xAI (4)
|
||||
// xAI (6)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4".into(),
|
||||
display_name: "Grok 4".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 256_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 3.0,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["grok".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4.1-fast".into(),
|
||||
display_name: "Grok 4.1 Fast".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 2_000_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.50,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["grok-fast".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-3".into(),
|
||||
display_name: "Grok 3".into(),
|
||||
@@ -1758,7 +1993,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
aliases: vec!["grok3".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-3-mini".into(),
|
||||
@@ -1786,7 +2021,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["grok".into()],
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-2-mini".into(),
|
||||
@@ -2205,8 +2440,36 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// AWS Bedrock (6)
|
||||
// AWS Bedrock (8)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "bedrock/anthropic.claude-opus-4-6".into(),
|
||||
display_name: "Claude Opus 4.6 (Bedrock)".into(),
|
||||
provider: "bedrock".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 5.00,
|
||||
output_cost_per_m: 25.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "bedrock/anthropic.claude-sonnet-4-6".into(),
|
||||
display_name: "Claude Sonnet 4.6 (Bedrock)".into(),
|
||||
provider: "bedrock".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
input_cost_per_m: 3.00,
|
||||
output_cost_per_m: 15.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "bedrock/anthropic.claude-opus-4-20250514".into(),
|
||||
display_name: "Claude Opus 4 (Bedrock)".into(),
|
||||
@@ -2323,7 +2586,7 @@ mod tests {
|
||||
fn test_find_model_by_alias() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("sonnet").unwrap();
|
||||
assert_eq!(entry.id, "claude-sonnet-4-20250514");
|
||||
assert_eq!(entry.id, "claude-sonnet-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2344,7 +2607,7 @@ mod tests {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(
|
||||
catalog.resolve_alias("sonnet"),
|
||||
Some("claude-sonnet-4-20250514")
|
||||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.resolve_alias("haiku"),
|
||||
@@ -2357,7 +2620,7 @@ mod tests {
|
||||
fn test_models_by_provider() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let anthropic = catalog.models_by_provider("anthropic");
|
||||
assert_eq!(anthropic.len(), 5);
|
||||
assert_eq!(anthropic.len(), 7);
|
||||
assert!(anthropic.iter().all(|m| m.provider == "anthropic"));
|
||||
}
|
||||
|
||||
@@ -2415,9 +2678,9 @@ mod tests {
|
||||
fn test_provider_model_counts() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let anthropic = catalog.get_provider("anthropic").unwrap();
|
||||
assert_eq!(anthropic.model_count, 5);
|
||||
assert_eq!(anthropic.model_count, 7);
|
||||
let groq = catalog.get_provider("groq").unwrap();
|
||||
assert_eq!(groq.model_count, 10);
|
||||
assert_eq!(groq.model_count, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2425,9 +2688,9 @@ mod tests {
|
||||
let catalog = ModelCatalog::new();
|
||||
let aliases = catalog.list_aliases();
|
||||
assert!(aliases.len() >= 20);
|
||||
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-20250514");
|
||||
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6");
|
||||
// New aliases
|
||||
assert_eq!(aliases.get("grok").unwrap(), "grok-2");
|
||||
assert_eq!(aliases.get("grok").unwrap(), "grok-4");
|
||||
assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large");
|
||||
}
|
||||
|
||||
@@ -2435,7 +2698,7 @@ mod tests {
|
||||
fn test_find_grok_by_alias() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("grok").unwrap();
|
||||
assert_eq!(entry.id, "grok-2");
|
||||
assert_eq!(entry.id, "grok-4");
|
||||
assert_eq!(entry.provider, "xai");
|
||||
}
|
||||
|
||||
@@ -2456,11 +2719,13 @@ mod tests {
|
||||
fn test_xai_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let xai = catalog.models_by_provider("xai");
|
||||
assert_eq!(xai.len(), 4);
|
||||
assert!(xai.iter().any(|m| m.id == "grok-2"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-2-mini"));
|
||||
assert_eq!(xai.len(), 6);
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4.1-fast"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-3"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-3-mini"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-2"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-2-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2543,6 +2808,52 @@ mod tests {
|
||||
fn test_bedrock_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let bedrock = catalog.models_by_provider("bedrock");
|
||||
assert_eq!(bedrock.len(), 6);
|
||||
assert_eq!(bedrock.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_provider_url() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let old_url = catalog.get_provider("ollama").unwrap().base_url.clone();
|
||||
assert_eq!(old_url, OLLAMA_BASE_URL);
|
||||
|
||||
let updated = catalog.set_provider_url("ollama", "http://192.168.1.100:11434/v1");
|
||||
assert!(updated);
|
||||
assert_eq!(
|
||||
catalog.get_provider("ollama").unwrap().base_url,
|
||||
"http://192.168.1.100:11434/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_provider_url_unknown() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999");
|
||||
assert!(!updated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_url_overrides() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let mut overrides = HashMap::new();
|
||||
overrides.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string());
|
||||
overrides.insert("vllm".to_string(), "http://10.0.0.6:8000/v1".to_string());
|
||||
overrides.insert("nonexistent".to_string(), "http://nowhere".to_string());
|
||||
|
||||
catalog.apply_url_overrides(&overrides);
|
||||
|
||||
assert_eq!(
|
||||
catalog.get_provider("ollama").unwrap().base_url,
|
||||
"http://10.0.0.5:11434/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.get_provider("vllm").unwrap().base_url,
|
||||
"http://10.0.0.6:8000/v1"
|
||||
);
|
||||
// lmstudio should be unchanged
|
||||
assert_eq!(
|
||||
catalog.get_provider("lmstudio").unwrap().base_url,
|
||||
LMSTUDIO_BASE_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +522,15 @@ pub fn tool_hint(name: &str) -> &'static str {
|
||||
|
||||
/// Cap a string to `max_chars`, appending "..." if truncated.
|
||||
fn cap_str(s: &str, max_chars: usize) -> String {
|
||||
if s.len() <= max_chars {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_chars])
|
||||
let end = s
|
||||
.char_indices()
|
||||
.nth(max_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(s.len());
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,6 +841,23 @@ mod tests {
|
||||
assert_eq!(result, "hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cap_str_multibyte_utf8() {
|
||||
// This was panicking with "byte index is not a char boundary" (#38)
|
||||
let chinese = "你好世界这是一个测试字符串";
|
||||
let result = cap_str(chinese, 4);
|
||||
assert_eq!(result, "你好世界...");
|
||||
// Exact boundary
|
||||
assert_eq!(cap_str(chinese, 100), chinese);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cap_str_emoji() {
|
||||
let emoji = "👋🌍🚀✨💯";
|
||||
let result = cap_str(emoji, 3);
|
||||
assert_eq!(result, "👋🌍🚀...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capitalize() {
|
||||
assert_eq!(capitalize("files"), "Files");
|
||||
|
||||
@@ -149,7 +149,7 @@ impl ModelRouter {
|
||||
|
||||
/// Resolve aliases in the routing config using the catalog.
|
||||
///
|
||||
/// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-20250514".
|
||||
/// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-6".
|
||||
pub fn resolve_aliases(&mut self, catalog: &crate::model_catalog::ModelCatalog) {
|
||||
if let Some(resolved) = catalog.resolve_alias(&self.config.simple_model) {
|
||||
self.config.simple_model = resolved.to_string();
|
||||
@@ -172,8 +172,8 @@ mod tests {
|
||||
fn default_config() -> ModelRoutingConfig {
|
||||
ModelRoutingConfig {
|
||||
simple_model: "llama-3.3-70b-versatile".to_string(),
|
||||
medium_model: "claude-sonnet-4-20250514".to_string(),
|
||||
complex_model: "claude-opus-4-20250514".to_string(),
|
||||
medium_model: "claude-sonnet-4-6".to_string(),
|
||||
complex_model: "claude-opus-4-6".to_string(),
|
||||
simple_threshold: 200,
|
||||
complex_threshold: 800,
|
||||
}
|
||||
@@ -274,11 +274,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
router.model_for_complexity(TaskComplexity::Medium),
|
||||
"claude-sonnet-4-20250514"
|
||||
"claude-sonnet-4-6"
|
||||
);
|
||||
assert_eq!(
|
||||
router.model_for_complexity(TaskComplexity::Complex),
|
||||
"claude-opus-4-20250514"
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,8 +294,8 @@ mod tests {
|
||||
let catalog = crate::model_catalog::ModelCatalog::new();
|
||||
let config = ModelRoutingConfig {
|
||||
simple_model: "llama-3.3-70b-versatile".to_string(),
|
||||
medium_model: "claude-sonnet-4-20250514".to_string(),
|
||||
complex_model: "claude-opus-4-20250514".to_string(),
|
||||
medium_model: "claude-sonnet-4-6".to_string(),
|
||||
complex_model: "claude-opus-4-6".to_string(),
|
||||
simple_threshold: 200,
|
||||
complex_threshold: 800,
|
||||
};
|
||||
@@ -309,8 +309,8 @@ mod tests {
|
||||
let catalog = crate::model_catalog::ModelCatalog::new();
|
||||
let config = ModelRoutingConfig {
|
||||
simple_model: "unknown-model".to_string(),
|
||||
medium_model: "claude-sonnet-4-20250514".to_string(),
|
||||
complex_model: "claude-opus-4-20250514".to_string(),
|
||||
medium_model: "claude-sonnet-4-6".to_string(),
|
||||
complex_model: "claude-opus-4-6".to_string(),
|
||||
simple_threshold: 200,
|
||||
complex_threshold: 800,
|
||||
};
|
||||
@@ -338,11 +338,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
router.model_for_complexity(TaskComplexity::Medium),
|
||||
"claude-sonnet-4-20250514"
|
||||
"claude-sonnet-4-6"
|
||||
);
|
||||
assert_eq!(
|
||||
router.model_for_complexity(TaskComplexity::Complex),
|
||||
"claude-opus-4-20250514"
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -520,7 +520,7 @@ pub fn strip_tool_result_details(content: &str) -> String {
|
||||
} else {
|
||||
format!(
|
||||
"{}...[truncated from {} chars]",
|
||||
&cleaned[..max_len],
|
||||
crate::str_utils::safe_truncate_str(&cleaned, max_len),
|
||||
cleaned.len()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! UTF-8-safe string utilities.
|
||||
|
||||
/// Truncate a string to at most `max_bytes` bytes without splitting a multi-byte
|
||||
/// character. Returns the full string when it already fits.
|
||||
///
|
||||
/// This avoids panics that occur when using `&s[..max_bytes]` on strings containing
|
||||
/// multi-byte characters (e.g. Chinese, emoji, accented Latin).
|
||||
#[inline]
|
||||
pub fn safe_truncate_str(s: &str, max_bytes: usize) -> &str {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
// Walk backwards to the nearest char boundary
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_within_limit() {
|
||||
let s = "hello";
|
||||
assert_eq!(safe_truncate_str(s, 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_exact_limit() {
|
||||
let s = "hello";
|
||||
assert_eq!(safe_truncate_str(s, 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_truncated() {
|
||||
let s = "hello world";
|
||||
assert_eq!(safe_truncate_str(s, 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_chinese() {
|
||||
// Each Chinese character is 3 bytes in UTF-8
|
||||
let s = "\u{4f60}\u{597d}\u{4e16}\u{754c}"; // "hello world" in Chinese, 12 bytes
|
||||
// Truncating at 7 bytes should not split the 3rd char (bytes 6..9)
|
||||
let t = safe_truncate_str(s, 7);
|
||||
assert_eq!(t, "\u{4f60}\u{597d}"); // 6 bytes, 2 chars
|
||||
assert!(t.len() <= 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_emoji() {
|
||||
let s = "\u{1f600}\u{1f601}\u{1f602}"; // 3 emoji, 4 bytes each = 12 bytes
|
||||
let t = safe_truncate_str(s, 5);
|
||||
assert_eq!(t, "\u{1f600}"); // 4 bytes, 1 emoji
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limit() {
|
||||
let s = "hello";
|
||||
assert_eq!(safe_truncate_str(s, 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string() {
|
||||
assert_eq!(safe_truncate_str("", 10), "");
|
||||
}
|
||||
}
|
||||
@@ -1231,7 +1231,7 @@ async fn tool_web_fetch_legacy(input: &serde_json::Value) -> Result<String, Stri
|
||||
let truncated = if body.len() > max_len {
|
||||
format!(
|
||||
"{}... [truncated, {} total bytes]",
|
||||
&body[..max_len],
|
||||
crate::str_utils::safe_truncate_str(&body, max_len),
|
||||
body.len()
|
||||
)
|
||||
} else {
|
||||
@@ -1367,7 +1367,7 @@ async fn tool_shell_exec(
|
||||
let stdout_str = if stdout.len() > max_output {
|
||||
format!(
|
||||
"{}...\n[truncated, {} total bytes]",
|
||||
&stdout[..max_output],
|
||||
crate::str_utils::safe_truncate_str(&stdout, max_output),
|
||||
stdout.len()
|
||||
)
|
||||
} else {
|
||||
@@ -1376,7 +1376,7 @@ async fn tool_shell_exec(
|
||||
let stderr_str = if stderr.len() > max_output {
|
||||
format!(
|
||||
"{}...\n[truncated, {} total bytes]",
|
||||
&stderr[..max_output],
|
||||
crate::str_utils::safe_truncate_str(&stderr, max_output),
|
||||
stderr.len()
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -114,7 +114,7 @@ impl TtsEngine {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let err = response.text().await.unwrap_or_default();
|
||||
let truncated = if err.len() > 500 { &err[..500] } else { &err };
|
||||
let truncated = crate::str_utils::safe_truncate_str(&err, 500);
|
||||
return Err(format!("OpenAI TTS failed (HTTP {status}): {truncated}"));
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl TtsEngine {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let err = response.text().await.unwrap_or_default();
|
||||
let truncated = if err.len() > 500 { &err[..500] } else { &err };
|
||||
let truncated = crate::str_utils::safe_truncate_str(&err, 500);
|
||||
return Err(format!(
|
||||
"ElevenLabs TTS failed (HTTP {status}): {truncated}"
|
||||
));
|
||||
|
||||
@@ -146,7 +146,7 @@ impl WorkspaceContext {
|
||||
if let Some(content) = self.get_file(&name) {
|
||||
// Take first 200 chars as preview
|
||||
let preview = if content.len() > 200 {
|
||||
format!("{}...", &content[..200])
|
||||
format!("{}...", crate::str_utils::safe_truncate_str(content, 200))
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
|
||||
@@ -56,7 +56,11 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result<Pa
|
||||
// Verify the canonical path is inside the workspace
|
||||
if !canon_candidate.starts_with(&canon_root) {
|
||||
return Err(format!(
|
||||
"Access denied: path '{}' resolves outside workspace",
|
||||
"Access denied: path '{}' resolves outside workspace. \
|
||||
If you have an MCP filesystem server configured, use the \
|
||||
mcp_filesystem_* tools (e.g. mcp_filesystem_read_file, \
|
||||
mcp_filesystem_list_directory) to access files outside \
|
||||
the workspace.",
|
||||
user_path
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1039,6 +1039,10 @@ pub struct KernelConfig {
|
||||
/// Global spending budget configuration.
|
||||
#[serde(default)]
|
||||
pub budget: BudgetConfig,
|
||||
/// Provider base URL overrides (provider ID → custom base URL).
|
||||
/// e.g. `ollama = "http://192.168.1.100:11434/v1"`
|
||||
#[serde(default)]
|
||||
pub provider_urls: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Global spending budget configuration.
|
||||
@@ -1183,6 +1187,7 @@ impl Default for KernelConfig {
|
||||
auth_profiles: HashMap::new(),
|
||||
thinking: None,
|
||||
budget: BudgetConfig::default(),
|
||||
provider_urls: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-8
@@ -20,17 +20,40 @@ function Write-Banner {
|
||||
}
|
||||
|
||||
function Get-Architecture {
|
||||
# Try multiple detection methods — piped iex can break some approaches
|
||||
$arch = ""
|
||||
|
||||
# Method 1: .NET RuntimeInformation
|
||||
try {
|
||||
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
} catch {
|
||||
# PowerShell 5.1 fallback
|
||||
$arch = $env:PROCESSOR_ARCHITECTURE
|
||||
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
||||
} catch {}
|
||||
|
||||
# Method 2: PROCESSOR_ARCHITECTURE env var
|
||||
if (-not $arch -or $arch -eq "") {
|
||||
try { $arch = $env:PROCESSOR_ARCHITECTURE } catch {}
|
||||
}
|
||||
switch ($arch) {
|
||||
{ $_ -in "X64", "AMD64" } { return "x86_64" }
|
||||
{ $_ -in "Arm64", "ARM64" } { return "aarch64" }
|
||||
|
||||
# Method 3: WMI
|
||||
if (-not $arch -or $arch -eq "") {
|
||||
try {
|
||||
$wmiArch = (Get-CimInstance Win32_Processor).Architecture
|
||||
if ($wmiArch -eq 9) { $arch = "AMD64" }
|
||||
elseif ($wmiArch -eq 12) { $arch = "ARM64" }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Method 4: pointer size fallback (64-bit = 8 bytes)
|
||||
if (-not $arch -or $arch -eq "") {
|
||||
if ([IntPtr]::Size -eq 8) { $arch = "X64" }
|
||||
}
|
||||
|
||||
$archUpper = "$arch".ToUpper().Trim()
|
||||
switch ($archUpper) {
|
||||
{ $_ -in "X64", "AMD64", "X86_64" } { return "x86_64" }
|
||||
{ $_ -in "ARM64", "AARCH64", "ARM" } { return "aarch64" }
|
||||
default {
|
||||
Write-Host " Unsupported architecture: $arch" -ForegroundColor Red
|
||||
Write-Host " Unsupported architecture: $arch (detection may have failed)" -ForegroundColor Red
|
||||
Write-Host " Try: cargo install --git https://github.com/RightNow-AI/openfang openfang-cli" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user