feat(config): runtime RPC URL configuration bootstrap (Issue #933) (#948)

Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Gaurang Patel
2026-04-29 11:33:35 -07:00
committed by GitHub
co-authored by Steven Enamakel Steven Enamakel
parent d9ed70142e
commit b56e846988
26 changed files with 729 additions and 36 deletions
+5 -5
View File
@@ -77,8 +77,8 @@ jobs:
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb
env:
NODE_ENV: production
CARGO_PROFILE_RELEASE_OPT_LEVEL: '1'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
CARGO_PROFILE_RELEASE_LTO: 'false'
CARGO_PROFILE_RELEASE_STRIP: 'true'
CARGO_PROFILE_RELEASE_DEBUG: 'false'
CARGO_PROFILE_RELEASE_OPT_LEVEL: "1"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_STRIP: "true"
CARGO_PROFILE_RELEASE_DEBUG: "false"
+10 -1
View File
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Config (Issue #933)**: Bootstrap from config.toml RPC URL with runtime derivation
- Added "Configure RPC URL" option on Welcome screen for self-hosted/internal deployments
- Users can now set core JSON-RPC URL on login screen without build-time configuration
- RPC URL persisted to localStorage and restored on next launch
- Added "Test Connection" button to validate RPC endpoint before saving
- Core now exposes `openhuman.config_get_client` RPC method returning safe client fields
- Frontend `coreRpcClient` respects user-configured URL over build-time defaults
- Unit tests added for URL persistence and validation utilities
- **DevOps**: Added Sentry debug symbol upload to CI/CD pipeline
- Rust debug symbols from Tauri build are now automatically uploaded to Sentry on every main branch push
- Creates and finalizes Sentry releases with proper version tagging (`openhuman@{version}`)
@@ -38,4 +47,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.52.28] - 2026-04-18
See [release notes](https://github.com/tinyhumansai/openhuman/releases/tag/v0.52.28) for details.
See [release notes](https://github.com/tinyhumansai/openhuman/releases/tag/v0.52.28) for details.
@@ -31,6 +31,7 @@ import {
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import CustomModelSection from './local-model/CustomModelSection';
import ModelDownloadSection from './local-model/ModelDownloadSection';
import ModelStatusSection from './local-model/ModelStatusSection';
@@ -416,6 +417,8 @@ const LocalModelDebugPanel = () => {
onSetTtsOutputPath={setTtsOutputPath}
onRunTtsTest={() => void runTtsTest()}
/>
<CustomModelSection />
</div>
</div>
);
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import {
openhumanGetConfig,
openhumanUpdateModelSettings,
} from '../../../../utils/tauriCommands/config';
const CustomModelSection = () => {
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
let mounted = true;
const fetchConfig = async () => {
try {
const { result } = await openhumanGetConfig();
if (mounted) {
setApiUrl(String(result.config.api_url || ''));
setApiKey(String(result.config.api_key || ''));
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to load custom backend config');
}
} finally {
if (mounted) setIsLoading(false);
}
};
void fetchConfig();
return () => {
mounted = false;
};
}, []);
const handleSave = async () => {
setIsSaving(true);
setError('');
setSaveSuccess(false);
try {
await openhumanUpdateModelSettings({
api_url: apiUrl.trim() || null,
api_key: apiKey.trim() || null,
});
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save custom backend settings');
} finally {
setIsSaving(false);
}
};
return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Custom Provider (OpenAI Compatible)</h3>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<p className="text-xs text-stone-500">
Configure a custom OpenAI-compatible backend (like vLLM or LiteLLM). If set, this will
route requests to your custom endpoint instead of the standard local or built-in backends.
</p>
{isLoading ? (
<div className="text-sm text-stone-500 animate-pulse">Loading settings</div>
) : (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">Base URL</label>
<input
type="text"
value={apiUrl}
onChange={e => setApiUrl(e.target.value)}
placeholder="http://localhost:8000/v1"
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">API Key</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 focus:outline-none disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save Config'}
</button>
{saveSuccess && <span className="text-xs text-green-600">Saved successfully.</span>}
</div>
{error && <div className="text-xs text-red-600 mt-2">{error}</div>}
</div>
)}
</div>
</section>
);
};
export default CustomModelSection;
+153
View File
@@ -4,7 +4,16 @@ import OAuthProviderButton from '../components/oauth/OAuthProviderButton';
import { oauthProviderConfigs } from '../components/oauth/providerConfigs';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { sendEmailMagicLink } from '../services/api/authApi';
import { clearCoreRpcUrlCache } from '../services/coreRpcClient';
import { useDeepLinkAuthState } from '../store/deepLinkAuthState';
import {
clearStoredRpcUrl,
getDefaultRpcUrl,
getStoredRpcUrl,
isValidRpcUrl,
normalizeRpcUrl,
storeRpcUrl,
} from '../utils/configPersistence';
import { isTauri } from '../utils/tauriCommands';
// Desktop deep-link scheme root; must match DESKTOP_FRONTEND_URI on the backend
@@ -18,6 +27,83 @@ const Welcome = () => {
const [isSent, setIsSent] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
// RPC URL configuration state
const [showAdvanced, setShowAdvanced] = useState(false);
const [rpcUrl, setRpcUrl] = useState(getStoredRpcUrl());
const [rpcUrlError, setRpcUrlError] = useState<string | null>(null);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
// Persist RPC URL changes to localStorage
const handleRpcUrlChange = (value: string) => {
setRpcUrl(value);
setRpcUrlError(null);
setSaveSuccess(false);
};
// Save RPC URL preference
const handleSaveRpcUrl = () => {
const normalized = normalizeRpcUrl(rpcUrl);
if (!isValidRpcUrl(normalized)) {
setRpcUrlError('Please enter a valid HTTP or HTTPS URL');
return;
}
storeRpcUrl(normalized);
clearCoreRpcUrlCache();
setRpcUrlError(null);
setSaveSuccess(true);
// Show brief success feedback
setTimeout(() => setSaveSuccess(false), 2000);
};
// Reset RPC URL to default
const handleResetRpcUrl = () => {
clearStoredRpcUrl();
clearCoreRpcUrlCache();
setRpcUrl(getDefaultRpcUrl());
setRpcUrlError(null);
setSaveSuccess(false);
};
// Test RPC connection
const handleTestConnection = async () => {
const normalized = normalizeRpcUrl(rpcUrl);
if (!isValidRpcUrl(normalized)) {
setRpcUrlError('Please enter a valid HTTP or HTTPS URL');
return;
}
setIsTestingConnection(true);
setRpcUrlError(null);
try {
// Simple health check - try to fetch the RPC endpoint
const response = await fetch(normalized, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'openhuman.ping', params: {} }),
});
if (response.ok || response.status === 405) {
// 405 means the endpoint exists but method-not-allowed - still a valid JSON-RPC server
setSaveSuccess(true);
storeRpcUrl(normalized);
clearCoreRpcUrlCache();
} else {
setRpcUrlError(`Connection failed: ${response.status} ${response.statusText}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to reach the RPC endpoint';
setRpcUrlError(`Connection failed: ${message}`);
} finally {
setIsTestingConnection(false);
}
};
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || isSending) return;
@@ -62,6 +148,73 @@ const Welcome = () => {
AI Super Intelligence. Private, Simple and extremely powerful.
</p>
{/* RPC URL Configuration (Advanced) */}
{showAdvanced ? (
<div className="mb-5 p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="flex items-center justify-between mb-3">
<label htmlFor="rpc-url-input" className="text-xs font-medium text-stone-700">
Core RPC URL
</label>
<button
type="button"
onClick={() => setShowAdvanced(false)}
className="text-xs text-stone-500 hover:text-stone-700">
Close
</button>
</div>
<div className="flex gap-2">
<input
id="rpc-url-input"
type="url"
value={rpcUrl}
onChange={e => handleRpcUrlChange(e.target.value)}
placeholder="http://127.0.0.1:7788/rpc"
className="flex-1 rounded-lg border border-stone-300 bg-white px-3 py-2 text-xs text-stone-900 placeholder:text-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
<button
type="button"
onClick={handleTestConnection}
disabled={isTestingConnection}
className="px-3 py-2 bg-stone-200 hover:bg-stone-300 text-stone-700 text-xs font-medium rounded-lg transition-colors disabled:opacity-60">
{isTestingConnection ? (
<span className="flex items-center gap-1">
<span className="h-3 w-3 animate-spin rounded-full border border-stone-400 border-t-transparent" />
Testing
</span>
) : (
'Test'
)}
</button>
</div>
{rpcUrlError ? (
<p className="mt-2 text-xs text-red-600">{rpcUrlError}</p>
) : saveSuccess ? (
<p className="mt-2 text-xs text-green-600">URL saved successfully.</p>
) : null}
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={handleSaveRpcUrl}
className="px-3 py-1.5 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded-lg transition-colors">
Save
</button>
<button
type="button"
onClick={handleResetRpcUrl}
className="px-3 py-1.5 bg-stone-200 hover:bg-stone-300 text-stone-700 text-xs font-medium rounded-lg transition-colors">
Reset to Default
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdvanced(true)}
className="mb-5 text-xs text-stone-500 hover:text-stone-700 underline">
Configure RPC URL (Advanced)
</button>
)}
{errorMessage ? (
<div
role="alert"
+28 -2
View File
@@ -3,6 +3,7 @@ import debug from 'debug';
import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory';
import { CORE_RPC_URL } from '../utils/config';
import { getStoredRpcUrl } from '../utils/configPersistence';
import { sanitizeError } from '../utils/sanitize';
interface CoreRpcRelayRequest {
@@ -48,6 +49,16 @@ const LEGACY_METHOD_ALIASES: Record<string, string> = {
let nextJsonRpcId = 1;
let resolvedCoreRpcUrl: string | null = null;
let resolvingCoreRpcUrl: Promise<string> | null = null;
/**
* Invalidate the cached core RPC URL so the next call to getCoreRpcUrl()
* re-resolves from the user-configured or environment-default value.
* Call this after the user saves a new RPC URL preference.
*/
export function clearCoreRpcUrlCache(): void {
resolvedCoreRpcUrl = null;
resolvingCoreRpcUrl = null;
}
const coreRpcLog = debug('core-rpc');
const coreRpcError = debug('core-rpc:error');
@@ -93,6 +104,12 @@ export async function getCoreRpcUrl(): Promise<string> {
}
if (!coreIsTauri()) {
// Web environment: check for user-configured RPC URL first
const storedUrl = getStoredRpcUrl();
if (storedUrl && storedUrl !== CORE_RPC_URL) {
resolvedCoreRpcUrl = storedUrl;
return storedUrl;
}
resolvedCoreRpcUrl = CORE_RPC_URL;
return CORE_RPC_URL;
}
@@ -103,13 +120,22 @@ export async function getCoreRpcUrl(): Promise<string> {
const resolvePromise: Promise<string> = (async () => {
try {
// Tauri: check for user-configured URL first
const storedUrl = getStoredRpcUrl();
if (storedUrl && storedUrl !== CORE_RPC_URL) {
resolvedCoreRpcUrl = storedUrl;
return storedUrl;
}
const url = await invoke<string>('core_rpc_url');
const trimmed = String(url || '').trim();
resolvedCoreRpcUrl = trimmed || CORE_RPC_URL;
return resolvedCoreRpcUrl || CORE_RPC_URL;
} catch {
resolvedCoreRpcUrl = CORE_RPC_URL;
return CORE_RPC_URL;
// Fallback to stored or default on error
const storedUrl = getStoredRpcUrl();
resolvedCoreRpcUrl = storedUrl || CORE_RPC_URL;
return resolvedCoreRpcUrl;
} finally {
resolvingCoreRpcUrl = null;
}
@@ -0,0 +1,147 @@
/**
* Unit tests for configPersistence utilities.
* Tests URL storage, validation, and normalization.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
clearStoredRpcUrl,
getDefaultRpcUrl,
getStoredRpcUrl,
isValidRpcUrl,
normalizeRpcUrl,
storeRpcUrl,
} from '../configPersistence';
const STORAGE_KEY = 'openhuman_core_rpc_url';
describe('configPersistence', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.removeItem(STORAGE_KEY);
});
afterEach(() => {
// Clean up after each test
localStorage.removeItem(STORAGE_KEY);
});
describe('getStoredRpcUrl', () => {
it('returns default URL when no URL is stored', () => {
const result = getStoredRpcUrl();
expect(result).toBe('http://127.0.0.1:7788/rpc');
});
it('returns stored URL when available', () => {
localStorage.setItem(STORAGE_KEY, 'http://localhost:8080/rpc');
const result = getStoredRpcUrl();
expect(result).toBe('http://localhost:8080/rpc');
});
it('trims whitespace from stored URL', () => {
localStorage.setItem(STORAGE_KEY, ' http://localhost:8080/rpc ');
const result = getStoredRpcUrl();
expect(result).toBe('http://localhost:8080/rpc');
});
it('returns default when stored URL is empty', () => {
localStorage.setItem(STORAGE_KEY, '');
const result = getStoredRpcUrl();
expect(result).toBe('http://127.0.0.1:7788/rpc');
});
});
describe('storeRpcUrl', () => {
it('stores a valid URL', () => {
storeRpcUrl('http://localhost:9000/rpc');
expect(localStorage.getItem(STORAGE_KEY)).toBe('http://localhost:9000/rpc');
});
it('trims and stores URL', () => {
storeRpcUrl(' http://localhost:9000/rpc ');
expect(localStorage.getItem(STORAGE_KEY)).toBe('http://localhost:9000/rpc');
});
it('clears stored URL when given empty string', () => {
localStorage.setItem(STORAGE_KEY, 'http://localhost:9000/rpc');
storeRpcUrl('');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('clears stored URL when given whitespace-only string', () => {
localStorage.setItem(STORAGE_KEY, 'http://localhost:9000/rpc');
storeRpcUrl(' ');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('clearStoredRpcUrl', () => {
it('removes stored URL', () => {
localStorage.setItem(STORAGE_KEY, 'http://localhost:9000/rpc');
clearStoredRpcUrl();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('isValidRpcUrl', () => {
it('returns true for valid http URL', () => {
expect(isValidRpcUrl('http://localhost:7788/rpc')).toBe(true);
});
it('returns true for valid https URL', () => {
expect(isValidRpcUrl('https://api.example.com/rpc')).toBe(true);
});
it('returns true for URL without /rpc suffix', () => {
expect(isValidRpcUrl('http://localhost:7788')).toBe(true);
});
it('returns false for empty string', () => {
expect(isValidRpcUrl('')).toBe(false);
});
it('returns false for whitespace-only string', () => {
expect(isValidRpcUrl(' ')).toBe(false);
});
it('returns false for null/undefined', () => {
expect(isValidRpcUrl(null as unknown as string)).toBe(false);
expect(isValidRpcUrl(undefined as unknown as string)).toBe(false);
});
it('returns false for invalid protocol', () => {
expect(isValidRpcUrl('ftp://localhost:7788/rpc')).toBe(false);
expect(isValidRpcUrl('ws://localhost:7788/rpc')).toBe(false);
});
it('returns false for malformed URL', () => {
expect(isValidRpcUrl('not a valid url')).toBe(false);
expect(isValidRpcUrl('http://')).toBe(false);
});
});
describe('normalizeRpcUrl', () => {
it('trims whitespace', () => {
expect(normalizeRpcUrl(' http://localhost:7788/rpc ')).toBe('http://localhost:7788/rpc');
});
it('removes trailing slashes', () => {
expect(normalizeRpcUrl('http://localhost:7788/rpc/')).toBe('http://localhost:7788/rpc');
expect(normalizeRpcUrl('http://localhost:7788/')).toBe('http://localhost:7788');
});
it('handles multiple trailing slashes', () => {
expect(normalizeRpcUrl('http://localhost:7788/rpc///')).toBe('http://localhost:7788/rpc');
});
it('preserves URL without trailing slash', () => {
expect(normalizeRpcUrl('http://localhost:7788/rpc')).toBe('http://localhost:7788/rpc');
});
});
describe('getDefaultRpcUrl', () => {
it('returns the expected default URL', () => {
expect(getDefaultRpcUrl()).toBe('http://127.0.0.1:7788/rpc');
});
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Config persistence utilities for runtime settings.
*
* Handles storing/retrieving user preferences like RPC URL using
* localStorage (web) or Tauri store (desktop).
*/
import { CORE_RPC_URL } from './config';
import { isTauri } from './tauriCommands';
// Storage key for RPC URL preference
const RPC_URL_STORAGE_KEY = 'openhuman_core_rpc_url';
// Default RPC URL — canonical value from config.ts so they can never drift
const DEFAULT_RPC_URL = CORE_RPC_URL;
/**
* Check if we're running in a Tauri environment.
* Used to determine storage backend.
*/
export function isTauriEnvironment(): boolean {
return isTauri();
}
/**
* Get the stored RPC URL preference.
*
* @returns The stored RPC URL or the default if none stored
*/
export function getStoredRpcUrl(): string {
try {
const stored = localStorage.getItem(RPC_URL_STORAGE_KEY);
if (stored && stored.trim().length > 0) {
return stored.trim();
}
} catch {
// localStorage might be unavailable in some environments
console.warn('[configPersistence] Unable to access localStorage');
}
return DEFAULT_RPC_URL;
}
/**
* Store the RPC URL preference.
*
* @param url - The RPC URL to store
*/
export function storeRpcUrl(url: string): void {
try {
if (url && url.trim().length > 0) {
localStorage.setItem(RPC_URL_STORAGE_KEY, url.trim());
console.debug('[configPersistence] Stored RPC URL:', { url: url.trim() });
} else {
// Allow clearing the stored URL to reset to default
localStorage.removeItem(RPC_URL_STORAGE_KEY);
console.debug('[configPersistence] Cleared stored RPC URL');
}
} catch {
console.warn('[configPersistence] Unable to store RPC URL in localStorage');
}
}
/**
* Clear the stored RPC URL preference.
* This will cause the app to use the default RPC URL.
*/
export function clearStoredRpcUrl(): void {
storeRpcUrl('');
}
/**
* Validate an RPC URL format.
*
* @param url - The URL to validate
* @returns true if the URL is valid, false otherwise
*/
export function isValidRpcUrl(url: string): boolean {
if (!url || url.trim().length === 0) {
return false;
}
try {
const parsed = new URL(url);
// Must be http or https
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
/**
* Normalize an RPC URL by trimming whitespace and trailing slashes.
*
* @param url - The URL to normalize
* @returns The normalized URL
*/
export function normalizeRpcUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
/**
* Get the default RPC URL.
*
* @returns The default RPC URL
*/
export function getDefaultRpcUrl(): string {
return CORE_RPC_URL;
}
/**
* Build the full RPC endpoint URL from a base URL.
*
* @param baseUrl - The base URL (e.g., 'http://127.0.0.1:7788')
* @returns The full RPC endpoint URL
*/
export function buildRpcEndpoint(baseUrl: string): string {
const normalized = normalizeRpcUrl(baseUrl);
return normalized.endsWith('/rpc') ? normalized : `${normalized}/rpc`;
}
+1
View File
@@ -12,6 +12,7 @@ export interface ConfigSnapshot {
export interface ModelSettingsUpdate {
api_url?: string | null;
api_key?: string | null;
default_model?: string | null;
default_temperature?: number | null;
}
+1 -1
View File
@@ -251,4 +251,4 @@ main() {
}
# Run main function
main "$@"
main "$@"
@@ -620,6 +620,7 @@ impl Agent {
let provider: Box<dyn Provider> = providers::create_intelligent_routing_provider(
config.api_url.as_deref(),
config.api_key.as_deref(),
config,
&provider_runtime_options,
)?;
@@ -731,6 +732,7 @@ impl Agent {
{
Some(Arc::from(providers::create_routed_provider(
config.api_url.as_deref(),
config.api_key.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
+1
View File
@@ -246,6 +246,7 @@ fn build_remote_provider(config: &Config) -> anyhow::Result<ResolvedProvider> {
};
let provider_box = providers::create_routed_provider_with_options(
config.api_url.as_deref(),
config.api_key.as_deref(),
&config.reliability,
&config.model_routes,
default_model.as_str(),
+1
View File
@@ -179,6 +179,7 @@ pub(crate) async fn get_or_create_provider(
let provider = providers::create_resilient_provider_with_options(
api_url,
None,
&ctx.reliability,
&ctx.provider_runtime_options,
)?;
@@ -88,6 +88,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
};
let provider: Arc<dyn Provider> = Arc::from(providers::create_intelligent_routing_provider(
config.api_url.as_deref(),
config.api_key.as_deref(),
&config,
&provider_runtime_options,
)?);
+18 -7
View File
@@ -221,6 +221,8 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK;
use tempfile::tempdir;
#[test]
fn tick_seconds_is_sane_default() {
@@ -273,13 +275,18 @@ mod tests {
#[tokio::test]
async fn run_one_tick_returns_ok_when_no_client() {
// With no session stored, `build_composio_client` returns None and
// the tick should silently skip (returning Ok). This covers the
// early-return path that's otherwise only hit in production.
//
// Note: this uses the same `load_config_with_timeout()` call path
// that real startup uses. If some other test has written a session
// profile to disk, this test accepts either outcome (Ok) gracefully.
// Isolate the workspace/env so config loading doesn't contend with
// sibling tests mutating OPENHUMAN_WORKSPACE in parallel.
let _guard = ENV_LOCK.lock().expect("env lock");
let tmp = tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
// With no session stored in the isolated workspace,
// `build_composio_client` returns None and the tick should
// silently skip (returning Ok). This covers the early-return
// path that's otherwise only hit in production.
let inner = tokio::time::timeout(Duration::from_secs(5), run_one_tick())
.await
.expect("run_one_tick should not hang indefinitely during tests");
@@ -287,6 +294,10 @@ mod tests {
inner.is_ok(),
"run_one_tick should return Ok when no client is available: {inner:?}"
);
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
#[tokio::test]
+9
View File
@@ -151,6 +151,7 @@ pub fn snapshot_config_json(config: &Config) -> Result<serde_json::Value, String
#[derive(Debug, Clone, Default)]
pub struct ModelSettingsPatch {
pub api_url: Option<String>,
pub api_key: Option<String>,
pub default_model: Option<String>,
pub default_temperature: Option<f64>,
}
@@ -224,6 +225,14 @@ pub async fn apply_model_settings(
Some(api_url)
};
}
if let Some(api_key) = update.api_key {
let trimmed_key = api_key.trim();
config.api_key = if trimmed_key.is_empty() {
None
} else {
Some(trimmed_key.to_string())
};
}
if let Some(model) = update.default_model {
config.default_model = if model.trim().is_empty() {
None
+2
View File
@@ -220,6 +220,7 @@ async fn apply_model_settings_updates_fields_and_persists_snapshot() {
let mut cfg = tmp_config(&tmp);
let patch = ModelSettingsPatch {
api_url: Some("https://api.example.test".into()),
api_key: None,
default_model: Some("gpt-4o".into()),
default_temperature: Some(0.25),
};
@@ -240,6 +241,7 @@ async fn apply_model_settings_empty_strings_clear_optional_fields() {
cfg.default_model = Some("prev-model".into());
let patch = ModelSettingsPatch {
api_url: Some("".into()),
api_key: None,
default_model: Some("".into()),
default_temperature: None,
};
+6
View File
@@ -9,6 +9,10 @@ pub struct LocalAiConfig {
pub enabled: bool,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_model_id")]
pub model_id: String,
#[serde(default = "default_chat_model_id")]
@@ -156,6 +160,8 @@ impl Default for LocalAiConfig {
Self {
enabled: default_enabled(),
provider: default_provider(),
base_url: None,
api_key: None,
model_id: default_model_id(),
chat_model_id: default_chat_model_id(),
vision_model_id: default_vision_model_id(),
+2
View File
@@ -29,6 +29,7 @@ pub struct Config {
#[serde(skip)]
pub config_path: PathBuf,
pub api_url: Option<String>,
pub api_key: Option<String>,
pub default_model: Option<String>,
pub default_temperature: f64,
@@ -240,6 +241,7 @@ impl Default for Config {
workspace_dir: openhuman_dir.join("workspace"),
config_path: openhuman_dir.join("config.toml"),
api_url: None,
api_key: None,
default_model: Some(DEFAULT_MODEL.to_string()),
default_temperature: 0.7,
observability: ObservabilityConfig::default(),
+48
View File
@@ -100,6 +100,7 @@ struct VoiceServerSettingsUpdate {
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("get_config"),
schemas("get_client_config"),
schemas("update_model_settings"),
schemas("update_memory_settings"),
schemas("update_screen_intelligence_settings"),
@@ -129,6 +130,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("get_config"),
handler: handle_get_config,
},
RegisteredController {
schema: schemas("get_client_config"),
handler: handle_get_client_config,
},
RegisteredController {
schema: schemas("update_model_settings"),
handler: handle_update_model_settings,
@@ -226,6 +231,32 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"get_client_config" => ControllerSchema {
namespace: "config",
function: "get_client_config",
description: "Read safe client-facing config fields (api_url, feature flags). No secrets.",
inputs: vec![],
outputs: vec![
FieldSchema {
name: "api_url",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Configured backend API URL, if any.",
required: false,
},
FieldSchema {
name: "default_model",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Default model identifier.",
required: false,
},
FieldSchema {
name: "app_version",
ty: TypeSchema::String,
comment: "OpenHuman core version.",
required: true,
},
],
},
"update_model_settings" => ControllerSchema {
namespace: "config",
function: "update_model_settings",
@@ -553,11 +584,28 @@ fn handle_get_config(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::load_and_get_config_snapshot().await?) })
}
fn handle_get_client_config(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let app_version =
std::env::var("OPENHUMAN_APP_VERSION").unwrap_or_else(|_| "unknown".to_string());
to_json(RpcOutcome::new(
serde_json::json!({
"api_url": config.api_url,
"default_model": config.default_model,
"app_version": app_version,
}),
vec!["client config read".to_string()],
))
})
}
fn handle_update_model_settings(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let update = deserialize_params::<ModelSettingsUpdate>(params)?;
let patch = config_rpc::ModelSettingsPatch {
api_url: update.api_url,
api_key: None,
default_model: update.default_model,
default_temperature: update.default_temperature,
};
@@ -328,7 +328,11 @@ pub async fn summarise_profile_with_llm(config: &Config, raw_md: &str) -> anyhow
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
let provider = create_backend_inference_provider(config.api_url.as_deref(), &options)?;
let provider = create_backend_inference_provider(
config.api_url.as_deref(),
config.api_key.as_deref(),
&options,
)?;
let system = "\
You are a profile analyst. You will receive a user's LinkedIn profile in Markdown format. \
+2 -1
View File
@@ -71,7 +71,8 @@ pub async fn agent_chat_simple(
};
let provider = providers::create_routed_provider_with_options(
effective.api_url.as_deref(),
config.api_url.as_deref(),
config.api_key.as_deref(),
&effective.reliability,
&effective.model_routes,
default_model.as_str(),
+40 -13
View File
@@ -150,24 +150,48 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
/// Create the OpenHuman backend inference client (session JWT only).
pub fn create_backend_inference_provider(
api_url: Option<&str>,
api_key: Option<&str>,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
Ok(Box::new(openhuman_backend::OpenHumanBackendProvider::new(
api_url, options,
)))
if let (Some(url), Some(key)) = (api_url, api_key) {
Ok(Box::new(
crate::openhuman::providers::compatible::OpenAiCompatibleProvider::new(
"custom_openai",
url,
Some(key),
crate::openhuman::providers::compatible::AuthStyle::Bearer,
),
))
} else {
if api_key.is_some() && api_url.is_none() {
log::warn!(
"[providers] api_key provided without api_url — key will be ignored, using default backend provider"
);
}
Ok(Box::new(openhuman_backend::OpenHumanBackendProvider::new(
api_url, options,
)))
}
}
/// Create provider chain with retry and fallback behavior.
pub fn create_resilient_provider(
api_url: Option<&str>,
api_key: Option<&str>,
reliability: &crate::openhuman::config::ReliabilityConfig,
) -> anyhow::Result<Box<dyn Provider>> {
create_resilient_provider_with_options(api_url, reliability, &ProviderRuntimeOptions::default())
create_resilient_provider_with_options(
api_url,
api_key,
reliability,
&ProviderRuntimeOptions::default(),
)
}
/// Create provider chain with retry/fallback behavior and auth runtime options.
pub fn create_resilient_provider_with_options(
api_url: Option<&str>,
api_key: Option<&str>,
reliability: &crate::openhuman::config::ReliabilityConfig,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
@@ -177,7 +201,7 @@ pub fn create_resilient_provider_with_options(
);
}
let primary_provider = create_backend_inference_provider(api_url, options)?;
let primary_provider = create_backend_inference_provider(api_url, api_key, options)?;
let providers: Vec<(String, Box<dyn Provider>)> =
vec![(INFERENCE_BACKEND_ID.to_string(), primary_provider)];
@@ -194,12 +218,14 @@ pub fn create_resilient_provider_with_options(
/// Create a RouterProvider if model routes are configured, otherwise return a resilient provider.
pub fn create_routed_provider(
api_url: Option<&str>,
api_key: Option<&str>,
reliability: &crate::openhuman::config::ReliabilityConfig,
model_routes: &[crate::openhuman::config::ModelRouteConfig],
default_model: &str,
) -> anyhow::Result<Box<dyn Provider>> {
create_routed_provider_with_options(
api_url,
api_key,
reliability,
model_routes,
default_model,
@@ -209,16 +235,17 @@ pub fn create_routed_provider(
pub fn create_routed_provider_with_options(
api_url: Option<&str>,
api_key: Option<&str>,
reliability: &crate::openhuman::config::ReliabilityConfig,
model_routes: &[crate::openhuman::config::ModelRouteConfig],
default_model: &str,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
if model_routes.is_empty() {
return create_resilient_provider_with_options(api_url, reliability, options);
return create_resilient_provider_with_options(api_url, api_key, reliability, options);
}
let backend = create_backend_inference_provider(api_url, options)?;
let backend = create_backend_inference_provider(api_url, api_key, options)?;
let providers: Vec<(String, Box<dyn Provider>)> =
vec![(INFERENCE_BACKEND_ID.to_string(), backend)];
@@ -254,10 +281,11 @@ pub fn create_routed_provider_with_options(
/// `"routing"` tracing target.
pub fn create_intelligent_routing_provider(
api_url: Option<&str>,
api_key: Option<&str>,
config: &crate::openhuman::config::Config,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
let remote = create_backend_inference_provider(api_url, options)?;
let remote = create_backend_inference_provider(api_url, api_key, options)?;
let default_model = config
.default_model
.as_deref()
@@ -318,10 +346,9 @@ mod tests {
#[test]
fn factory_backend() {
assert!(create_backend_inference_provider(
Some("https://api.example.com"),
&ProviderRuntimeOptions::default()
)
.is_ok());
assert!(
create_backend_inference_provider(None, None, &ProviderRuntimeOptions::default())
.is_ok()
);
}
}
+14 -5
View File
@@ -40,18 +40,27 @@ pub fn new_provider(
.filter(|s| !s.is_empty());
let provider_kind = local_ai_config.provider.trim().to_ascii_lowercase();
let use_openai_compat_local =
override_base.is_some() || matches!(provider_kind.as_str(), "llamacpp" | "llama-server");
let use_openai_compat_local = override_base.is_some()
|| matches!(
provider_kind.as_str(),
"llamacpp" | "llama-server" | "custom_openai"
);
let (provider_label, local_base, health) = if use_openai_compat_local {
let base = override_base.unwrap_or_else(|| "http://127.0.0.1:8080/v1".to_string());
let base = override_base
.or_else(|| local_ai_config.base_url.clone())
.unwrap_or_else(|| "http://127.0.0.1:8080/v1".to_string());
let probe = format!("{base}/models");
tracing::debug!(
provider = %provider_kind,
"[routing] local inference configured via OpenAI-compat (non-ollama)"
);
(
"llamacpp",
if provider_kind == "custom_openai" {
"custom_openai"
} else {
"llamacpp"
},
base,
Arc::new(LocalHealthChecker::with_probe_url(probe, LOCAL_HEALTH_TTL)),
)
@@ -68,7 +77,7 @@ pub fn new_provider(
let local: Box<dyn Provider> = Box::new(OpenAiCompatibleProvider::new(
provider_label,
&local_base,
None, // local servers do not require authentication
local_ai_config.api_key.as_deref(),
AuthStyle::Bearer,
));
+1
View File
@@ -282,6 +282,7 @@ pub async fn thread_generate_title(
let provider = match providers::create_intelligent_routing_provider(
config.api_url.as_deref(),
config.api_key.as_deref(),
&config,
&provider_runtime_options,
) {
@@ -185,6 +185,7 @@ impl Tool for DelegateTool {
}
let provider: Box<dyn Provider> = match providers::create_backend_inference_provider(
None,
None,
&self.provider_runtime_options,
) {