Skip to content

Commit eda88ba

Browse files
committed
provider URLs
1 parent 0bb4e6f commit eda88ba

File tree

10 files changed

+617
-47
lines changed

10 files changed

+617
-47
lines changed

crates/openfang-api/src/routes.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4842,6 +4842,7 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
48424842
"model_count": p.model_count,
48434843
"key_required": p.key_required,
48444844
"api_key_env": p.api_key_env,
4845+
"base_url": p.base_url,
48454846
});
48464847

48474848
// For local providers, add reachability info via health probe
@@ -5899,6 +5900,122 @@ pub async fn test_provider(
58995900
}
59005901
}
59015902

5903+
/// PUT /api/providers/{name}/url — Set a custom base URL for a provider.
5904+
pub async fn set_provider_url(
5905+
State(state): State<Arc<AppState>>,
5906+
Path(name): Path<String>,
5907+
Json(body): Json<serde_json::Value>,
5908+
) -> impl IntoResponse {
5909+
// Validate provider exists
5910+
let provider_exists = {
5911+
let catalog = state
5912+
.kernel
5913+
.model_catalog
5914+
.read()
5915+
.unwrap_or_else(|e| e.into_inner());
5916+
catalog.get_provider(&name).is_some()
5917+
};
5918+
if !provider_exists {
5919+
return (
5920+
StatusCode::NOT_FOUND,
5921+
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
5922+
);
5923+
}
5924+
5925+
let base_url = match body["base_url"].as_str() {
5926+
Some(u) if !u.trim().is_empty() => u.trim().to_string(),
5927+
_ => {
5928+
return (
5929+
StatusCode::BAD_REQUEST,
5930+
Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})),
5931+
);
5932+
}
5933+
};
5934+
5935+
// Validate URL scheme
5936+
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
5937+
return (
5938+
StatusCode::BAD_REQUEST,
5939+
Json(serde_json::json!({"error": "base_url must start with http:// or https://"})),
5940+
);
5941+
}
5942+
5943+
// Update catalog in memory
5944+
{
5945+
let mut catalog = state
5946+
.kernel
5947+
.model_catalog
5948+
.write()
5949+
.unwrap_or_else(|e| e.into_inner());
5950+
catalog.set_provider_url(&name, &base_url);
5951+
}
5952+
5953+
// Persist to config.toml [provider_urls] section
5954+
let config_path = state.kernel.config.home_dir.join("config.toml");
5955+
if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) {
5956+
return (
5957+
StatusCode::INTERNAL_SERVER_ERROR,
5958+
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
5959+
);
5960+
}
5961+
5962+
// Probe reachability at the new URL
5963+
let probe =
5964+
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
5965+
5966+
(
5967+
StatusCode::OK,
5968+
Json(serde_json::json!({
5969+
"status": "saved",
5970+
"provider": name,
5971+
"base_url": base_url,
5972+
"reachable": probe.reachable,
5973+
"latency_ms": probe.latency_ms,
5974+
})),
5975+
)
5976+
}
5977+
5978+
/// Upsert a provider URL in the `[provider_urls]` section of config.toml.
5979+
fn upsert_provider_url(
5980+
config_path: &std::path::Path,
5981+
provider: &str,
5982+
url: &str,
5983+
) -> Result<(), Box<dyn std::error::Error>> {
5984+
let content = if config_path.exists() {
5985+
std::fs::read_to_string(config_path)?
5986+
} else {
5987+
String::new()
5988+
};
5989+
5990+
let mut doc: toml::Value = if content.trim().is_empty() {
5991+
toml::Value::Table(toml::map::Map::new())
5992+
} else {
5993+
toml::from_str(&content)?
5994+
};
5995+
5996+
let root = doc.as_table_mut().ok_or("Config is not a TOML table")?;
5997+
5998+
if !root.contains_key("provider_urls") {
5999+
root.insert(
6000+
"provider_urls".to_string(),
6001+
toml::Value::Table(toml::map::Map::new()),
6002+
);
6003+
}
6004+
let urls_table = root
6005+
.get_mut("provider_urls")
6006+
.and_then(|v| v.as_table_mut())
6007+
.ok_or("provider_urls is not a table")?;
6008+
6009+
urls_table.insert(provider.to_string(), toml::Value::String(url.to_string()));
6010+
6011+
if let Some(parent) = config_path.parent() {
6012+
std::fs::create_dir_all(parent)?;
6013+
}
6014+
6015+
std::fs::write(config_path, toml::to_string_pretty(&doc)?)?;
6016+
Ok(())
6017+
}
6018+
59026019
/// POST /api/skills/create — Create a local prompt-only skill.
59036020
pub async fn create_skill(
59046021
State(state): State<Arc<AppState>>,

crates/openfang-api/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ pub async fn build_router(
460460
"/api/providers/{name}/test",
461461
axum::routing::post(routes::test_provider),
462462
)
463+
.route(
464+
"/api/providers/{name}/url",
465+
axum::routing::put(routes::set_provider_url),
466+
)
463467
.route(
464468
"/api/skills/create",
465469
axum::routing::post(routes::create_skill),

crates/openfang-api/static/index_body.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2886,6 +2886,19 @@ <h4>LLM Providers</h4>
28862886
<template x-if="!p.api_key_env || p.key_required === false">
28872887
<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>
28882888
</template>
2889+
<!-- Base URL editor for local providers -->
2890+
<template x-if="p.is_local">
2891+
<div class="mt-3" style="border-top:1px solid var(--border);padding-top:8px">
2892+
<div class="text-xs text-dim mb-1">Base URL</div>
2893+
<div class="key-input-group">
2894+
<input type="text" :placeholder="'http://localhost:...'" x-model="providerUrlInputs[p.id]" style="font-size:12px">
2895+
<button class="btn btn-primary btn-sm" @click="saveProviderUrl(p)" :disabled="providerUrlSaving[p.id]">
2896+
<span x-show="!providerUrlSaving[p.id]">Save</span>
2897+
<span x-show="providerUrlSaving[p.id]" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
2898+
</button>
2899+
</div>
2900+
</div>
2901+
</template>
28892902
</div>
28902903
</template>
28912904
</div>

crates/openfang-api/static/js/pages/settings.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ function settingsPage() {
1515
modelProviderFilter: '',
1616
modelTierFilter: '',
1717
providerKeyInputs: {},
18+
providerUrlInputs: {},
19+
providerUrlSaving: {},
1820
providerTesting: {},
1921
providerTestResults: {},
2022
loading: true,
@@ -208,6 +210,12 @@ function settingsPage() {
208210
try {
209211
var data = await OpenFangAPI.get('/api/providers');
210212
this.providers = data.providers || [];
213+
for (var i = 0; i < this.providers.length; i++) {
214+
var p = this.providers[i];
215+
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
216+
this.providerUrlInputs[p.id] = p.base_url;
217+
}
218+
}
211219
} catch(e) { this.providers = []; }
212220
},
213221

@@ -378,6 +386,28 @@ function settingsPage() {
378386
this.providerTesting[provider.id] = false;
379387
},
380388

389+
async saveProviderUrl(provider) {
390+
var url = this.providerUrlInputs[provider.id];
391+
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
392+
url = url.trim();
393+
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
394+
OpenFangToast.error('URL must start with http:// or https://'); return;
395+
}
396+
this.providerUrlSaving[provider.id] = true;
397+
try {
398+
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
399+
if (result.reachable) {
400+
OpenFangToast.success(provider.display_name + ' URL saved &mdash; reachable (' + (result.latency_ms || '?') + 'ms)');
401+
} else {
402+
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
403+
}
404+
await this.loadProviders();
405+
} catch(e) {
406+
OpenFangToast.error('Failed to save URL: ' + e.message);
407+
}
408+
this.providerUrlSaving[provider.id] = false;
409+
},
410+
381411
// -- Security methods --
382412
async loadSecurity() {
383413
this.secLoading = true;

crates/openfang-kernel/src/config_reload.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ pub enum HotAction {
4141
ReloadA2aConfig,
4242
/// Fallback provider chain changed.
4343
ReloadFallbackProviders,
44+
/// Provider base URL overrides changed.
45+
ReloadProviderUrls,
4446
}
4547

4648
// ---------------------------------------------------------------------------
@@ -235,6 +237,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
235237
plan.hot_actions.push(HotAction::ReloadFallbackProviders);
236238
}
237239

240+
if field_changed(&old.provider_urls, &new.provider_urls) {
241+
plan.hot_actions.push(HotAction::ReloadProviderUrls);
242+
}
243+
238244
// ----- No-op fields -----
239245

240246
if old.log_level != new.log_level {
@@ -461,6 +467,17 @@ mod tests {
461467
assert!(plan.hot_actions.contains(&HotAction::ReloadExtensions));
462468
}
463469

470+
#[test]
471+
fn test_provider_urls_hot_reload() {
472+
let a = default_cfg();
473+
let mut b = default_cfg();
474+
b.provider_urls
475+
.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string());
476+
let plan = build_reload_plan(&a, &b);
477+
assert!(!plan.restart_required);
478+
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
479+
}
480+
464481
// -----------------------------------------------------------------------
465482
// Mixed changes
466483
// -----------------------------------------------------------------------

crates/openfang-kernel/src/kernel.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,16 @@ impl OpenFangKernel {
590590
info!("RBAC enabled with {} users", auth.user_count());
591591
}
592592

593-
// Initialize model catalog and detect provider auth
593+
// Initialize model catalog, detect provider auth, and apply URL overrides
594594
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
595595
model_catalog.detect_auth();
596+
if !config.provider_urls.is_empty() {
597+
model_catalog.apply_url_overrides(&config.provider_urls);
598+
info!(
599+
"applied {} provider URL override(s)",
600+
config.provider_urls.len()
601+
);
602+
}
596603
let available_count = model_catalog.available_models().len();
597604
let total_count = model_catalog.list_models().len();
598605
let local_count = model_catalog
@@ -2762,6 +2769,14 @@ impl OpenFangKernel {
27622769
self.cron_scheduler
27632770
.set_max_total_jobs(new_config.max_cron_jobs);
27642771
}
2772+
HotAction::ReloadProviderUrls => {
2773+
info!("Hot-reload: applying provider URL overrides");
2774+
let mut catalog = self
2775+
.model_catalog
2776+
.write()
2777+
.unwrap_or_else(|e| e.into_inner());
2778+
catalog.apply_url_overrides(&new_config.provider_urls);
2779+
}
27652780
_ => {
27662781
// Other hot actions (channels, web, browser, extensions, etc.)
27672782
// are logged but not applied here — they require subsystem-specific

0 commit comments

Comments
 (0)