provider URLs

This commit is contained in:
jaberjaber23
2026-02-27 15:56:57 +03:00
parent 0bb4e6f17b
commit eda88ba5a2
10 changed files with 617 additions and 47 deletions
+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),
@@ -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
// -----------------------------------------------------------------------
+16 -1
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
+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);
+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
);
}
}
+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"
);
}
+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(),
}
}
}