Compare commits

...
5 Commits
Author SHA1 Message Date
jaberjaber23 51c1e154d9 MCP guidance 2026-02-27 18:06:17 +03:00
jaberjaber23 036cc14ad5 fix installer 2026-02-27 17:29:18 +03:00
jaberjaber23 b866ae7055 fix installer 2026-02-27 17:27:25 +03:00
jaberjaber23 15a859b7f9 fix issues 2026-02-27 17:13:10 +03:00
jaberjaber23 eda88ba5a2 provider URLs 2026-02-27 15:56:57 +03:00
25 changed files with 787 additions and 85 deletions
+1 -1
View File
@@ -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"
+117
View File
@@ -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>>,
+4
View File
@@ -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),
+3
View File
@@ -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 &mdash; 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 &mdash; 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
// -----------------------------------------------------------------------
+28 -2
View File
@@ -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
}
+61 -3
View File
@@ -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);
+6 -5
View File
@@ -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()
};
+1 -5
View File
@@ -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
+1
View File
@@ -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;
+8 -3
View File
@@ -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()
}
+1 -5
View File
@@ -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())
}
+343 -32
View File
@@ -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
);
}
}
+24 -2
View File
@@ -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");
+11 -11
View File
@@ -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()
)
}
+70
View File
@@ -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), "");
}
}
+3 -3
View File
@@ -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 {
+2 -2
View File
@@ -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
));
}
+5
View File
@@ -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
View File
@@ -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
}
}