mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
Steven Enamakel
parent
d9ed70142e
commit
b56e846988
@@ -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
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)?);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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. \
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user