diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 6de47a44a..2df009fd1 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -22,7 +22,10 @@ "opener:default", { "identifier": "opener:allow-open-url", - "allow": [{ "url": "obsidian://open*" }] + "allow": [ + { "url": "obsidian://open*" }, + { "url": "https://ollama.com/*" } + ] }, "updater:default", "allow-core-process", diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index d8846edba..856bdd08d 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -137,9 +137,9 @@ const SettingsHome = () => { onClick: () => navigateToSettings('features'), }, { - id: 'ai-models', - title: 'AI & Models', - description: 'Local AI model setup, downloads, and LLM provider', + id: 'ai', + title: 'AI', + description: 'Cloud providers, local Ollama models, and per-workload routing', icon: ( { /> ), - onClick: () => navigateToSettings('ai-models'), + onClick: () => navigateToSettings('ai'), }, ], }, diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index 8e97a6511..c2432d845 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -114,13 +114,16 @@ describe('SettingsHome', () => { ); }); - it('places Features, AI & Models under Features & AI', () => { + it('places Features and AI under Features & AI', () => { + // After #1710: the catch-all "AI & Models" row collapsed into a + // nested "AI" section page (with LLM + Voice as children), so + // the home tile label changed from "AI & Models" to just "AI". renderSettingsHome(); const header = screen.getByText('Features & AI'); expect(header.compareDocumentPosition(screen.getByText('Features'))).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); - expect(header.compareDocumentPosition(screen.getByText('AI & Models'))).toBe( + expect(header.compareDocumentPosition(screen.getByText('AI'))).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); }); @@ -219,12 +222,12 @@ describe('SettingsHome', () => { expect(mockNavigateToSettings).toHaveBeenCalledWith('features'); }); - it('navigates to ai-models settings when AI & Models is clicked', async () => { + it('navigates to ai settings when AI is clicked', async () => { const user = userEvent.setup(); renderSettingsHome(); - await user.click(screen.getByText('AI & Models').closest('button')!); - expect(mockNavigateToSettings).toHaveBeenCalledWith('ai-models'); + await user.click(screen.getByText('AI').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('ai'); }); it('opens billing URL when Billing & Usage is clicked', async () => { diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index a63dba27c..e2b825119 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -5,7 +5,6 @@ export type SettingsRoute = | 'home' | 'account' | 'features' - | 'ai-models' | 'connections' | 'messaging' | 'cron-jobs' @@ -18,7 +17,7 @@ export type SettingsRoute = | 'team-invites' | 'developer-options' | 'ai' - | 'local-model' + | 'llm' | 'voice' | 'tools' | 'memory-data' @@ -81,7 +80,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/team')) return 'team'; if (path.includes('/settings/account')) return 'account'; if (path.includes('/settings/features')) return 'features'; - if (path.includes('/settings/ai-models')) return 'ai-models'; if (path.includes('/settings/connections')) return 'connections'; if (path.includes('/settings/messaging')) return 'messaging'; if (path.includes('/settings/cron-jobs')) return 'cron-jobs'; @@ -92,9 +90,9 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/privacy')) return 'privacy'; if (path.includes('/settings/billing')) return 'billing'; if (path.includes('/settings/developer-options')) return 'developer-options'; + if (path.includes('/settings/llm')) return 'llm'; if (path.includes('/settings/ai')) return 'ai'; if (path.includes('/settings/local-model-debug')) return 'local-model-debug'; - if (path.includes('/settings/local-model')) return 'local-model'; if (path.includes('/settings/voice-debug')) return 'voice-debug'; if (path.includes('/settings/voice')) return 'voice'; if (path.includes('/settings/tools')) return 'tools'; @@ -160,10 +158,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { onClick: () => navigate('/settings/features'), }; - const aiModelsCrumb: BreadcrumbItem = { - label: 'AI & Models', - onClick: () => navigate('/settings/ai-models'), - }; + const aiCrumb: BreadcrumbItem = { label: 'AI', onClick: () => navigate('/settings/ai') }; const teamCrumb: BreadcrumbItem = { label: 'Team', onClick: () => navigate('/settings/team') }; @@ -177,7 +172,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // Section pages case 'account': case 'features': - case 'ai-models': + case 'ai': return [settingsCrumb]; // Leaf panels under account @@ -197,10 +192,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'tools': return [settingsCrumb, featuresCrumb]; - // Leaf panels under AI & Models - case 'local-model': + // Leaf panels under AI case 'voice': - return [settingsCrumb, aiModelsCrumb]; + case 'llm': + return [settingsCrumb, aiCrumb]; // Team sub-pages case 'team-members': @@ -208,7 +203,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { return [settingsCrumb, accountCrumb, teamCrumb]; // Developer sub-pages - case 'ai': case 'agent-chat': case 'cron-jobs': case 'screen-awareness-debug': diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 5f68e0faa..901629602 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -1,309 +1,1511 @@ -import { useCallback, useEffect, useState } from 'react'; +/* + * AI settings — three orthogonal sections: + * 1. Cloud providers (credentials + primary selection) + * 2. Local provider (Ollama runtime + installed models) + * 3. Workload routing (8-row matrix; per-workload provider + model) + * + * "Primary cloud" is an abstraction: any workload set to "Primary" inherits + * whichever cloud provider is currently marked primary. Overrides are explicit + * per row, so the resolved provider+model is always rendered inline. + */ +import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + LuCheck, + LuCircleAlert, + LuCloud, + LuCpu, + LuDownload, + LuKey, + LuLoader, + LuPencilLine, + LuPlus, + LuPower, + LuRefreshCw, + LuServer, + LuShield, + LuTrash2, + LuWand, + LuZap, +} from 'react-icons/lu'; import { - aiGetConfig, - type AIPreview, - aiRefreshConfig, - type LocalAiStatus, - openhumanLocalAiDownload, - openhumanLocalAiStatus, -} from '../../../utils/tauriCommands'; + type AISettings as ApiAISettings, + type ProviderRef as ApiProviderRef, + clearCloudProviderKey, + type CloudProviderView, + loadAISettings, + loadLocalProviderSnapshot, + localProvider, + type LocalProviderSnapshot, + saveAISettings, + setCloudProviderKey, +} from '../../../services/api/aiSettingsApi'; +import { openUrl } from '../../../utils/openUrl'; +import type { CloudProviderType as ApiCloudProviderType } from '../../../utils/tauriCommands/config'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -const AIPanel = () => { - const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); - const [aiConfig, setAiConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [refreshingComponent, setRefreshingComponent] = useState<'soul' | 'tools' | 'all' | null>( - null - ); - const [error, setError] = useState(''); - const [localAiStatus, setLocalAiStatus] = useState(null); - const localAiRuntimeEnabled = localAiStatus != null && localAiStatus.state !== 'disabled'; +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── - const loadAIPreview = useCallback(async () => { +type CloudProviderType = 'openhuman' | 'openai' | 'anthropic' | 'openrouter' | 'custom'; + +type CloudProvider = { + id: string; + type: CloudProviderType; + label: string; + endpoint: string; + maskedKey: string; + defaultModel: string; +}; + +type OllamaState = 'disabled' | 'missing' | 'stopped' | 'starting' | 'running' | 'error'; + +type OllamaModel = { id: string; sizeBytes: number; family: string }; + +type WorkloadId = + | 'reasoning' + | 'agentic' + | 'coding' + | 'memory' + | 'embeddings' + | 'heartbeat' + | 'learning' + | 'subconscious'; + +type WorkloadGroup = 'chat' | 'background'; + +type ProviderRef = + | { kind: 'primary' } + | { kind: 'cloud'; providerId: string; model: string } + | { kind: 'local'; model: string }; + +type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; description: string }; + +type RoutingMap = Record; + +// ───────────────────────────────────────────────────────────────────────────── +// Static catalog +// ───────────────────────────────────────────────────────────────────────────── + +const WORKLOADS: Workload[] = [ + { + id: 'reasoning', + group: 'chat', + label: 'Reasoning', + description: 'Main chat agent, meeting summarizer', + }, + { + id: 'agentic', + group: 'chat', + label: 'Agentic', + description: 'Sub-agent runners, tool loops, GIF decisions', + }, + { + id: 'coding', + group: 'chat', + label: 'Coding', + description: 'Code generation and refactor passes', + }, + { + id: 'memory', + group: 'background', + label: 'Memory summarization', + description: 'Tree-extracts and consolidations', + }, + { + id: 'embeddings', + group: 'background', + label: 'Embeddings', + description: 'Vector encoding for memory retrieval', + }, + { + id: 'heartbeat', + group: 'background', + label: 'Heartbeat', + description: 'Background reasoning between user turns', + }, + { + id: 'learning', + group: 'background', + label: 'Learning · Reflections', + description: 'Periodic reflection over recent history', + }, + { + id: 'subconscious', + group: 'background', + label: 'Subconscious', + description: 'Eventfulness scoring + drift checks', + }, +]; + +const PROVIDER_META: Record< + CloudProviderType, + { label: string; rail: string; pill: string; icon: ReactElement } +> = { + openhuman: { + label: 'OpenHuman', + rail: 'bg-primary-500', + pill: 'bg-primary-50 text-primary-700 ring-primary-200', + icon: , + }, + openai: { + label: 'OpenAI', + rail: 'bg-sage-500', + pill: 'bg-sage-50 text-sage-700 ring-sage-200', + icon: , + }, + anthropic: { + label: 'Anthropic', + rail: 'bg-amber-500', + pill: 'bg-amber-50 text-amber-700 ring-amber-200', + icon: , + }, + openrouter: { + label: 'OpenRouter', + rail: 'bg-slate-500', + pill: 'bg-slate-50 text-slate-700 ring-slate-200', + icon: , + }, + custom: { + label: 'Custom', + rail: 'bg-stone-500', + pill: 'bg-stone-100 text-stone-700 ring-stone-200', + icon: , + }, +}; + +const TIER_PRESETS = [ + { id: 'lite', label: '2–4 GB RAM', model: 'llama3.2:1b', blurb: 'Tiny + responsive' }, + { id: 'standard', label: '4–8 GB RAM', model: 'llama3.1:8b', blurb: 'Balanced default' }, + { id: 'studio', label: '8 GB+ RAM', model: 'qwen2.5:14b', blurb: 'Headroom for nuance' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// API-adapter hooks +// +// The panel works in terms of `CloudProvider` (with a derived `label` + +// `maskedKey`) and `ProviderRef.cloud.providerId`. The wire format uses +// provider TYPE, not id. These hooks bridge the two. +// ───────────────────────────────────────────────────────────────────────────── + +type AISettings = { cloudProviders: CloudProvider[]; primaryCloudId: string; routing: RoutingMap }; + +const EMPTY_SETTINGS: AISettings = { + cloudProviders: [], + primaryCloudId: '', + routing: { + reasoning: { kind: 'primary' }, + agentic: { kind: 'primary' }, + coding: { kind: 'primary' }, + memory: { kind: 'primary' }, + embeddings: { kind: 'primary' }, + heartbeat: { kind: 'primary' }, + learning: { kind: 'primary' }, + subconscious: { kind: 'primary' }, + }, +}; + +function maskKeyLabel(hasKey: boolean): string { + return hasKey ? '•••• configured' : 'Not configured'; +} + +function toPanelProvider(p: CloudProviderView): CloudProvider { + return { + id: p.id, + type: p.type as CloudProviderType, + label: PROVIDER_META[p.type as CloudProviderType].label, + endpoint: p.endpoint, + maskedKey: maskKeyLabel(p.has_api_key), + defaultModel: p.default_model, + }; +} + +function toPanelRoutingFromApi(api: ApiAISettings): { panel: AISettings } { + const cloudProviders = api.cloudProviders.map(toPanelProvider); + const idByType = new Map(); + for (const p of cloudProviders) { + idByType.set(p.type, p.id); + } + const liftRef = (r: ApiProviderRef): ProviderRef => { + if (r.kind === 'primary') return { kind: 'primary' }; + if (r.kind === 'local') return { kind: 'local', model: r.model }; + // cloud + const id = idByType.get(r.providerType) ?? ''; + if (!id) { + // Provider type referenced but no entry — degrade to primary. + return { kind: 'primary' }; + } + return { kind: 'cloud', providerId: id, model: r.model }; + }; + const routing: RoutingMap = { + reasoning: liftRef(api.routing.reasoning), + agentic: liftRef(api.routing.agentic), + coding: liftRef(api.routing.coding), + memory: liftRef(api.routing.memory), + embeddings: liftRef(api.routing.embeddings), + heartbeat: liftRef(api.routing.heartbeat), + learning: liftRef(api.routing.learning), + subconscious: liftRef(api.routing.subconscious), + }; + return { + panel: { + cloudProviders, + primaryCloudId: api.primaryCloudId ?? cloudProviders[0]?.id ?? '', + routing, + }, + }; +} + +function toApiRefFromPanel(r: ProviderRef, providers: CloudProvider[]): ApiProviderRef { + if (r.kind === 'primary') return { kind: 'primary' }; + if (r.kind === 'local') return { kind: 'local', model: r.model }; + const entry = providers.find(p => p.id === r.providerId); + if (!entry) return { kind: 'primary' }; + return { kind: 'cloud', providerType: entry.type as ApiCloudProviderType, model: r.model }; +} + +function toApiSettings(panel: AISettings): ApiAISettings { + return { + cloudProviders: panel.cloudProviders.map(p => ({ + id: p.id, + type: p.type as ApiCloudProviderType, + endpoint: p.endpoint, + default_model: p.defaultModel, + has_api_key: p.maskedKey.startsWith('••••'), + })), + primaryCloudId: panel.primaryCloudId || null, + routing: { + reasoning: toApiRefFromPanel(panel.routing.reasoning, panel.cloudProviders), + agentic: toApiRefFromPanel(panel.routing.agentic, panel.cloudProviders), + coding: toApiRefFromPanel(panel.routing.coding, panel.cloudProviders), + memory: toApiRefFromPanel(panel.routing.memory, panel.cloudProviders), + embeddings: toApiRefFromPanel(panel.routing.embeddings, panel.cloudProviders), + heartbeat: toApiRefFromPanel(panel.routing.heartbeat, panel.cloudProviders), + learning: toApiRefFromPanel(panel.routing.learning, panel.cloudProviders), + subconscious: toApiRefFromPanel(panel.routing.subconscious, panel.cloudProviders), + }, + }; +} + +function useAISettings() { + const [saved, setSaved] = useState(EMPTY_SETTINGS); + const [draft, setDraft] = useState(EMPTY_SETTINGS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const reload = useCallback(async () => { setLoading(true); setError(''); try { - const config = await aiGetConfig(); - setAiConfig(config); - if (config.metadata.errors.length > 0) { - setError(config.metadata.errors.join('; ')); - } + const api = await loadAISettings(); + const { panel } = toPanelRoutingFromApi(api); + setSaved(panel); + setDraft(panel); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load AI configuration'; + const message = err instanceof Error ? err.message : 'Failed to load AI settings'; setError(message); } finally { setLoading(false); } }, []); - const loadLocalAiStatus = useCallback(async () => { + useEffect(() => { + void reload(); + }, [reload]); + + const isDirty = JSON.stringify(saved) !== JSON.stringify(draft); + + const save = useCallback(async () => { try { - const result = await openhumanLocalAiStatus(); - setLocalAiStatus(result.result); + await saveAISettings(toApiSettings(saved), toApiSettings(draft)); + setSaved(draft); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save AI settings'; + setError(message); + } + }, [saved, draft]); + + const discard = useCallback(() => setDraft(saved), [saved]); + + return { saved, draft, setDraft, isDirty, save, discard, loading, error, reload }; +} + +function useOllamaStatus() { + const [snapshot, setSnapshot] = useState(null); + const lastPollRef = useRef(0); + + const refresh = useCallback(async (): Promise => { + try { + const s = await loadLocalProviderSnapshot(); + setSnapshot(s); + lastPollRef.current = Date.now(); + return s; } catch { - setLocalAiStatus(null); + // Swallow — keep last good snapshot, return null so callers can + // detect failure without a try/catch. + return null; } }, []); useEffect(() => { - const initialLoad = window.setTimeout(() => { - void loadAIPreview(); - void loadLocalAiStatus(); - }, 0); - const timer = window.setInterval(() => { - void loadLocalAiStatus(); - }, 5000); - return () => { - window.clearTimeout(initialLoad); - window.clearInterval(timer); - }; - }, [loadAIPreview, loadLocalAiStatus]); + void refresh(); + const id = window.setInterval(() => void refresh(), 5000); + return () => window.clearInterval(id); + }, [refresh]); - const refreshConfig = async (target: 'soul' | 'tools' | 'all') => { - setRefreshingComponent(target); - setError(''); - try { - const config = await aiRefreshConfig(); - setAiConfig(config); - if (config.metadata.errors.length > 0) { - setError(config.metadata.errors.join('; ')); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to refresh AI configuration'; - setError(message); - } finally { - setRefreshingComponent(null); - } - }; + // Translate to the OllamaState the panel UI expects. + // + // `disabled` is the config-side master switch (user turned local AI off + // via the toggle). `missing` is "user wants local AI but the daemon + // isn't installed". Keep them distinct so the toggle's `checked` state + // and the Install/Retry button can render the right thing. + const state: OllamaState = useMemo(() => { + if (!snapshot) return 'stopped'; + const stateStr = snapshot.status?.state ?? ''; + if (stateStr === 'disabled') return 'disabled'; + if (snapshot.diagnostics?.ollama_running) return 'running'; + if (stateStr === 'missing') return 'missing'; + if (stateStr === 'starting' || stateStr === 'downloading') return 'starting'; + if (stateStr === 'error') return 'error'; + return 'stopped'; + }, [snapshot]); + const version = snapshot?.diagnostics?.ollama_binary_path + ? // Diagnostics doesn't surface a version string today; show the binary path tail. + (snapshot.diagnostics.ollama_binary_path.split(/[\\/]/).pop() ?? '') + : ''; + + return { state, version, snapshot, refresh }; +} + +function useInstalledModels(snapshot: LocalProviderSnapshot | null): OllamaModel[] { + return useMemo(() => { + const list = snapshot?.installedModels ?? []; + return list.map(m => ({ + id: m.name, + sizeBytes: m.size ?? 0, + family: m.name.split(/[:/]/, 1)[0] ?? 'model', + })); + }, [snapshot]); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Primitives +// ───────────────────────────────────────────────────────────────────────────── + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const formatBytes = (n: number): string => { + if (n < 1024 ** 2) return `${(n / 1024).toFixed(0)} KB`; + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(0)} MB`; + return `${(n / 1024 ** 3).toFixed(1)} GB`; +}; + +const StatusDot = ({ state }: { state: OllamaState }) => { + const tone = + state === 'running' + ? 'bg-sage-500' + : state === 'starting' + ? 'bg-amber-500' + : state === 'error' + ? 'bg-coral-500' + : 'bg-stone-300'; return ( -
- + + + ); +}; -
-
-

AI System Overview

-

- Prompt and markdown orchestration is handled in Rust runtime. -

+const ProviderChip = ({ type }: { type: CloudProviderType }) => { + const meta = PROVIDER_META[type]; + return ( + + {meta.icon} + {meta.label} + + ); +}; - {aiConfig && ( -
-
-
- -
- {aiConfig.metadata.hasFallbacks ? 'Fallback Mode' : 'Loaded from Runtime'} -
-
-
- -
- {aiConfig.metadata.loadingDuration}ms -
-
-
-
- )} -
+// ───────────────────────────────────────────────────────────────────────────── +// Cloud provider card +// ───────────────────────────────────────────────────────────────────────────── -
-
-

Local Model Runtime

-
+const CloudProviderCard = ({ + provider, + isPrimary, + onMakePrimary, + onEdit, + onRemove, +}: { + provider: CloudProvider; + isPrimary: boolean; + onMakePrimary: () => void; + onEdit: () => void; + onRemove: () => void; +}) => { + const meta = PROVIDER_META[provider.type]; + return ( +
+
+
+
+
+ {provider.label} + {isPrimary && ( + + Primary + + )} + +
+
+ {!isPrimary && ( - -
+ )} + {/* OpenHuman is the signed-in default: its endpoint comes from + the user's account, its key is the session JWT (managed + separately), and its type can't be changed. So edit + delete + are both meaningless here — hide them to avoid confusion. + "Set primary" stays, so the user can still re-mark OpenHuman + as primary if they've switched to another provider. */} + {provider.type !== 'openhuman' && ( + <> + + + + )}
- {localAiStatus ? ( -
-
- State - {localAiStatus.state} -
-
- Target Model - {localAiStatus.model_id} -
- {localAiStatus.download_progress != null && ( -
- Download: {(localAiStatus.download_progress * 100).toFixed(0)}% -
- )} - {localAiStatus.warning && ( -
{localAiStatus.warning}
- )} -
- ) : ( -
Local model status unavailable.
- )} -
- -
-
-

SOUL Persona Configuration

- -
- - {loading && ( -
- Loading SOUL configuration... -
- )} - - {error && ( -
-
{error}
-
- )} - - {aiConfig && ( -
-
- -
{aiConfig.soul.name}
-
{aiConfig.soul.description}
-
- - {aiConfig.soul.personalityPreview.length > 0 && ( -
- -
- {aiConfig.soul.personalityPreview.join(' • ')} -
-
- )} - - {aiConfig.soul.safetyRulesPreview.length > 0 && ( -
- -
- {aiConfig.soul.safetyRulesPreview.join(' • ')} -
-
- )} - -
-
- Source: {aiConfig.metadata.sources.soul} -
-
- Loaded: {new Date(aiConfig.soul.loadedAt).toLocaleTimeString()} -
-
-
- )} -
- -
-
-

TOOLS Configuration

- -
- - {aiConfig && ( -
-
-
- -
- {aiConfig.tools.totalTools} tools -
-
-
- -
- {aiConfig.tools.activeSkills} skills -
-
-
- - {aiConfig.tools.skillsPreview.length > 0 && ( -
- -
- {aiConfig.tools.skillsPreview.join(' • ')} -
-
- )} - -
-
- Source: {aiConfig.metadata.sources.tools} -
-
- Loaded: {new Date(aiConfig.tools.loadedAt).toLocaleTimeString()} -
-
-
- )} -
- -
-
- -
-
+
+ {provider.type === 'openhuman' ? ( +
Signed-in default · no configuration needed
+ ) : ( +
+
Endpoint
+
{provider.endpoint}
+
Key
+
+ + {provider.maskedKey} +
+
Model
+
{provider.defaultModel}
+
+ )}
); }; +// ───────────────────────────────────────────────────────────────────────────── +// Workload row (stacked, narrow-friendly) +// ───────────────────────────────────────────────────────────────────────────── + +type WorkloadRowProps = { + workload: Workload; + ref_: ProviderRef; + primary: CloudProvider | undefined; + cloudProviders: CloudProvider[]; + localModels: OllamaModel[]; + ollamaState: OllamaState; + onChange: (next: ProviderRef) => void; +}; + +const WorkloadRow = ({ + workload, + ref_, + primary, + cloudProviders, + localModels, + ollamaState, + onChange, +}: WorkloadRowProps) => { + const localAvailable = ollamaState === 'running' && localModels.length > 0; + const selectedCloud = + ref_.kind === 'cloud' ? cloudProviders.find(c => c.id === ref_.providerId) : undefined; + + const tabBase = 'flex-1 px-2 py-1 text-[11px] font-medium transition-colors'; + const tab = (active: boolean, disabled = false) => + `${tabBase} first:rounded-l last:rounded-r ${ + active + ? 'bg-white text-stone-900 shadow-subtle ring-1 ring-stone-200' + : disabled + ? 'text-stone-300' + : 'text-stone-500 hover:text-stone-800' + } ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`; + + let resolved: string; + if (ref_.kind === 'primary') { + if (!primary) resolved = 'no primary set'; + else if (primary.type === 'openhuman') resolved = 'openhuman'; + else resolved = `${PROVIDER_META[primary.type].label.toLowerCase()} · ${primary.defaultModel}`; + } else if (ref_.kind === 'cloud') { + if (!selectedCloud) resolved = ref_.model; + else if (selectedCloud.type === 'openhuman') resolved = 'openhuman'; + else resolved = `${PROVIDER_META[selectedCloud.type].label.toLowerCase()} · ${ref_.model}`; + } else { + resolved = `ollama · ${ref_.model}`; + } + + return ( +
+
+
+
{workload.label}
+
{workload.description}
+
+
+ + + +
+
+ + {ref_.kind === 'primary' && ( +
↳ {resolved}
+ )} + {ref_.kind === 'cloud' && ( +
+ + {selectedCloud?.type !== 'openhuman' && ( + + onChange({ kind: 'cloud', providerId: ref_.providerId, model: e.target.value }) + } + className="w-28 rounded-md border border-stone-300 bg-white px-2 py-1 font-mono text-[11px] text-stone-800 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + /> + )} +
+ )} + {ref_.kind === 'local' && ( +
+ +
+ )} +
+ ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Save bar (sticky) +// ───────────────────────────────────────────────────────────────────────────── + +const SaveBar = ({ + diffSummary, + changeCount, + onSave, + onDiscard, +}: { + diffSummary: string[]; + changeCount: number; + onSave: () => void; + onDiscard: () => void; +}) => ( +
+
+
+ +
+
+
+ {changeCount} unsaved change{changeCount === 1 ? '' : 's'} +
+
+ {diffSummary.slice(0, 2).join(' · ')} + {diffSummary.length > 2 ? ` · +${diffSummary.length - 2}` : ''} +
+
+ + +
+
+); + +// ───────────────────────────────────────────────────────────────────────────── +// Main panel +// ───────────────────────────────────────────────────────────────────────────── + +const AIPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { saved, draft, setDraft, isDirty, save, discard, loading, error, reload } = + useAISettings(); + const ollama = useOllamaStatus(); + const installed = useInstalledModels(ollama.snapshot); + const [editing, setEditing] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [customPathInput, setCustomPathInput] = useState(''); + // Seed the custom-path input from the resolved binary path the FIRST time + // diagnostics arrives, so the field shows what's currently in use. + const resolvedBinaryPath = ollama.snapshot?.diagnostics?.ollama_binary_path ?? ''; + useEffect(() => { + if (customPathInput === '' && resolvedBinaryPath) { + setCustomPathInput(resolvedBinaryPath); + } + // We deliberately do NOT re-sync on every diagnostics tick — that would + // clobber in-flight edits while the user is typing. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedBinaryPath]); + + const daemonWarning = ollama.snapshot?.status?.warning ?? ''; + const isDaemonConflict = + daemonWarning.toLowerCase().includes('external ollama daemon') || + daemonWarning.toLowerCase().includes('broken runner'); + + const primary = useMemo( + () => draft.cloudProviders.find(p => p.id === draft.primaryCloudId), + [draft] + ); + + const updateRouting = (id: WorkloadId, next: ProviderRef) => + setDraft({ ...draft, routing: { ...draft.routing, [id]: next } }); + + const applyPreset = (kind: 'cloud' | 'local' | 'mixed') => { + const next: RoutingMap = { ...draft.routing }; + for (const w of WORKLOADS) { + if (kind === 'cloud') next[w.id] = { kind: 'primary' }; + else if (kind === 'local') { + const m = installed[0]?.id; + next[w.id] = m ? { kind: 'local', model: m } : { kind: 'primary' }; + } else { + const firstModel = installed[0]?.id; + next[w.id] = + w.group === 'chat' || !firstModel + ? { kind: 'primary' } + : { kind: 'local', model: firstModel }; + } + } + setDraft({ ...draft, routing: next }); + }; + + const diffSummary = useMemo(() => { + const out: string[] = []; + for (const w of WORKLOADS) { + const a = saved.routing[w.id]; + const b = draft.routing[w.id]; + if (JSON.stringify(a) !== JSON.stringify(b)) { + const describe = (r: ProviderRef) => + r.kind === 'primary' + ? 'primary' + : r.kind === 'cloud' + ? `cloud:${r.model}` + : `local:${r.model}`; + out.push(`${w.label} → ${describe(b)}`); + } + } + if (saved.primaryCloudId !== draft.primaryCloudId) { + const p = draft.cloudProviders.find(cp => cp.id === draft.primaryCloudId); + out.push(`primary → ${p ? PROVIDER_META[p.type].label : '—'}`); + } + return out; + }, [saved, draft]); + + const chatRows = WORKLOADS.filter(w => w.group === 'chat'); + const bgRows = WORKLOADS.filter(w => w.group === 'background'); + + return ( +
+ + +
+ {/* ─── Cloud providers ─────────────────────────────────────────── */} +
+
+ Cloud providers + +
+ + {loading &&
Loading…
} + {error && ( +
+ {error} +
+ )} + +
+ {draft.cloudProviders.map(p => ( + setDraft({ ...draft, primaryCloudId: p.id })} + onEdit={() => setEditing(p)} + onRemove={() => { + const remaining = draft.cloudProviders.filter(cp => cp.id !== p.id); + // If the removed provider was primary, clear or reassign. + const nextPrimaryId = + draft.primaryCloudId === p.id + ? (remaining[0]?.id ?? null) + : draft.primaryCloudId; + // Scrub pinned workload routes that reference the removed provider. + const nextRouting = Object.fromEntries( + Object.entries(draft.routing).map(([wid, ref]) => [ + wid, + ref.kind === 'cloud' && ref.providerId === p.id + ? { kind: 'primary' as const } + : ref, + ]) + ) as typeof draft.routing; + setDraft({ + ...draft, + cloudProviders: remaining, + primaryCloudId: nextPrimaryId, + routing: nextRouting, + }); + }} + /> + ))} +
+
+ + {/* ─── Local provider ──────────────────────────────────────────── */} +
+ Local provider + + {/* Master enable / disable for the Ollama runtime. + The `state` field is the source of truth: when + `runtime_enabled = false` in config, bootstrap forces + status.state = "disabled". Flipping ON also has to kick + `local_ai_download` because bootstrap only auto-fires from + "idle"/"degraded" — "disabled" → "ready" is NOT an automatic + transition. */} + + +
+
+ +
+
{ollama.state}
+
+ ollama{ollama.version ? ` · ${ollama.version}` : ''} +
+ {ollama.snapshot?.status?.warning && ( +
+ {ollama.snapshot.status.warning} +
+ )} + {typeof ollama.snapshot?.status?.download_progress === 'number' && ( +
+ Download {(ollama.snapshot.status.download_progress * 100).toFixed(0)}% +
+ )} +
+ + +
+ + {installed.length === 0 ? ( +
+

+ {ollama.snapshot?.presets?.recommended_tier + ? `Recommended for this device: ${ollama.snapshot.presets.recommended_tier}.` + : 'Pick a tier preset to install a default model.'} +

+
+ {(ollama.snapshot?.presets?.presets ?? []).map(t => ( + + ))} + {(ollama.snapshot?.presets?.presets ?? []).length === 0 && + TIER_PRESETS.map(t => ( +
+
+
+ {t.label} +
+
{t.model}
+
+ {t.blurb} +
+ ))} +
+
+ ) : ( + <> +
    + {installed.map(m => ( +
  • + + + {m.id} + + + {formatBytes(m.sizeBytes)} + + +
  • + ))} +
+
+ {/* There's no "pull arbitrary model by name" RPC today + — the existing local_ai_download_asset only pulls + whatever model is configured per capability slot. + So instead of a fake button, surface the real + workflow: browse Ollama's library, run `ollama pull` + from a terminal, and the installed-model list above + picks it up automatically on the next poll. */} + +
+ To add a model, run{' '} + + ollama pull <model> + {' '} + in your terminal. Installed models appear here within ~5s. +
+
+ + )} +
+ + {/* Daemon-conflict callout — surfaces the "external Ollama with + broken runner" state in plain English so the user knows the + recovery (kill external, retry) without having to read logs. */} + {isDaemonConflict && ollama.state !== 'disabled' && ( +
+
Conflicting Ollama daemon detected
+
+ Another Ollama process is bound to :11434 but + OpenHuman didn't start it, so it can't safely restart it on your behalf. + To recover: +
+
    +
  1. + Stop the running Ollama (Windows Task Manager → end{' '} + ollama.exe /{' '} + ollama app.exe, or{' '} + taskkill /F /IM ollama.exe). +
  2. +
  3. + Click Retry above — OpenHuman will spawn its + own managed daemon. +
  4. +
  5. + Or, if you want to keep your install, set its binary path below — OpenHuman will + use yours. +
  6. +
+ {daemonWarning && ( +
+ {daemonWarning} +
+ )} +
+ )} + + {/* Advanced: show the resolved binary path + let the user + override it. The Rust resolver already supports a chain + (user path → OLLAMA_BIN env → workspace bin → system PATH → + auto-install); this surfaces it and provides one-field + override. */} +
setAdvancedOpen((e.target as HTMLDetailsElement).open)} + className="rounded-lg border border-stone-200 bg-white"> + + Advanced + +
+
+
+ Resolved Ollama binary +
+
+ {resolvedBinaryPath || + '— not detected; OpenHuman will auto-install on next start'} +
+
+
+ +
+ setCustomPathInput(e.target.value)} + placeholder="e.g. C:\Program Files\Ollama\ollama.exe" + className="min-w-0 flex-1 rounded-md border border-stone-300 bg-white px-2 py-1 font-mono text-[11px] text-stone-800 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + /> + + {customPathInput && ( + + )} +
+
+ Empty = auto-detect (workspace install →{' '} + OLLAMA_BIN env → system{' '} + PATH → managed install). +
+
+
+
+
+ + {/* ─── Workload routing ────────────────────────────────────────── */} +
+
+ Workload routing +
+ + + +
+
+ +
+
+
+ Chat +
+
+ {chatRows.map(w => ( + updateRouting(w.id, next)} + /> + ))} +
+
+
+
+ Background +
+
+ {bgRows.map(w => ( + updateRouting(w.id, next)} + /> + ))} +
+
+
+ + {primary && ( +
+ Primary resolves to{' '} + + {primary.type === 'openhuman' + ? 'openhuman' + : `${PROVIDER_META[primary.type].label.toLowerCase()} · ${primary.defaultModel}`} + +
+ )} +
+
+ + {isDirty && ( + void save()} + onDiscard={discard} + /> + )} + + {editing && ( + p.id !== (editing === 'new' ? '' : editing.id)) + .map(p => p.type)} + onClose={() => setEditing(null)} + onSubmit={async (next, apiKey) => { + setBusyAction('save-provider'); + try { + const id = + editing === 'new' || !editing.id + ? `p_${next.type}_${Math.random().toString(36).slice(2, 7)}` + : editing.id; + const upserted: CloudProvider = { + ...next, + id, + maskedKey: maskKeyLabel(apiKey ? true : next.maskedKey.startsWith('••••')), + }; + const list = + editing === 'new' + ? [...draft.cloudProviders, upserted] + : draft.cloudProviders.map(p => (p.id === editing.id ? upserted : p)); + setDraft({ + ...draft, + cloudProviders: list, + primaryCloudId: draft.primaryCloudId || upserted.id, + }); + if (apiKey && upserted.type !== 'openhuman') { + try { + await setCloudProviderKey(upserted.type as ApiCloudProviderType, apiKey); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.warn('[ai-settings] setCloudProviderKey failed', msg); + } + } + setEditing(null); + } finally { + setBusyAction(null); + } + }} + onClearKey={async type => { + try { + await clearCloudProviderKey(type as ApiCloudProviderType); + await reload(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.warn('[ai-settings] clearCloudProviderKey failed', msg); + } + }} + /> + )} +
+ ); +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Cloud provider editor modal +// ───────────────────────────────────────────────────────────────────────────── + +const CloudProviderEditor = ({ + initial, + existingTypes, + onClose, + onSubmit, + onClearKey, +}: { + initial: CloudProvider | null; + existingTypes: CloudProviderType[]; + onClose: () => void; + onSubmit: (next: CloudProvider, apiKey: string) => Promise | void; + onClearKey: (type: CloudProviderType) => Promise | void; +}) => { + const defaultType: CloudProviderType = + initial?.type ?? + (['openai', 'anthropic', 'openrouter', 'custom'] as CloudProviderType[]).find( + t => !existingTypes.includes(t) + ) ?? + 'custom'; + const [type, setType] = useState(defaultType); + const [endpoint, setEndpoint] = useState(initial?.endpoint ?? defaultEndpointFor(defaultType)); + const [defaultModel, setDefaultModel] = useState(initial?.defaultModel ?? ''); + const [apiKey, setApiKey] = useState(''); + const [saving, setSaving] = useState(false); + const isOpenHuman = type === 'openhuman'; + const hasExistingKey = (initial?.maskedKey ?? '').startsWith('••••'); + + return ( +
+
+
+
+ {initial ? `Edit ${initial.label}` : 'Add cloud provider'} +
+
+ API keys are encrypted at rest in auth-profiles.json. +
+
+
+
+ + +
+
+ + setEndpoint(e.target.value)} + disabled={isOpenHuman} + className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-xs text-stone-900 placeholder:text-stone-400 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + placeholder="https://api.example.com/v1" + /> +
+
+ + setDefaultModel(e.target.value)} + className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-xs text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + placeholder="gpt-4o" + /> +
+ {!isOpenHuman && ( +
+ + setApiKey(e.target.value)} + className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-xs text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" + placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'} + /> +
+ )} +
+
+ + +
+
+
+ ); +}; + +function defaultEndpointFor(t: CloudProviderType): string { + switch (t) { + case 'openhuman': + return 'https://api.openhuman.ai/v1'; + case 'openai': + return 'https://api.openai.com/v1'; + case 'anthropic': + return 'https://api.anthropic.com/v1'; + case 'openrouter': + return 'https://openrouter.ai/api/v1'; + case 'custom': + return ''; + } +} + export default AIPanel; diff --git a/app/src/components/settings/panels/BackendProviderPanel.tsx b/app/src/components/settings/panels/BackendProviderPanel.tsx deleted file mode 100644 index 2ef8cf20b..000000000 --- a/app/src/components/settings/panels/BackendProviderPanel.tsx +++ /dev/null @@ -1,545 +0,0 @@ -import debug from 'debug'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { - type ClientConfig, - type ModelRoute, - openhumanGetClientConfig, - openhumanUpdateModelSettings, -} from '../../../utils/tauriCommands'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - -const log = debug('settings:llm-provider'); - -const KEY_PLACEHOLDER = '••••••••••••••••'; - -/** - * Task-hint slots the core router understands (see - * `src/openhuman/providers/router.rs`). When the user picks a non-OpenHuman - * preset we persist a `model_routes` entry for each role so the router has a - * sensible per-task default for the chosen provider. - */ -const ROLE_HINTS = ['reasoning', 'agentic', 'coding', 'summarization'] as const; -type RoleHint = (typeof ROLE_HINTS)[number]; - -const ROLE_LABELS: Record = { - reasoning: { label: 'Reasoning', help: 'Deep, multi-step thinking and planning.' }, - agentic: { label: 'Agentic', help: 'Tool use, sub-agent delegation, function calling.' }, - coding: { label: 'Coding', help: 'Code generation, refactoring, and review.' }, - summarization: { label: 'Summarization', help: 'Fast, cheap summaries and short responses.' }, -}; - -type RoleModels = Record; - -const EMPTY_ROLE_MODELS: RoleModels = { reasoning: '', agentic: '', coding: '', summarization: '' }; - -interface ProviderPreset { - /** Stable identifier — also drives the preset card key. */ - id: string; - label: string; - /** OpenAI-compatible base URL ending in `/v1`. Empty = use OpenHuman default. */ - apiUrl: string; - /** Suggested single default model id; ignored for OpenHuman (router-managed). */ - suggestedModel: string; - /** - * Per-role suggested models for the core router. `null` for OpenHuman since - * its built-in router picks per task without an external model_routes table. - */ - roleModels: RoleModels | null; - /** Short hint shown beneath the preset row when this preset is active. */ - note: string; - /** - * Tailwind classes giving the card a subtle brand-aligned tint. A colour - * cue, not a brand reproduction. - */ - tint: { idle: string; selected: string; dot: string }; -} - -/** - * Curated list of OpenAI-compatible providers (#1342). The core uses - * `OpenAiCompatibleProvider` (`/chat/completions` shape); Anthropic ships an - * OpenAI-compat shim at `https://api.anthropic.com/v1` so it lives here too. - */ -const PROVIDER_PRESETS: ProviderPreset[] = [ - { - id: 'openhuman', - label: 'OpenHuman', - apiUrl: 'https://api.tinyhumans.ai/openai/v1/chat/completions', - suggestedModel: '', - roleModels: null, - note: 'Hosted OpenHuman backend — uses your signed-in session, no API key required.', - tint: { - idle: 'border-stone-200 hover:border-primary-300 hover:bg-primary-50/40', - selected: 'border-primary-500 bg-primary-100 ring-2 ring-primary-300 text-primary-900', - dot: 'bg-primary-500', - }, - }, - { - id: 'openai', - label: 'OpenAI', - apiUrl: 'https://api.openai.com/v1/chat/completions', - suggestedModel: 'gpt-5.5', - // Unversioned aliases so the preset survives OpenAI's dated revisions - // (the dated `gpt-5.5-YYYY-MM-DD` form is rejected once a new snapshot - // ships). The smaller variant in the 5.5 family is `gpt-5.4-mini` per - // OpenAI's model catalog — not a fictional `gpt-5.5-mini`. - roleModels: { - reasoning: 'gpt-5.5', - agentic: 'gpt-5.5', - coding: 'gpt-5.5', - summarization: 'gpt-5.4-mini', - }, - note: 'Use a key from platform.openai.com. Defaults pick gpt-5.5 for reasoning/agentic/coding and gpt-5.4-mini for summarization.', - tint: { - idle: 'border-stone-200 hover:border-sage-400 hover:bg-sage-50/40', - selected: 'border-sage-600 bg-sage-100 ring-2 ring-sage-300 text-sage-900', - dot: 'bg-sage-600', - }, - }, - { - id: 'anthropic', - label: 'Anthropic', - // Anthropic ships an OpenAI-compatibility shim at /v1/chat/completions - // that maps to the same Claude models — see docs.anthropic.com/en/api/openai-sdk. - apiUrl: 'https://api.anthropic.com/v1/chat/completions', - suggestedModel: 'claude-sonnet-4-6', - roleModels: { - reasoning: 'claude-opus-4-7', - agentic: 'claude-sonnet-4-6', - coding: 'claude-sonnet-4-6', - summarization: 'claude-haiku-4-5-20251001', - }, - note: 'Uses Anthropic’s OpenAI-compatibility endpoint with a key from console.anthropic.com. Defaults: Opus 4.7 reasoning, Sonnet 4.6 agentic/coding, Haiku 4.5 summarization.', - tint: { - idle: 'border-stone-200 hover:border-coral-400 hover:bg-coral-50/40', - selected: 'border-coral-600 bg-coral-100 ring-2 ring-coral-300 text-coral-900', - dot: 'bg-coral-600', - }, - }, - { - id: 'openrouter', - label: 'OpenRouter', - apiUrl: 'https://openrouter.ai/api/v1/chat/completions', - suggestedModel: 'openai/gpt-4o', - roleModels: { - reasoning: 'openai/o1', - agentic: 'anthropic/claude-sonnet-4.6', - coding: 'anthropic/claude-sonnet-4.6', - summarization: 'openai/gpt-4o-mini', - }, - note: 'One key, dozens of providers (openrouter.ai). Mix and match per role — swap to meta-llama/llama-3.3-70b-instruct, google/gemini-2.0-flash, etc.', - tint: { - idle: 'border-stone-200 hover:border-amber-400 hover:bg-amber-50/40', - selected: 'border-amber-600 bg-amber-100 ring-2 ring-amber-300 text-amber-900', - dot: 'bg-amber-500', - }, - }, - { - id: 'ollama', - label: 'Ollama (local)', - apiUrl: 'http://localhost:11434/v1/chat/completions', - suggestedModel: 'llama3.3', - roleModels: { - reasoning: 'llama3.3', - agentic: 'llama3.3', - coding: 'qwen2.5-coder', - summarization: 'llama3.2', - }, - note: 'Local Ollama runtime via its OpenAI-compatible endpoint. API key is ignored — leave blank.', - tint: { - idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50', - selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900', - dot: 'bg-stone-500', - }, - }, - { - id: 'custom', - label: 'Custom', - apiUrl: '', - suggestedModel: '', - roleModels: { ...EMPTY_ROLE_MODELS }, - note: 'Any other endpoint that speaks the OpenAI /chat/completions shape (vLLM, LiteLLM, LM Studio, self-hosted gateways).', - tint: { - idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50', - selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900', - dot: 'bg-stone-400', - }, - }, -]; - -function detectPreset(apiUrl: string, routes: ModelRoute[]): ProviderPreset { - const trimmed = apiUrl.trim(); - // Saved routes mean "non-OpenHuman provider"; an empty URL with routes is - // a hand-edited Custom setup, not the OpenHuman default. - const hasRoutes = routes.length > 0; - if (!trimmed) - return hasRoutes ? PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1] : PROVIDER_PRESETS[0]; - const match = PROVIDER_PRESETS.find(p => p.apiUrl && p.apiUrl === trimmed); - if (match) return match; - return PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; // custom -} - -function roleModelsFromRoutes(routes: ModelRoute[]): RoleModels { - const out: RoleModels = { ...EMPTY_ROLE_MODELS }; - for (const r of routes) { - if ((ROLE_HINTS as readonly string[]).includes(r.hint)) { - out[r.hint as RoleHint] = r.model; - } - } - return out; -} - -/** - * Configure the LLM provider (#1342). Defaults to the hosted OpenHuman - * backend, whose built-in router picks the best model per request. Selecting - * any other preset reveals per-role model inputs (reasoning / agentic / - * coding / summarization) that get persisted to `config.model_routes` so the - * core router obeys them. - * - * The api_key is stored on the user's machine in `config.toml`. It is sent - * over the local Tauri↔core RPC only at write time; subsequent reads return - * only `api_key_set: bool` so the secret never leaves the core process once - * persisted. - */ -const BackendProviderPanel = () => { - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const [loaded, setLoaded] = useState(false); - const [client, setClient] = useState(null); - const [apiUrl, setApiUrl] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [apiKeyDirty, setApiKeyDirty] = useState(false); - // Per-field dirty flags so a failed `load()` (which leaves the inputs at - // their empty defaults) can't silently overwrite stored config when the - // user clicks Save. CodeRabbit feedback on PR #1467. - const [apiUrlDirty, setApiUrlDirty] = useState(false); - const [roleModelsDirty, setRoleModelsDirty] = useState(false); - const [roleModels, setRoleModels] = useState(EMPTY_ROLE_MODELS); - // Explicit active-preset state. We can't derive this from `apiUrl` alone - // because OpenHuman and Custom both store an empty URL — clicking Custom - // would otherwise snap back to OpenHuman on the next render. - const [activePresetId, setActivePresetId] = useState(PROVIDER_PRESETS[0].id); - const [saving, setSaving] = useState(false); - const [status, setStatus] = useState<{ kind: 'idle' | 'ok' | 'error'; message: string }>({ - kind: 'idle', - message: '', - }); - - const load = useCallback(async () => { - try { - log('[llm-provider] loading client config'); - const response = await openhumanGetClientConfig(); - const config = response.result; - setClient(config); - // The LLM Provider panel works exclusively against `inference_url` — - // `config.api_url` is the OpenHuman product backend URL and must never - // be redirected by this UI (auth/billing/voice all depend on it). - const persistedUrl = config.inference_url ?? ''; - const persistedRoutes = config.model_routes ?? []; - setApiUrl(persistedUrl); - setRoleModels(roleModelsFromRoutes(persistedRoutes)); - setActivePresetId(detectPreset(persistedUrl, persistedRoutes).id); - setApiKey(''); - setApiKeyDirty(false); - setApiUrlDirty(false); - setRoleModelsDirty(false); - setLoaded(true); - } catch (err) { - log('failed to load client config: %s', err instanceof Error ? err.message : 'unknown'); - setStatus({ - kind: 'error', - message: - err instanceof Error - ? `Failed to load current settings: ${err.message}` - : 'Failed to load current settings.', - }); - setLoaded(true); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - const activePreset = useMemo( - () => PROVIDER_PRESETS.find(p => p.id === activePresetId) ?? PROVIDER_PRESETS[0], - [activePresetId] - ); - const isOpenHuman = activePreset.id === 'openhuman'; - - const applyPreset = useCallback( - (preset: ProviderPreset) => { - // Re-clicking the currently active preset is a no-op — otherwise the - // user's saved/customized role models would be wiped just by tapping - // the highlighted card. - if (preset.id === activePresetId) return; - setActivePresetId(preset.id); - setApiUrl(preset.apiUrl); - setApiUrlDirty(true); - // Switching presets gives a clean, opinionated starting point. - setRoleModels(preset.roleModels ? { ...preset.roleModels } : { ...EMPTY_ROLE_MODELS }); - setRoleModelsDirty(true); - setStatus({ kind: 'idle', message: '' }); - }, - [activePresetId] - ); - - const handleSave = useCallback(async () => { - setSaving(true); - setStatus({ kind: 'idle', message: '' }); - try { - // Build model_routes from role state when a non-OpenHuman preset is - // active. Empty roles are filtered so the router doesn't dispatch to - // an empty model id. Switching back to OpenHuman sends [] so the - // built-in router takes over. We only send routes when the user has - // actually changed the provider or edited a role input — keeps a - // stale Save click after a failed `load()` from clobbering stored - // config (CodeRabbit #1467). - const routesTouched = apiUrlDirty || roleModelsDirty; - const routes: ModelRoute[] | undefined = !routesTouched - ? undefined - : isOpenHuman - ? [] - : ROLE_HINTS.flatMap(hint => { - const model = roleModels[hint].trim(); - return model ? [{ hint, model }] : []; - }); - // Persist the LLM endpoint into `inference_url`, NEVER `api_url`. - // OpenHuman preset → empty string clears `inference_url` so the core - // routes inference through the OpenHuman backend. Custom providers - // write their full chat-completions URL. - const inferenceUrlPayload = apiUrlDirty ? (isOpenHuman ? '' : apiUrl) : undefined; - await openhumanUpdateModelSettings({ - inference_url: inferenceUrlPayload, - api_key: apiKeyDirty ? apiKey : undefined, - model_routes: routes, - }); - setStatus({ kind: 'ok', message: 'LLM provider settings saved.' }); - await load(); - } catch (err) { - log('save failed: %s', err instanceof Error ? err.message : 'unknown'); - setStatus({ - kind: 'error', - message: - err instanceof Error ? `Failed to save: ${err.message}` : 'Failed to save settings.', - }); - } finally { - setSaving(false); - } - }, [apiKey, apiKeyDirty, apiUrl, apiUrlDirty, isOpenHuman, load, roleModels, roleModelsDirty]); - - const handleClearKey = useCallback(async () => { - setSaving(true); - setStatus({ kind: 'idle', message: '' }); - try { - await openhumanUpdateModelSettings({ api_key: '' }); - setStatus({ kind: 'ok', message: 'API key cleared.' }); - await load(); - } catch (err) { - log('clear key failed: %s', err instanceof Error ? err.message : 'unknown'); - setStatus({ - kind: 'error', - message: - err instanceof Error ? `Failed to clear key: ${err.message}` : 'Failed to clear key.', - }); - } finally { - setSaving(false); - } - }, [load]); - - return ( -
- -
-

- Pick where inference runs. Any OpenAI-compatible provider (OpenAI, Anthropic, OpenRouter, - Ollama, your own gateway). -

- - {!loaded ? ( -
Loading current settings…
- ) : ( - <> -
- -
- {PROVIDER_PRESETS.map(preset => { - const selected = preset.id === activePreset.id; - return ( - - ); - })} -
-

{activePreset.note}

-
- - {isOpenHuman && ( -
-
-

- Congrats! You’re using the most optimized setup -

-

- OpenHuman's built-in smart router picks the best model giving you top-tier - quality at the lowest blended cost. All within your current subscription. -

-
-
- )} - - {!isOpenHuman && ( -
-

- Consider switching to OpenHuman as it comes with a built-in smart router that - picks the best model for each request, cutting costs and improving quality. -

-
- )} - - {activePreset.id === 'custom' && ( -
- - { - setApiUrl(e.target.value); - setApiUrlDirty(true); - }} - placeholder="https://your-host.example/v1/chat/completions" - className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" - autoComplete="off" - spellCheck={false} - /> -

- Full URL of the OpenAI-compatible chat-completions endpoint for your gateway. -

-
- )} - - {!isOpenHuman && ( -
-
- - {client?.api_key_set && ( - - )} -
- { - setApiKey(e.target.value); - setApiKeyDirty(true); - }} - placeholder={ - client?.api_key_set ? `${KEY_PLACEHOLDER} (replace to change)` : 'sk-…' - } - className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" - autoComplete="off" - spellCheck={false} - /> -

- {client?.api_key_set ? 'A key is currently saved.' : 'No key is currently saved.'} -

-
- )} - - {!isOpenHuman && ( -
- -

- The core router dispatches each task to the right model. Leave a field blank to - skip routing for that role. -

-
- {ROLE_HINTS.map(hint => ( -
- - { - const next = e.target.value; - setRoleModels(prev => ({ ...prev, [hint]: next })); - setRoleModelsDirty(true); - }} - placeholder={ROLE_LABELS[hint].help} - className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200" - autoComplete="off" - spellCheck={false} - /> -
- ))} -
-
- )} - -
- - {status.kind === 'ok' && ( - {status.message} - )} - {status.kind === 'error' && ( - {status.message} - )} -
- - )} -
-
- ); -}; - -export default BackendProviderPanel; diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx deleted file mode 100644 index 00d772487..000000000 --- a/app/src/components/settings/panels/LocalModelPanel.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import { triggerLocalAiAssetBootstrap } from '../../../utils/localAiBootstrap'; -import { - formatBytes, - formatEta, - progressFromDownloads, - progressFromStatus, -} from '../../../utils/localAiHelpers'; -import { - type ApplyPresetResult, - type LlmBackend, - type LocalAiDownloadsProgress, - type LocalAiStatus, - memoryTreeGetLlm, - memoryTreeSetLlm, - openhumanGetConfig, - openhumanLocalAiApplyPreset, - openhumanLocalAiDownload, - openhumanLocalAiDownloadAllAssets, - openhumanLocalAiDownloadsProgress, - openhumanLocalAiPresets, - openhumanLocalAiStatus, - openhumanUpdateLocalAiSettings, - type PresetsResponse, -} from '../../../utils/tauriCommands'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import DeviceCapabilitySection from './local-model/DeviceCapabilitySection'; - -const formatRamGb = (bytes: number): string => { - const gb = bytes / (1024 * 1024 * 1024); - return gb >= 10 ? `${Math.round(gb)} GB` : `${gb.toFixed(1)} GB`; -}; - -const LocalModelPanel = () => { - const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); - const [status, setStatus] = useState(null); - const [downloads, setDownloads] = useState(null); - const [statusError, setStatusError] = useState(''); - const [isTriggeringDownload, setIsTriggeringDownload] = useState(false); - const [bootstrapMessage, setBootstrapMessage] = useState(''); - - const [presetsData, setPresetsData] = useState(null); - const [presetsLoading, setPresetsLoading] = useState(true); - const [presetError, setPresetError] = useState(''); - const [presetSuccess, setPresetSuccess] = useState(null); - - const [usageFlags, setUsageFlags] = useState<{ - runtime_enabled: boolean; - usage_embeddings: boolean; - usage_heartbeat: boolean; - usage_learning_reflection: boolean; - usage_subconscious: boolean; - }>({ - runtime_enabled: false, - usage_embeddings: false, - usage_heartbeat: false, - usage_learning_reflection: false, - usage_subconscious: false, - }); - const [usageError, setUsageError] = useState(''); - const [usageSaving, setUsageSaving] = useState(false); - - // Memory summarizer backend lives in `memory_tree.llm_backend`, which is - // outside the `local_ai.usage.*` surface — so it has its own RPC pair - // (`memoryTreeGetLlm` / `memoryTreeSetLlm`). This is now the only UI - // surface for the cloud/local toggle (the Intelligence Memory tab's - // BackendChooser was removed in this PR to eliminate the duplicate - // control surface). The tab's local-only Ollama model picker reads - // the same field at mount to decide visibility. - const [summarizerBackend, setSummarizerBackend] = useState('cloud'); - const [summarizerSaving, setSummarizerSaving] = useState(false); - - const progress = useMemo(() => { - const downloadProgress = progressFromDownloads(downloads); - if (downloadProgress != null) return downloadProgress; - return progressFromStatus(status); - }, [downloads, status]); - const currentState = downloads?.state ?? status?.state; - const runtimeEnabled = usageFlags.runtime_enabled; - const isInstalling = currentState === 'installing'; - const isIndeterminateDownload = - isInstalling || - (currentState === 'downloading' && - typeof downloads?.progress !== 'number' && - typeof status?.download_progress !== 'number'); - const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes; - const totalBytes = downloads?.total_bytes ?? status?.total_bytes; - const speedBps = downloads?.speed_bps ?? status?.download_speed_bps; - const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds; - - const loadStatus = async () => { - try { - const [statusResponse, downloadsResponse] = await Promise.all([ - openhumanLocalAiStatus(), - openhumanLocalAiDownloadsProgress(), - ]); - setStatus(statusResponse.result); - setDownloads(downloadsResponse.result); - setStatusError(''); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to read local model status'; - setStatusError(message); - setStatus(null); - setDownloads(null); - } - }; - - const loadPresets = async () => { - setPresetsLoading(true); - try { - const data = await openhumanLocalAiPresets(); - setPresetsData(data); - setPresetError(''); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to load presets'; - setPresetError(msg); - } finally { - setPresetsLoading(false); - } - }; - - const loadUsage = async () => { - try { - const snap = await openhumanGetConfig(); - const localAi = (snap.result?.config?.local_ai ?? {}) as Record; - const usage = (localAi.usage ?? {}) as Record; - setUsageFlags({ - runtime_enabled: Boolean(localAi.runtime_enabled), - usage_embeddings: Boolean(usage.embeddings), - usage_heartbeat: Boolean(usage.heartbeat), - usage_learning_reflection: Boolean(usage.learning_reflection), - usage_subconscious: Boolean(usage.subconscious), - }); - setUsageError(''); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to load local AI flags'; - setUsageError(msg); - } - }; - - const updateUsage = async (patch: Partial) => { - const next = { ...usageFlags, ...patch }; - setUsageFlags(next); - setUsageSaving(true); - setUsageError(''); - try { - await openhumanUpdateLocalAiSettings(patch); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to save local AI flags'; - setUsageError(msg); - void loadUsage(); - } finally { - setUsageSaving(false); - } - }; - - const loadSummarizerBackend = async () => { - try { - const resp = await memoryTreeGetLlm(); - if (resp?.current === 'local' || resp?.current === 'cloud') { - setSummarizerBackend(resp.current); - } - } catch (err) { - // Non-fatal — the row stays at its default (cloud) and the user - // can still flip it; the next save attempt will surface the error. - console.warn('[local-model-panel] memoryTreeGetLlm failed', err); - } - }; - - const updateSummarizerBackend = async (next: LlmBackend) => { - // Belt-and-braces: the checkbox is already `disabled` when the - // master runtime is off or a save is in flight, but the rendered - // disabled attribute only stops real-browser click events — JSDOM - // / fireEvent ignore it. Mirror the gate here so programmatic - // dispatch can't sneak past the master switch either. - if (!usageFlags.runtime_enabled || summarizerSaving) return; - const prev = summarizerBackend; - setSummarizerBackend(next); - setSummarizerSaving(true); - setUsageError(''); - try { - await memoryTreeSetLlm({ backend: next }); - } catch (err) { - // Roll back the optimistic toggle and surface the error. - setSummarizerBackend(prev); - const msg = err instanceof Error ? err.message : 'Failed to save memory summarizer backend'; - setUsageError(msg); - } finally { - setSummarizerSaving(false); - } - }; - - useEffect(() => { - const initialLoad = window.setTimeout(() => { - void loadStatus(); - void loadPresets(); - void loadUsage(); - void loadSummarizerBackend(); - }, 0); - const timer = window.setInterval(() => { - void loadStatus(); - }, 1500); - return () => { - window.clearTimeout(initialLoad); - window.clearInterval(timer); - }; - }, []); - - const triggerDownload = async (force: boolean) => { - if (!runtimeEnabled) return; - setIsTriggeringDownload(true); - setStatusError(''); - setBootstrapMessage(''); - try { - await openhumanLocalAiDownload(force); - await openhumanLocalAiDownloadAllAssets(force); - const freshStatus = await openhumanLocalAiStatus(); - setStatus(freshStatus.result); - if (freshStatus.result?.state === 'ready') { - setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Models verified'); - } - setTimeout(() => setBootstrapMessage(''), 3000); - } catch (err) { - const message = - err instanceof Error ? err.message : 'Failed to trigger local model bootstrap'; - setStatusError(message); - } finally { - setIsTriggeringDownload(false); - } - }; - - /** - * Install-Ollama entry point used by the locked tier picker and the - * runtime-status install CTA. - * - * Three preconditions need to be flipped before `download_all_models` - * will actually run on the core side: - * - `runtime_enabled = true` (cloud-fallback override off) - * - `selected_tier` (anything other than "disabled") - * - `opt_in_confirmed = true` (so bootstrap doesn't hard-override - * to disabled in `config_with_recommended_tier_if_unselected`) - * - * `apply_preset()` sets all three in one save. We call it - * **unconditionally** here rather than going through - * `ensureRecommendedLocalAiPresetIfNeeded`, because that helper - * short-circuits when `selected_tier` is already set — which is exactly - * the case for users who previously picked "Disabled (cloud fallback)" - * and now want to switch on local AI. Without the explicit apply, - * runtime_enabled stays false, `download_all_models` returns - * `local ai is disabled`, the task marks the service degraded, and the - * UI silently bounces back to idle. - */ - const triggerInstallWithRecommendedTier = async () => { - setIsTriggeringDownload(true); - setStatusError(''); - setBootstrapMessage(''); - try { - const presetsResult = presetsData ?? (await openhumanLocalAiPresets()); - const tier = presetsResult.recommended_tier || 'ram_2_4gb'; - if (tier === 'disabled') { - throw new Error('Cannot install Ollama for the "disabled" tier — pick a local tier first.'); - } - await openhumanLocalAiApplyPreset(tier); - await triggerLocalAiAssetBootstrap(true); - await loadPresets(); - const freshStatus = await openhumanLocalAiStatus(); - setStatus(freshStatus.result); - setBootstrapMessage('Install started — Ollama and models are downloading'); - setTimeout(() => setBootstrapMessage(''), 4000); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to start Ollama install'; - setStatusError(message); - } finally { - setIsTriggeringDownload(false); - } - }; - - return ( -
- - -
- void triggerInstallWithRecommendedTier()} - isTriggeringInstall={isTriggeringDownload} - installState={status?.state} - installWarning={status?.warning} - installError={status?.error_detail} - onPresetApplied={result => { - setPresetSuccess(result); - void loadPresets(); - void loadStatus(); - }} - /> - - {/* - Simplified Model Status — only meaningful AFTER Ollama is on disk. - Before that, every readout here ("idle"/"missing", Re-bootstrap - button, refresh) is noise: there's no runtime to inspect and the - right call-to-action is "Install Ollama" up top in the tier-picker - banner. Hide the whole section to keep the UI progressive: - 1) Ollama missing → only the install CTA (above) - 2) Ollama installing → CTA flips to blue progress (above) - 3) Ollama installed onward → this section appears with model state - */} - {(downloads?.ollama_available ?? true) && ( -
-

Model Status

- -
- State:{' '} - - {currentState ?? 'unknown'} - -
- - {(currentState === 'downloading' || isInstalling) && ( -
-
- {isIndeterminateDownload ? ( -
- ) : ( -
- )} -
-
- - {typeof downloadedBytes === 'number' - ? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}` - : ''} - - - {typeof speedBps === 'number' && speedBps > 0 - ? `${formatBytes(speedBps)}/s` - : ''} - {etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''} - -
-
- )} - - {bootstrapMessage &&
{bootstrapMessage}
} - -
- - -
- - {statusError && ( -
- {statusError} -
- )} -
- )} - -
-
-

Usage

-

- Choose which subsystems run on the local model. Anything off uses the cloud. -

-
- - - -
- {/* Memory summarizer is special: it writes `memory_tree.llm_backend`, - not `local_ai.usage.*`. This is now the sole UI surface for - that field (the Intelligence Memory tab's BackendChooser was - removed); the tab's Ollama model picker still reads it at - mount to gate visibility. */} - - - {( - [ - { - key: 'usage_embeddings' as const, - label: 'Embeddings', - hint: 'Generate memory embeddings locally instead of in the cloud.', - }, - { - key: 'usage_heartbeat' as const, - label: 'Heartbeat', - hint: 'Run heartbeat reasoning locally.', - }, - { - key: 'usage_learning_reflection' as const, - label: 'Learning / reflection', - hint: 'Run learning and reflection passes locally.', - }, - { - key: 'usage_subconscious' as const, - label: 'Subconscious', - hint: 'Run subconscious evaluation locally.', - }, - ] as const - ).map(({ key, label, hint }) => ( - - ))} -
- - {usageError && ( -
- {usageError} -
- )} -
- - -
-
- ); -}; - -export default LocalModelPanel; diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 54ac52104..2f376fa17 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -1,71 +1,110 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadAISettings, loadLocalProviderSnapshot } from '../../../../services/api/aiSettingsApi'; import { renderWithProviders } from '../../../../test/test-utils'; -import { - aiGetConfig, - type AIPreview, - aiRefreshConfig, - type CommandResponse, - type LocalAiStatus, - openhumanLocalAiDownload, - openhumanLocalAiStatus, -} from '../../../../utils/tauriCommands'; import AIPanel from '../AIPanel'; -vi.mock('../../../../utils/tauriCommands', () => ({ - aiGetConfig: vi.fn(), - aiRefreshConfig: vi.fn(), - openhumanLocalAiDownload: vi.fn(), - openhumanLocalAiStatus: vi.fn(), +vi.mock('../../../../services/api/aiSettingsApi', () => ({ + ALL_WORKLOADS: [ + 'reasoning', + 'agentic', + 'coding', + 'memory', + 'embeddings', + 'heartbeat', + 'learning', + 'subconscious', + ], + loadAISettings: vi.fn(), + saveAISettings: vi.fn(), + loadLocalProviderSnapshot: vi.fn(), + setCloudProviderKey: vi.fn(), + clearCloudProviderKey: vi.fn(), + serializeProviderRef: vi.fn((r: { kind: string; model?: string }) => + r.kind === 'primary' ? 'cloud' : r.kind === 'local' ? `ollama:${r.model}` : `cloud:${r.model}` + ), + localProvider: { download: vi.fn(), applyPreset: vi.fn() }, })); -const aiPreview: AIPreview = { - soul: { - raw: '', - name: 'OpenHuman', - description: 'Test persona', - personalityPreview: [], - safetyRulesPreview: [], - loadedAt: 1, - }, - tools: { raw: '', totalTools: 0, activeSkills: 0, skillsPreview: [], loadedAt: 1 }, - metadata: { - loadedAt: 1, - loadingDuration: 1, - hasFallbacks: false, - sources: { soul: 'test', tools: 'test' }, - errors: [], +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +const baseSettings = { + cloudProviders: [ + { + id: 'p_oh_x', + type: 'openhuman' as const, + endpoint: 'https://api.openhuman.ai/v1', + default_model: 'reasoning-v1', + has_api_key: false, + }, + ], + primaryCloudId: 'p_oh_x', + routing: { + reasoning: { kind: 'primary' as const }, + agentic: { kind: 'primary' as const }, + coding: { kind: 'primary' as const }, + memory: { kind: 'primary' as const }, + embeddings: { kind: 'primary' as const }, + heartbeat: { kind: 'primary' as const }, + learning: { kind: 'primary' as const }, + subconscious: { kind: 'primary' as const }, }, }; -const disabledStatus: LocalAiStatus = { - state: 'disabled', - model_id: 'local-v1', -} as unknown as LocalAiStatus; - -describe('AIPanel local model runtime gate', () => { +describe('AIPanel', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(aiGetConfig).mockResolvedValue(aiPreview); - vi.mocked(aiRefreshConfig).mockResolvedValue(aiPreview); - vi.mocked(openhumanLocalAiStatus).mockResolvedValue({ - result: disabledStatus, - logs: [], - } as CommandResponse); - vi.mocked(openhumanLocalAiDownload).mockResolvedValue({ - result: disabledStatus, - logs: [], - } as CommandResponse); + vi.mocked(loadAISettings).mockResolvedValue(baseSettings); + vi.mocked(loadLocalProviderSnapshot).mockResolvedValue({ + status: null, + diagnostics: null, + presets: null, + installedModels: [], + }); }); - it('does not retry downloads while local AI runtime is disabled', async () => { - renderWithProviders(, { initialEntries: ['/settings/ai'] }); + it('renders the three section labels', async () => { + renderWithProviders(); + // Section labels are SectionLabel components — pick the one that + // matches each header exactly. Loose regex matches body copy too + // ("only use cloud providers" appears in the local-provider + // explanation, "Primary" appears on the provider card badge AND in + // workload routing rows). + await waitFor(() => expect(screen.getAllByText(/Cloud providers/i).length).toBeGreaterThan(0)); + expect(screen.getAllByText(/Local provider/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Workload routing/i).length).toBeGreaterThan(0); + }); - const retryButton = await screen.findByRole('button', { name: 'Retry Download' }); - expect(retryButton).toBeDisabled(); - fireEvent.click(retryButton); + it('renders the OpenHuman primary card after load', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByText(/OpenHuman/i)).toBeInTheDocument()); + // "Primary" shows up on the provider card badge AND in workload + // routing rows that read "Primary resolves to …", so multiple + // matches are expected. + expect(screen.getAllByText(/Primary/).length).toBeGreaterThan(0); + }); - expect(openhumanLocalAiDownload).not.toHaveBeenCalled(); + it('renders all eight workload labels', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Reasoning')).toBeInTheDocument()); + for (const label of [ + 'Reasoning', + 'Agentic', + 'Coding', + 'Memory summarization', + 'Embeddings', + 'Heartbeat', + /Learning/, + 'Subconscious', + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } }); }); diff --git a/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx b/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx deleted file mode 100644 index f54d88a04..000000000 --- a/app/src/components/settings/panels/__tests__/BackendProviderPanel.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../../../test/test-utils'; -import { - openhumanGetClientConfig, - openhumanUpdateModelSettings, -} from '../../../../utils/tauriCommands'; -import BackendProviderPanel from '../BackendProviderPanel'; - -vi.mock('../../../../utils/tauriCommands', () => ({ - openhumanGetClientConfig: vi.fn(), - openhumanUpdateModelSettings: vi.fn(), -})); - -vi.mock('../../hooks/useSettingsNavigation', () => ({ - useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), -})); - -function mockClientConfig(overrides: Record = {}) { - vi.mocked(openhumanGetClientConfig).mockResolvedValue({ - result: { - api_url: '', - inference_url: '', - default_model: '', - app_version: '0.0.0-test', - api_key_set: false, - model_routes: [], - ...overrides, - }, - messages: [], - } as unknown as Awaited>); -} - -function mockUpdateOk() { - vi.mocked(openhumanUpdateModelSettings).mockResolvedValue({ - result: { config: {}, workspace_dir: '', config_path: '' }, - messages: [], - } as unknown as Awaited>); -} - -describe('BackendProviderPanel', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUpdateOk(); - }); - - it('renders all provider preset chips and the OpenHuman success banner by default', async () => { - mockClientConfig(); - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole('button', { name: /OpenHuman/i })).toBeTruthy(); - }); - - for (const label of ['OpenHuman', 'OpenAI', 'Anthropic', 'OpenRouter', 'Ollama', 'Custom']) { - expect(screen.getByRole('button', { name: new RegExp(label, 'i') })).toBeTruthy(); - } - - // Congrats banner is visible because OpenHuman is the default - expect(screen.getByText(/Congrats! You.{1,3}re using the most optimized setup/i)).toBeTruthy(); - // URL input is hidden for OpenHuman - expect(screen.queryByLabelText(/API URL/i)).toBeNull(); - // API key field is hidden for OpenHuman - expect(screen.queryByLabelText(/API Key/i)).toBeNull(); - // No role inputs for OpenHuman - expect(screen.queryByLabelText(/Reasoning/i)).toBeNull(); - }); - - it('shows API key + role-model inputs when a non-OpenHuman preset is picked', async () => { - mockClientConfig(); - renderWithProviders(); - - const openaiChip = await screen.findByRole('button', { name: /^OpenAI$/i }); - fireEvent.click(openaiChip); - - // API key field appears - expect(await screen.findByLabelText(/API Key/i)).toBeTruthy(); - // Role inputs appear - expect(screen.getByLabelText(/Reasoning/i)).toBeTruthy(); - expect(screen.getByLabelText(/Agentic/i)).toBeTruthy(); - expect(screen.getByLabelText(/Coding/i)).toBeTruthy(); - expect(screen.getByLabelText(/Summarization/i)).toBeTruthy(); - // Congrats banner is gone - expect(screen.queryByText(/Congrats!/i)).toBeNull(); - // URL field still hidden (only shown for Custom) - expect(screen.queryByLabelText(/API URL/i)).toBeNull(); - }); - - it('reveals the URL input only when Custom is picked', async () => { - mockClientConfig(); - renderWithProviders(); - - const customChip = await screen.findByRole('button', { name: /^Custom$/i }); - fireEvent.click(customChip); - - expect(await screen.findByLabelText(/API URL/i)).toBeTruthy(); - }); - - it('Save sends model_routes derived from the selected preset and api_key when touched', async () => { - mockClientConfig(); - renderWithProviders(); - - fireEvent.click(await screen.findByRole('button', { name: /^Anthropic$/i })); - - const keyInput = await screen.findByLabelText(/API Key/i); - fireEvent.change(keyInput, { target: { value: 'sk-ant-test-123' } }); - - fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); - - await waitFor(() => { - expect(openhumanUpdateModelSettings).toHaveBeenCalled(); - }); - const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; - expect(args.inference_url).toBe('https://api.anthropic.com/v1/chat/completions'); - // Panel must never overwrite the OpenHuman backend URL when switching presets. - expect(args.api_url).toBeUndefined(); - expect(args.api_key).toBe('sk-ant-test-123'); - expect(args.model_routes).toBeInstanceOf(Array); - const hints = (args.model_routes ?? []).map(r => r.hint).sort(); - expect(hints).toEqual(['agentic', 'coding', 'reasoning', 'summarization']); - }); - - it('Save sends model_routes:[] and clears inference_url when switching back to OpenHuman', async () => { - mockClientConfig({ - inference_url: 'https://api.openai.com/v1/chat/completions', - api_key_set: true, - }); - renderWithProviders(); - - // Hydration finished — switch to OpenHuman explicitly - fireEvent.click(await screen.findByRole('button', { name: /^OpenHuman$/i })); - fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); - - await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); - const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; - expect(args.model_routes).toEqual([]); - // OpenHuman preset clears the custom override so inference falls through - // the backend; api_url (product backend) must remain untouched. - expect(args.inference_url).toBe(''); - expect(args.api_url).toBeUndefined(); - expect(args.api_key).toBeUndefined(); - }); - - it('omits all touched fields on Save when nothing was edited (post failed-load safety)', async () => { - mockClientConfig({ - inference_url: 'https://api.openai.com/v1/chat/completions', - api_key_set: true, - }); - renderWithProviders(); - - // Just hit Save without touching anything - fireEvent.click(await screen.findByRole('button', { name: /^Save$/i })); - - await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); - const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; - expect(args.api_url).toBeUndefined(); - expect(args.inference_url).toBeUndefined(); - expect(args.api_key).toBeUndefined(); - expect(args.model_routes).toBeUndefined(); - }); - - it('surfaces a "Clear stored key" button only when api_key_set is true (non-OpenHuman)', async () => { - mockClientConfig({ - inference_url: 'https://api.openai.com/v1/chat/completions', - api_key_set: true, - }); - renderWithProviders(); - - // Wait for the OpenAI preset to be active (inference_url matches) - await waitFor(() => { - expect(screen.getByLabelText(/API Key/i)).toBeTruthy(); - }); - expect(screen.getByRole('button', { name: /Clear stored key/i })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: /Clear stored key/i })); - await waitFor(() => expect(openhumanUpdateModelSettings).toHaveBeenCalled()); - const args = vi.mocked(openhumanUpdateModelSettings).mock.calls[0][0]; - expect(args.api_key).toBe(''); - }); - - it('shows an error status when the save RPC rejects', async () => { - mockClientConfig(); - vi.mocked(openhumanUpdateModelSettings).mockRejectedValueOnce(new Error('boom')); - renderWithProviders(); - - fireEvent.click(await screen.findByRole('button', { name: /^OpenAI$/i })); - fireEvent.click(screen.getByRole('button', { name: /^Save$/i })); - - await waitFor(() => { - expect(screen.getByText(/Failed to save: boom/i)).toBeTruthy(); - }); - }); - - it('shows a load-error status when the initial client-config fetch rejects', async () => { - vi.mocked(openhumanGetClientConfig).mockRejectedValueOnce(new Error('offline')); - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText(/Failed to load current settings: offline/i)).toBeTruthy(); - }); - }); -}); diff --git a/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx b/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx deleted file mode 100644 index 45b65a3ff..000000000 --- a/app/src/components/settings/panels/__tests__/LocalModelPanel.test.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../../../test/test-utils'; -import { - type CommandResponse, - type ConfigSnapshot, - isTauri, - type LocalAiDownloadsProgress, - type LocalAiStatus, - memoryTreeGetLlm, - memoryTreeSetLlm, - openhumanGetConfig, - openhumanLocalAiDownload, - openhumanLocalAiDownloadAllAssets, - openhumanLocalAiDownloadsProgress, - openhumanLocalAiPresets, - openhumanLocalAiStatus, - openhumanUpdateLocalAiSettings, - type PresetsResponse, -} from '../../../../utils/tauriCommands'; -import LocalModelPanel from '../LocalModelPanel'; - -vi.mock('../../../../utils/tauriCommands', () => ({ - isTauri: vi.fn(() => true), - memoryTreeGetLlm: vi.fn(), - memoryTreeSetLlm: vi.fn(), - openhumanGetConfig: vi.fn(), - openhumanLocalAiDownload: vi.fn(), - openhumanLocalAiDownloadAllAssets: vi.fn(), - openhumanLocalAiDownloadsProgress: vi.fn(), - openhumanLocalAiPresets: vi.fn(), - openhumanLocalAiStatus: vi.fn(), - openhumanUpdateLocalAiSettings: vi.fn(), -})); - -interface UsageFlags { - runtime_enabled: boolean; - embeddings: boolean; - heartbeat: boolean; - learning_reflection: boolean; - subconscious: boolean; -} - -const defaultUsage: UsageFlags = { - runtime_enabled: false, - embeddings: false, - heartbeat: false, - learning_reflection: false, - subconscious: false, -}; - -const makeSnapshot = (flags: UsageFlags): CommandResponse => ({ - result: { - config: { - local_ai: { - runtime_enabled: flags.runtime_enabled, - usage: { - embeddings: flags.embeddings, - heartbeat: flags.heartbeat, - learning_reflection: flags.learning_reflection, - subconscious: flags.subconscious, - }, - }, - }, - workspace_dir: '/tmp/openhuman-test', - config_path: '/tmp/openhuman-test/config.toml', - }, - logs: [], -}); - -const idleStatus: LocalAiStatus = { - state: 'idle', - installed: false, - download_progress: null, - downloaded_bytes: null, - total_bytes: null, - download_speed_bps: null, - eta_seconds: null, - message: null, - selected_tier: null, -} as unknown as LocalAiStatus; - -const idleDownloads: LocalAiDownloadsProgress = { - state: 'idle', - progress: null, - downloaded_bytes: null, - total_bytes: null, - speed_bps: null, - eta_seconds: null, -} as unknown as LocalAiDownloadsProgress; - -const presets: PresetsResponse = { - presets: [], - selected_tier: null, - detected_ram_bytes: 16 * 1024 * 1024 * 1024, - local_ai_enabled: false, -} as unknown as PresetsResponse; - -describe('LocalModelPanel — usage flags', () => { - let runtime: UsageFlags; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(isTauri).mockReturnValue(true); - runtime = { ...defaultUsage }; - - vi.mocked(openhumanLocalAiStatus).mockResolvedValue({ result: idleStatus, logs: [] }); - vi.mocked(openhumanLocalAiDownloadsProgress).mockResolvedValue({ - result: idleDownloads, - logs: [], - }); - vi.mocked(openhumanLocalAiPresets).mockResolvedValue(presets); - vi.mocked(openhumanLocalAiDownload).mockResolvedValue({ result: idleStatus, logs: [] }); - vi.mocked(openhumanLocalAiDownloadAllAssets).mockResolvedValue({ - result: idleDownloads, - logs: [], - }); - - vi.mocked(openhumanGetConfig).mockImplementation(async () => makeSnapshot(runtime)); - vi.mocked(openhumanUpdateLocalAiSettings).mockImplementation(async patch => { - runtime = { - runtime_enabled: patch.runtime_enabled ?? runtime.runtime_enabled, - embeddings: patch.usage_embeddings ?? runtime.embeddings, - heartbeat: patch.usage_heartbeat ?? runtime.heartbeat, - learning_reflection: patch.usage_learning_reflection ?? runtime.learning_reflection, - subconscious: patch.usage_subconscious ?? runtime.subconscious, - }; - return makeSnapshot(runtime); - }); - - // Memory summarizer backend defaults to cloud; tests that need a - // specific seed value override this in the test body. - vi.mocked(memoryTreeGetLlm).mockResolvedValue({ current: 'cloud' }); - vi.mocked(memoryTreeSetLlm).mockResolvedValue({ current: 'local' }); - }); - - it('renders all five usage toggles with sub-flags disabled when runtime is off', async () => { - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - await screen.findByText('Enable local AI runtime'); - expect(screen.getByText('Embeddings')).toBeInTheDocument(); - expect(screen.getByText('Heartbeat')).toBeInTheDocument(); - expect(screen.getByText('Learning / reflection')).toBeInTheDocument(); - expect(screen.getByText('Subconscious')).toBeInTheDocument(); - - // The four sub-flag inputs should be disabled while runtime is off - const checkboxes = screen.getAllByRole('checkbox'); - const masterIdx = checkboxes.findIndex(cb => - cb.closest('label')?.textContent?.includes('Enable local AI runtime') - ); - expect(masterIdx).toBeGreaterThanOrEqual(0); - const subFlags = checkboxes.filter((_, i) => i !== masterIdx); - for (const cb of subFlags) { - expect(cb).toBeDisabled(); - } - }); - - it('persists master toggle change via openhumanUpdateLocalAiSettings', async () => { - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const masterLabel = await screen.findByText('Enable local AI runtime'); - const master = masterLabel.closest('label')?.querySelector('input[type="checkbox"]'); - expect(master).toBeTruthy(); - fireEvent.click(master as HTMLInputElement); - - await waitFor(() => { - expect(openhumanUpdateLocalAiSettings).toHaveBeenCalledWith({ runtime_enabled: true }); - }); - }); - - it('does not invoke model downloads while runtime is disabled', async () => { - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const button = await screen.findByRole('button', { name: 'Download Models' }); - expect(button).toBeDisabled(); - fireEvent.click(button); - - const advancedButton = screen.getByRole('button', { name: 'Advanced settings' }); - expect(advancedButton).toBeDisabled(); - fireEvent.click(advancedButton); - - expect(openhumanLocalAiDownload).not.toHaveBeenCalled(); - expect(openhumanLocalAiDownloadAllAssets).not.toHaveBeenCalled(); - }); - - it('surfaces an error when the initial config load fails', async () => { - vi.mocked(openhumanGetConfig).mockRejectedValueOnce(new Error('boom: get_config')); - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - await screen.findByText('boom: get_config'); - }); - - it('rolls state back and shows error when save fails', async () => { - runtime.runtime_enabled = true; - vi.mocked(openhumanUpdateLocalAiSettings).mockRejectedValueOnce(new Error('save: forbidden')); - // Initial load succeeds; the reload triggered after a save error fails - // too, so the error message is not immediately cleared by a successful - // refetch. This exercises the catch arm in `updateUsage`. - vi.mocked(openhumanGetConfig).mockImplementationOnce(async () => makeSnapshot(runtime)); - vi.mocked(openhumanGetConfig).mockRejectedValueOnce(new Error('save: forbidden')); - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const heartbeatLabel = await screen.findByText('Heartbeat'); - const cb = heartbeatLabel.closest('label')?.querySelector('input[type="checkbox"]'); - fireEvent.click(cb as HTMLInputElement); - - await waitFor(() => { - expect(openhumanUpdateLocalAiSettings).toHaveBeenCalledWith({ usage_heartbeat: true }); - }); - await screen.findByText('save: forbidden'); - }); - - it('persists a sub-flag toggle once master is enabled', async () => { - runtime.runtime_enabled = true; - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const embeddingsLabel = await screen.findByText('Embeddings'); - const checkbox = embeddingsLabel.closest('label')?.querySelector('input[type="checkbox"]'); - expect(checkbox).toBeTruthy(); - await waitFor(() => { - expect(checkbox as HTMLInputElement).not.toBeDisabled(); - }); - fireEvent.click(checkbox as HTMLInputElement); - - await waitFor(() => { - expect(openhumanUpdateLocalAiSettings).toHaveBeenCalledWith({ usage_embeddings: true }); - }); - }); - - // The Memory summarizer checkbox is special — it writes - // `memory_tree.llm_backend` via memoryTreeSetLlm (the same field the - // removed Intelligence → Memory BackendChooser used to edit), not - // `local_ai.usage.*`. State seeds from memoryTreeGetLlm on mount. - it('seeds the Memory summarizer checkbox state from memoryTreeGetLlm', async () => { - vi.mocked(memoryTreeGetLlm).mockResolvedValueOnce({ current: 'local' }); - runtime.runtime_enabled = true; - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const summarizerLabel = await screen.findByText('Memory summarizer'); - const checkbox = summarizerLabel - .closest('label') - ?.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox).toBeTruthy(); - await waitFor(() => { - expect(memoryTreeGetLlm).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(checkbox.checked).toBe(true); - }); - }); - - it('flips the Memory summarizer checkbox and persists via memoryTreeSetLlm', async () => { - runtime.runtime_enabled = true; - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const summarizerLabel = await screen.findByText('Memory summarizer'); - const checkbox = summarizerLabel - .closest('label') - ?.querySelector('input[type="checkbox"]') as HTMLInputElement; - // The checkbox starts disabled until the async loadUsage() flips - // `usageFlags.runtime_enabled` to true; wait for that before clicking. - await waitFor(() => { - expect(checkbox).not.toBeDisabled(); - }); - fireEvent.click(checkbox); - - await waitFor(() => { - expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' }); - }); - }); - - it('rolls back the Memory summarizer optimistic toggle when memoryTreeSetLlm fails', async () => { - runtime.runtime_enabled = true; - vi.mocked(memoryTreeSetLlm).mockRejectedValueOnce(new Error('save: backend down')); - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const summarizerLabel = await screen.findByText('Memory summarizer'); - const checkbox = summarizerLabel - .closest('label') - ?.querySelector('input[type="checkbox"]') as HTMLInputElement; - await waitFor(() => { - expect(checkbox).not.toBeDisabled(); - }); - fireEvent.click(checkbox); - - // The error message surfaces in the shared usageError block. - await screen.findByText('save: backend down'); - // And the checkbox rolls back to its prior state (cloud → unchecked). - await waitFor(() => { - expect(checkbox.checked).toBe(false); - }); - }); - - it('does not call memoryTreeSetLlm when the Memory summarizer is disabled (runtime off)', async () => { - // runtime is OFF by default — summarizer checkbox should be disabled - // and clicks should not fire a setLlm call. - renderWithProviders(, { initialEntries: ['/settings/local-model'] }); - - const summarizerLabel = await screen.findByText('Memory summarizer'); - const checkbox = summarizerLabel - .closest('label') - ?.querySelector('input[type="checkbox"]') as HTMLInputElement; - expect(checkbox).toBeDisabled(); - // Exercise the disabled-click path — fireEvent dispatches even on - // disabled inputs (it bypasses React's synthetic event guard), so - // this confirms the handler doesn't fire `setLlm` because of the - // gating, not just because no click happened. - fireEvent.click(checkbox); - expect(memoryTreeSetLlm).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 5aa631e96..bd11ff455 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -6,7 +6,6 @@ import AgentChatPanel from '../components/settings/panels/AgentChatPanel'; import AIPanel from '../components/settings/panels/AIPanel'; import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel'; import AutocompletePanel from '../components/settings/panels/AutocompletePanel'; -import BackendProviderPanel from '../components/settings/panels/BackendProviderPanel'; import BillingPanel from '../components/settings/panels/BillingPanel'; import ComposioPanel from '../components/settings/panels/ComposioPanel'; import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel'; @@ -14,7 +13,6 @@ import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel'; import CronJobsPanel from '../components/settings/panels/CronJobsPanel'; import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel'; import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel'; -import LocalModelPanel from '../components/settings/panels/LocalModelPanel'; import MascotPanel from '../components/settings/panels/MascotPanel'; import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel'; import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel'; @@ -180,12 +178,13 @@ const featuresSettingsItems = [ }, ]; -const aiModelsSettingsItems = [ +const aiSettingsItems = [ { - id: 'local-model', - title: 'Local AI Model', - description: 'Choose model tier by device capability and manage downloads', - route: 'local-model', + id: 'llm', + title: 'LLM', + description: + 'Cloud providers, local Ollama models, and per-workload routing (reasoning, agentic, memory, …)', + route: 'llm', icon: ( ), }, - { - id: 'backend-provider', - title: 'LLM Provider', - description: 'Point inference at the OpenHuman backend or any OpenAI-compatible provider', - route: 'backend-provider', - icon: ( - - - - ), - }, { id: 'voice', - title: 'Voice (STT & TTS)', + title: 'Voice', description: - 'Choose between cloud and local providers for speech-to-text (Whisper) and text-to-speech (Piper)', + 'Speech-to-text (Whisper) and text-to-speech (Piper) — cloud vs local provider selection', route: 'voice', icon: ( @@ -279,12 +262,12 @@ const Settings = () => { )} /> )} /> @@ -314,16 +297,13 @@ const Settings = () => { )} /> )} /> )} /> - {/* AI & Models leaf panels */} - )} /> - )} /> {/* Developer Options */} )} /> )} /> - )} /> + )} /> )} /> )} /> ; +} + +// ─── Read path: load + parse ─────────────────────────────────────────────── + +const PROVIDER_PREFIXES: Record = { + openhuman: 'openhuman', + openai: 'openai', + anthropic: 'anthropic', + openrouter: 'openrouter', + custom: 'custom', +}; + +/** + * Parse a stored provider string (e.g. `"openai:gpt-4o"`) into a structured + * ProviderRef. Empty/null/`"cloud"` → primary. Unrecognised → primary (safest + * fallback). Mirrors the Rust factory grammar. + */ +export function parseProviderString(s: string | null | undefined): ProviderRef { + const trimmed = (s ?? '').trim(); + if (!trimmed || trimmed === 'cloud') { + return { kind: 'primary' }; + } + if (trimmed.startsWith('ollama:')) { + return { kind: 'local', model: trimmed.slice('ollama:'.length).trim() }; + } + // Bare "openhuman" (no model suffix) means "use the OpenHuman backend with + // its default model" — map to a cloud ref so the round-trip preserves the + // explicit override rather than collapsing to the primary-cloud sentinel. + if (trimmed === 'openhuman') { + return { kind: 'cloud', providerType: 'openhuman', model: '' }; + } + for (const prefix of Object.keys(PROVIDER_PREFIXES)) { + if (trimmed.startsWith(`${prefix}:`)) { + return { + kind: 'cloud', + providerType: PROVIDER_PREFIXES[prefix], + model: trimmed.slice(prefix.length + 1).trim(), + }; + } + } + return { kind: 'primary' }; +} + +/** Serialise a `ProviderRef` back to the wire-format string. */ +export function serializeProviderRef(ref: ProviderRef): string { + switch (ref.kind) { + case 'primary': + return 'cloud'; + case 'cloud': + // Bare "openhuman" (no model) uses the sentinel form the Rust factory + // expects — avoid emitting "openhuman:" (with trailing colon) which the + // factory does not parse. + if (ref.providerType === 'openhuman' && !ref.model) { + return 'openhuman'; + } + return `${ref.providerType}:${ref.model}`; + case 'local': + return `ollama:${ref.model}`; + } +} + +/** + * Loads the full AI settings view by joining: + * - the core's client-config snapshot (cloud_providers + *_provider fields) + * - the auth profiles list (to derive `has_api_key` per cloud provider) + * + * Defensive: a failed `auth_list` (e.g. brand-new workspace, no profiles + * file yet) silently degrades to `has_api_key: false` for all entries so + * the panel still renders. + */ +export async function loadAISettings(): Promise { + const [configRes, profilesRes] = await Promise.all([ + openhumanGetClientConfig(), + authListProviderCredentials().catch((): { result: AuthProfileSummary[] } => ({ result: [] })), + ]); + const config: ClientConfig = configRes.result; + const profilesByProvider = new Set( + profilesRes.result.map((p: AuthProfileSummary) => p.provider.toLowerCase()) + ); + + const cloudProviders: CloudProviderView[] = config.cloud_providers.map(p => ({ + ...p, + has_api_key: profilesByProvider.has(p.type.toLowerCase()), + })); + + const routing: Record = { + reasoning: parseProviderString(config.reasoning_provider), + agentic: parseProviderString(config.agentic_provider), + coding: parseProviderString(config.coding_provider), + memory: parseProviderString(config.memory_provider), + embeddings: parseProviderString(config.embeddings_provider), + heartbeat: parseProviderString(config.heartbeat_provider), + learning: parseProviderString(config.learning_provider), + subconscious: parseProviderString(config.subconscious_provider), + }; + + return { cloudProviders, primaryCloudId: config.primary_cloud, routing }; +} + +// ─── Write path: diff + save ─────────────────────────────────────────────── + +/** + * Persist a draft `AISettings` to the core. Diffs against a previous snapshot + * and only sends fields that actually changed — keeps the patch small and + * avoids inadvertently overwriting unrelated fields edited elsewhere. + */ +export async function saveAISettings(prev: AISettings, next: AISettings): Promise { + const patch: ModelSettingsUpdate = {}; + + // Cloud providers: any change → send the full list. + if ( + prev.cloudProviders.length !== next.cloudProviders.length || + prev.cloudProviders.some((p, i) => { + const n = next.cloudProviders[i]; + return ( + !n || + n.id !== p.id || + n.type !== p.type || + n.endpoint !== p.endpoint || + n.default_model !== p.default_model + ); + }) + ) { + patch.cloud_providers = next.cloudProviders.map(({ id, type, endpoint, default_model }) => ({ + id, + type, + endpoint, + default_model, + })); + } + + if (prev.primaryCloudId !== next.primaryCloudId) { + patch.primary_cloud = next.primaryCloudId ?? ''; + } + + for (const w of ALL_WORKLOADS) { + const a = serializeProviderRef(prev.routing[w]); + const b = serializeProviderRef(next.routing[w]); + if (a !== b) { + patch[`${w}_provider` as keyof ModelSettingsUpdate] = b as never; + } + } + + if (Object.keys(patch).length === 0) { + return; + } + await openhumanUpdateModelSettings(patch); +} + +// ─── API key management (per cloud provider type) ────────────────────────── + +/** + * Store an API key for a cloud provider (encrypted at rest). The provider + * type doubles as the auth-profile id, so every cloud_providers entry of + * the same type shares the same key. + */ +export async function setCloudProviderKey( + providerType: CloudProviderType, + apiKey: string +): Promise { + if (providerType === 'openhuman') { + throw new Error('OpenHuman uses the session JWT — keys are not configurable here.'); + } + await authStoreProviderCredentials({ + provider: providerType, + profile: 'default', + token: apiKey, + setActive: true, + }); +} + +/** Clear a stored API key. */ +export async function clearCloudProviderKey(providerType: CloudProviderType): Promise { + if (providerType === 'openhuman') { + return; + } + await authRemoveProviderCredentials({ provider: providerType, profile: 'default' }); +} + +// ─── Local provider façade (Ollama install / detect / model manage) ─────── + +/** Snapshot of the Ollama daemon + installed-model state for the AI panel. */ +export interface LocalProviderSnapshot { + status: LocalAiStatus | null; + diagnostics: LocalAiDiagnostics | null; + presets: PresetsResponse | null; + installedModels: Array<{ name: string; size?: number | null }>; +} + +export async function loadLocalProviderSnapshot(): Promise { + const [statusRes, diag, presets] = await Promise.all([ + openhumanLocalAiStatus().catch((): { result: LocalAiStatus | null } => ({ result: null })), + openhumanLocalAiDiagnostics().catch((): LocalAiDiagnostics | null => null), + openhumanLocalAiPresets().catch((): PresetsResponse | null => null), + ]); + return { + status: statusRes.result, + diagnostics: diag, + presets, + installedModels: diag?.installed_models ?? [], + }; +} + +/** + * Toggle the master local-AI runtime (Ollama daemon orchestration). When + * `false`, every workload routed to `ollama:*` will fail to build at the + * factory level — the user should leave routes set to "cloud" while local + * AI is disabled. The new AI panel surfaces this as a single switch. + * + * Critically: this flips BOTH `runtime_enabled` AND `opt_in_confirmed`. + * Bootstrap has a separate hard-override that forces status to "disabled" + * whenever `opt_in_confirmed` is false, regardless of `runtime_enabled`. + * Setting only `runtime_enabled = true` would spawn the daemon and + * immediately get re-disabled on the next bootstrap call: + * [local_ai] bootstrap: opt_in_confirmed=false, hard-overriding to disabled + * Tying the two flags together matches `apply_preset`'s behaviour and gives + * the user a single-click enable. + */ +export async function setLocalRuntimeEnabled(enabled: boolean): Promise { + await openhumanUpdateLocalAiSettings({ runtime_enabled: enabled, opt_in_confirmed: enabled }); +} + +/** + * Set / clear the user-configured Ollama binary path. The Rust resolver + * tries (in order): this field → `OLLAMA_BIN` env → workspace bin → + * system PATH → auto-install. Pass an empty string to clear and fall + * back to auto-detection. + * + * Triggers a re-bootstrap on the Rust side so the new path takes effect + * without needing a manual restart. + */ +export async function setLocalOllamaPath(path: string): Promise { + await openhumanLocalAiSetOllamaPath(path); +} + +/** + * Gate off the local-AI runtime: writes `runtime_enabled = false`, kills the + * Ollama daemon ONLY if OpenHuman spawned it (external daemons are left + * untouched), and forces status to `"disabled"`. Workloads routed to + * `ollama:` will fail at factory build time after this — the gate is + * at the routing layer, not by killing arbitrary processes. + */ +export async function shutdownLocalProvider(): Promise { + await setLocalRuntimeEnabled(false); + await openhumanLocalAiShutdownOwned(); +} + +/** Convenience helpers re-exported so the panel imports from one place. */ +export const localProvider = { + applyPreset: (tier: string) => openhumanLocalAiApplyPreset(tier), + download: (retry: boolean) => openhumanLocalAiDownload(retry), + setEnabled: (enabled: boolean) => setLocalRuntimeEnabled(enabled), + setBinaryPath: (path: string) => setLocalOllamaPath(path), + shutdown: () => shutdownLocalProvider(), +}; + +export type { ModelPresetResult }; diff --git a/app/src/utils/localAiHelpers.ts b/app/src/utils/localAiHelpers.ts index 67a8ebcd4..af2410751 100644 --- a/app/src/utils/localAiHelpers.ts +++ b/app/src/utils/localAiHelpers.ts @@ -1,6 +1,6 @@ /** * Shared helpers for local AI download progress display. - * Used by Home.tsx, LocalAIStep.tsx, LocalModelPanel.tsx, and LocalAIDownloadSnackbar.tsx. + * Used by Home.tsx, LocalAIStep.tsx, AIPanel.tsx, and LocalAIDownloadSnackbar.tsx. */ import type { LocalAiDownloadsProgress, LocalAiStatus } from './tauriCommands'; diff --git a/app/src/utils/tauriCommands/auth.ts b/app/src/utils/tauriCommands/auth.ts index 3d5246997..71bbc0f3f 100644 --- a/app/src/utils/tauriCommands/auth.ts +++ b/app/src/utils/tauriCommands/auth.ts @@ -90,3 +90,68 @@ export async function openhumanDecryptSecret(ciphertext: string): Promise; + created_at?: string; + updated_at?: string; +} + +/** + * Store an API key for a cloud LLM provider (or any other named provider). + * The token is encrypted at rest in `auth-profiles.json` under the workspace + * `.secret_key` — same scheme used by the Composio integration. + */ +export async function authStoreProviderCredentials(args: { + provider: string; + profile?: string; + token?: string; + fields?: Record; + setActive?: boolean; +}): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: 'openhuman.auth_store_provider_credentials', + params: args, + }); +} + +/** Remove a stored provider credential profile. */ +export async function authRemoveProviderCredentials(args: { + provider: string; + profile?: string; +}): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc< + CommandResponse<{ removed: boolean; provider: string; profile: string }> + >({ method: 'openhuman.auth_remove_provider_credentials', params: args }); +} + +/** List stored provider credential profiles, optionally filtered by provider. */ +export async function authListProviderCredentials( + provider?: string +): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: 'openhuman.auth_list_provider_credentials', + params: provider ? { provider } : {}, + }); +} diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 880959233..6773c2822 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -16,6 +16,22 @@ export interface ModelRoute { model: string; } +/** Cloud provider type discriminator. Lowercase JSON wire format. */ +export type CloudProviderType = 'openhuman' | 'openai' | 'anthropic' | 'openrouter' | 'custom'; + +/** + * Endpoint config for one cloud LLM provider. API keys are NOT carried on + * this struct — they live in `auth-profiles.json` via the AuthService + * (set/cleared through the `auth_*` RPCs). + */ +export interface CloudProviderCreds { + /** Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in UI. */ + id: string; + type: CloudProviderType; + endpoint: string; + default_model: string; +} + export interface ModelSettingsUpdate { /** * OpenHuman product backend URL. Almost always left untouched; the @@ -38,6 +54,22 @@ export interface ModelSettingsUpdate { * on its own). Omit to leave existing routes untouched. */ model_routes?: ModelRoute[] | null; + /** + * When present, REPLACES `config.cloud_providers` wholesale. API keys are + * NOT carried here — store them via `authStoreProviderCredentials`. + */ + cloud_providers?: CloudProviderCreds[] | null; + /** Id of the `cloud_providers` entry used when a workload routes to "cloud". */ + primary_cloud?: string | null; + /** Per-workload provider strings — see Rust `providers::factory` grammar. */ + reasoning_provider?: string | null; + agentic_provider?: string | null; + coding_provider?: string | null; + memory_provider?: string | null; + embeddings_provider?: string | null; + heartbeat_provider?: string | null; + learning_provider?: string | null; + subconscious_provider?: string | null; } /** @@ -88,6 +120,13 @@ export interface ScreenIntelligenceSettingsUpdate { export interface LocalAiSettingsUpdate { runtime_enabled?: boolean | null; + /** + * MVP opt-in marker. Bootstrap hard-overrides status to "disabled" when + * this is `false`, regardless of `runtime_enabled`. The unified AI panel + * toggle flips this in tandem with `runtime_enabled` so a single click + * actually turns local AI on — without it, the daemon spawns but + * bootstrap immediately forces status back to disabled (cloud fallback). + */ opt_in_confirmed?: boolean | null; provider?: string | null; base_url?: string | null; @@ -145,19 +184,29 @@ export interface ClientConfig { /** OpenHuman product backend URL (auth/billing/voice). */ api_url: string | null; /** - * Custom OpenAI-compatible LLM endpoint. When set with an api_key, the - * core routes inference directly to this URL instead of the OpenHuman - * backend. This is what the LLM Provider settings panel reads/writes. + * Custom OpenAI-compatible LLM endpoint. Legacy field, retained for + * back-compat — the new AI settings panel reads/writes + * `cloud_providers` + `*_provider` fields instead. */ inference_url: string | null; default_model: string | null; app_version: string; api_key_set: boolean; - /** - * Persisted task-hint -> model id pairs the core router will obey. Empty - * when the OpenHuman built-in router is active. - */ + /** Legacy per-task-hint model overrides (deprecated; will be removed). */ model_routes: ModelRoute[]; + /** Configured cloud providers (no API keys — those live in auth-profiles.json). */ + cloud_providers: CloudProviderCreds[]; + /** Id of the `cloud_providers` entry resolved by the `"cloud"` sentinel. */ + primary_cloud: string | null; + /** Per-workload provider strings (e.g. `"cloud"`, `"ollama:llama3.1:8b"`, `"openai:gpt-4o"`). */ + reasoning_provider: string | null; + agentic_provider: string | null; + coding_provider: string | null; + memory_provider: string | null; + embeddings_provider: string | null; + heartbeat_provider: string | null; + learning_provider: string | null; + subconscious_provider: string | null; } export async function openhumanGetClientConfig(): Promise> { diff --git a/app/src/utils/tauriCommands/localAi.ts b/app/src/utils/tauriCommands/localAi.ts index 1db167c2b..c1b487d1c 100644 --- a/app/src/utils/tauriCommands/localAi.ts +++ b/app/src/utils/tauriCommands/localAi.ts @@ -474,3 +474,15 @@ export async function openhumanLocalAiSetOllamaPath( params: { path }, }); } + +/** + * Gate off the local-AI runtime: kills the Ollama daemon only if OpenHuman + * spawned it (external daemons are left running), and forces status to + * `"disabled"` so the UI flips immediately. + */ +export async function openhumanLocalAiShutdownOwned(): Promise> { + return await callCoreRpc>({ + method: 'openhuman.local_ai_shutdown_owned', + params: {}, + }); +} diff --git a/design-previews/ai-settings.html b/design-previews/ai-settings.html new file mode 100644 index 000000000..60f90ba55 --- /dev/null +++ b/design-previews/ai-settings.html @@ -0,0 +1,529 @@ + + + + + + OpenHuman · AI settings (preview) + + + + + + + + + + + + + + + +
+ +
+
+ + +

AI

+ preview +
+
+ +
+ +
+
+

Cloud providers

+ +
+ +
+ +
+
+ + +
+

Local provider

+ +
+
+ + + +
+
running
+
ollama · v0.3.14
+
+ + +
+ +
    + +
+
+ +
+
+
+ + +
+
+

Workload routing

+
+ + + +
+
+ +
+
+
Chat
+
+ +
+
+ +
+
Background
+
+ +
+
+
+ +
+ Primary resolves to + +
+
+ + +
+
+
+ +
+
+
+ + unsaved change +
+
+
+ + +
+
+
+
+ + + + diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index 1619193a3..35d2a4f26 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -17,9 +17,59 @@ use super::definition::{AgentDefinition, DefinitionSource}; /// TOML parse happens at runtime; the unit tests in /// [`crate::openhuman::agent::agents`] verify in CI that every entry in /// [`crate::openhuman::agent::agents::BUILTINS`] still parses cleanly. +/// +/// In `#[cfg(test)]` builds the list additionally contains +/// [`test_inherit_echo_def`] — a sub-agent with `ModelSpec::Inherit` +/// that exists solely so the spawn-subagent end-to-end test can +/// exercise the dispatch/threading plumbing with the *parent's* +/// provider (every shipped builtin uses `Hint(...)`, which after +/// #1710 builds a fresh factory provider and therefore can't share a +/// test's `MockProvider`). It is never compiled into release builds. pub fn all() -> Vec { - crate::openhuman::agent::agents::load_builtins() - .expect("built-in agent TOML must always parse (see agents/*/agent.toml)") + #[allow(unused_mut)] + let mut defs = crate::openhuman::agent::agents::load_builtins() + .expect("built-in agent TOML must always parse (see agents/*/agent.toml)"); + #[cfg(test)] + defs.push(test_inherit_echo_def()); + defs +} + +/// Test-only sub-agent: `ModelSpec::Inherit`, wildcard tools, minimal +/// prompt. Inherit means the runner uses `parent.provider` verbatim, +/// so a test's scripted `MockProvider` reaches the sub-agent loop — +/// which is exactly what the full-path spawn test needs to assert the +/// dispatch → run_subagent → result-threading chain end to end. +/// Provider *routing* for `Hint` sub-agents is covered separately by +/// `subagent_runner::ops::tests::resolve_subagent_provider_*`. +#[cfg(test)] +pub(crate) fn test_inherit_echo_def() -> AgentDefinition { + use super::definition::{ModelSpec, PromptSource, SandboxMode, ToolScope}; + AgentDefinition { + id: "__test_inherit_echo".into(), + when_to_use: "test-only sub-agent that inherits the parent provider".into(), + display_name: None, + system_prompt: PromptSource::Inline("You are a test sub-agent.".into()), + omit_identity: true, + omit_memory_context: true, + omit_safety_preamble: true, + omit_skills_catalog: true, + omit_profile: true, + omit_memory_md: true, + model: ModelSpec::Inherit, + temperature: 0.0, + tools: ToolScope::Named(vec![]), + disallowed_tools: vec![], + skill_filter: None, + extra_tools: vec![], + max_iterations: 3, + max_result_chars: None, + timeout_secs: None, + sandbox_mode: SandboxMode::None, + background: false, + subagents: vec![], + delegate_name: None, + source: DefinitionSource::Builtin, + } } #[cfg(test)] @@ -29,7 +79,24 @@ mod tests { #[test] fn all_definitions_present() { let defs = all(); - assert_eq!(defs.len(), crate::openhuman::agent::agents::BUILTINS.len()); + // +1 for the cfg(test) `__test_inherit_echo` def appended by all(). + assert_eq!( + defs.len(), + crate::openhuman::agent::agents::BUILTINS.len() + 1 + ); + } + + #[test] + fn test_inherit_echo_is_present_and_inherits() { + use super::super::definition::ModelSpec; + let def = all() + .into_iter() + .find(|d| d.id == "__test_inherit_echo") + .expect("test-only inherit agent must be registered in test builds"); + assert!( + matches!(def.model, ModelSpec::Inherit), + "must be Inherit so the sub-agent uses the parent's (mock) provider" + ); } #[test] diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index 061807f4b..c509a47dc 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -25,6 +25,40 @@ use crate::openhuman::tools::{self, Tool, ToolSpec}; use anyhow::Result; use std::sync::Arc; +/// Drop entries with duplicate `name` fields, first occurrence wins. +/// +/// Anthropic (and other strict providers) rejects a chat/completions +/// request that lists two tools with the same name — OpenHuman's own +/// backend and OpenAI silently accept duplicates, which hid the +/// underlying collision (researcher sub-agent's `delegate_name = +/// "research"` shadowing a same-named skill tool) until #1710's +/// per-role routing started sending the same tool list to Anthropic. +/// +/// Called from every place that materialises the visible tool spec +/// list — initial build, post-composio refresh, scope-filter change — +/// so the request the provider sees is always name-unique regardless +/// of which path produced it. +pub(super) fn dedup_visible_tool_specs(specs: Vec) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut deduped: Vec = Vec::with_capacity(specs.len()); + let mut dropped: Vec = Vec::new(); + for spec in specs { + if seen.insert(spec.name.clone()) { + deduped.push(spec); + } else { + dropped.push(spec.name); + } + } + if !dropped.is_empty() { + log::warn!( + "[agent] dropped {} duplicate tool spec(s) before sending to provider: {:?}", + dropped.len(), + dropped + ); + } + deduped +} + impl AgentBuilder { /// Creates a new `AgentBuilder` with default values. pub fn new() -> Self { @@ -297,7 +331,7 @@ impl AgentBuilder { // (backward compat). When populated, only allowlisted tools // appear in the function-calling schema so the LLM literally // cannot call skill tools directly — it must use spawn_subagent. - let visible_tool_specs: Vec = if visible_names.is_empty() { + let visible_tool_specs_unfiltered: Vec = if visible_names.is_empty() { tool_specs.clone() } else { tool_specs @@ -307,6 +341,14 @@ impl AgentBuilder { .collect() }; + // Dedupe by tool name. Anthropic (and other strict providers) + // rejects a chat/completions request that lists two tools with + // the same name — OpenHuman's own backend and OpenAI silently + // accept duplicates, which hid this bug until #1710's per-role + // routing started sending the same tool list to Anthropic. + let visible_tool_specs: Vec = + dedup_visible_tool_specs(visible_tool_specs_unfiltered); + log::info!( "[agent] tool spec filter: total={} visible={} (filter_active={})", tool_specs.len(), @@ -602,9 +644,10 @@ impl Agent { &config.workspace_dir, )); + let local_embedding = config.workload_local_model("embeddings"); let memory: Arc = Arc::from(memory::create_memory_with_local_ai( &config.memory, - &config.local_ai, + local_embedding.as_deref(), &config.embedding_routes, Some(&config.storage.provider.config), &config.workspace_dir, @@ -655,26 +698,34 @@ impl Agent { } } - let model_name = config - .default_model - .as_deref() - .unwrap_or(crate::openhuman::config::DEFAULT_MODEL) - .to_string(); - - let provider_runtime_options = providers::ProviderRuntimeOptions { + // Route the main agent's chat through the unified per-workload + // factory so the user's "Reasoning" routing in the AI settings + // panel (e.g. `reasoning_provider = "anthropic:claude-..."`) + // actually takes effect. The factory returns a (Provider, model) + // tuple — the resolved model wins over the legacy `default_model` + // fallback so explicit picks like `anthropic:claude-sonnet-4-5` + // actually use claude-sonnet-4-5 end to end (sending the abstract + // "reasoning-v1" tier name to Anthropic would 404). + // + // When `reasoning_provider` is unset or `"cloud"`, the factory + // resolves to the primary cloud (OpenHuman by default), so the + // baseline behaviour is identical to the legacy + // `create_intelligent_routing_provider` path. + // + // What we deliberately lose for now: the ReliableProvider retry + // wrapper, model_routes translation, and intelligent local/cloud + // task hinting that the legacy router added on top of the raw + // backend. Those are valuable but orthogonal — they can be layered + // back on top of the factory's output in a follow-up without + // re-introducing the routing bypass. + let _ = providers::ProviderRuntimeOptions { auth_profile_override: None, openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), secrets_encrypt: config.secrets.encrypt, reasoning_enabled: config.runtime.reasoning_enabled, }; - - let provider: Box = providers::create_intelligent_routing_provider( - config.inference_url.as_deref(), - config.api_url.as_deref(), - config.api_key.as_deref(), - config, - &provider_runtime_options, - )?; + let (provider, model_name): (Box, String) = + crate::openhuman::providers::create_chat_provider("reasoning", config)?; // Dispatcher selection is deferred until after the tool list is // finalised (orchestrator tools are appended below). We capture @@ -1245,3 +1296,78 @@ fn prefetch_tool_memory_rules_blocking( }) }) } + +#[cfg(test)] +mod dedup_tests { + use super::dedup_visible_tool_specs; + use crate::openhuman::tools::ToolSpec; + use serde_json::json; + + fn spec(name: &str) -> ToolSpec { + ToolSpec { + name: name.to_string(), + description: format!("description for {name}"), + parameters: json!({}), + } + } + + #[test] + fn drops_duplicates_first_wins() { + // Real-world collision: researcher's `delegate_name = "research"` + // synthesises a delegate tool that shadows a same-named skill. + // Anthropic 400s on duplicate tool names; the dedup helper must + // keep the *first* occurrence so registration order semantics + // are preserved (the underlying tool dispatch lookup-by-name + // still resolves the right tool). + let specs = vec![ + spec("research"), // skill + spec("plan"), + spec("research"), // delegate, dropped + spec("run_code"), + spec("plan"), // dropped + ]; + + let deduped = dedup_visible_tool_specs(specs); + + let names: Vec<&str> = deduped.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["research", "plan", "run_code"]); + } + + #[test] + fn passes_through_when_no_duplicates() { + let specs = vec![spec("a"), spec("b"), spec("c")]; + let deduped = dedup_visible_tool_specs(specs); + assert_eq!(deduped.len(), 3); + assert_eq!(deduped[0].name, "a"); + assert_eq!(deduped[1].name, "b"); + assert_eq!(deduped[2].name, "c"); + } + + #[test] + fn handles_empty_input() { + let deduped = dedup_visible_tool_specs(Vec::::new()); + assert!(deduped.is_empty()); + } + + #[test] + fn preserves_full_spec_content_for_kept_entries() { + // Description + parameters must survive the dedup pass intact — + // the LLM uses both for tool-call decisions, and corrupting them + // would silently degrade function-calling quality. + let mut spec_a = spec("alpha"); + spec_a.description = "first alpha — should win".to_string(); + spec_a.parameters = json!({"type": "object", "required": ["x"]}); + + let mut spec_a_dup = spec("alpha"); + spec_a_dup.description = "second alpha — should be dropped".to_string(); + + let deduped = dedup_visible_tool_specs(vec![spec_a.clone(), spec_a_dup]); + + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].description, "first alpha — should win"); + assert_eq!( + deduped[0].parameters, + json!({"type": "object", "required": ["x"]}) + ); + } +} diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 5c1a76f0b..b79bdf7ea 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -223,7 +223,7 @@ impl Agent { .cloned() .collect() }; - self.visible_tool_specs = Arc::new(visible_specs); + self.visible_tool_specs = Arc::new(super::builder::dedup_visible_tool_specs(visible_specs)); } /// Clears the agent's conversation history. diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 627cf4c7b..43bcff9de 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -388,6 +388,16 @@ async fn turn_with_native_dispatcher_persists_fallback_tool_calls() { /// - AgentDefinitionRegistry::global lookup /// - run_subagent → run_inner_loop with the parent's provider /// - Result returned as a ToolResult and threaded back into history +/// +/// Uses the `#[cfg(test)]`-only `__test_inherit_echo` sub-agent +/// (`ModelSpec::Inherit`) rather than `researcher`. After #1710, +/// sub-agents with a `Hint(workload)` spec build a fresh provider via +/// `create_chat_provider(...)` and therefore can't share this test's +/// `MockProvider` — so a Hint sub-agent here would leak the scripted +/// chain. `Inherit` keeps `parent.provider`, which is exactly the +/// plumbing this test asserts. Provider *routing* for Hint sub-agents +/// is covered independently by +/// `subagent_runner::ops::tests::resolve_subagent_provider_*`. #[tokio::test] async fn turn_dispatches_spawn_subagent_through_full_path() { use crate::openhuman::agent::harness::AgentDefinitionRegistry; @@ -411,7 +421,7 @@ async fn turn_dispatches_spawn_subagent_through_full_path() { id: "call-spawn".into(), name: "spawn_subagent".into(), arguments: serde_json::json!({ - "agent_id": "researcher", + "agent_id": "__test_inherit_echo", "prompt": "find out about X" }) .to_string(), diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 9c9609a13..05f0a8b2e 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -1478,7 +1478,11 @@ impl Agent { } // Rebuild the visible-spec cache from the new tool_specs so the - // next provider call carries the reconciled schema. + // next provider call carries the reconciled schema. Dedup + // afterward so a delegate synthesised here (e.g. + // `delegate_name = "research"`) doesn't collide with a + // same-named skill tool on the wire — Anthropic 400s on dup + // tool names where OpenHuman's backend silently accepts. let visible_specs: Vec = if self.visible_tool_names.is_empty() { (*self.tool_specs).clone() @@ -1489,7 +1493,7 @@ impl Agent { .cloned() .collect() }; - self.visible_tool_specs = Arc::new(visible_specs); + self.visible_tool_specs = Arc::new(super::builder::dedup_visible_tool_specs(visible_specs)); // Compute add/remove deltas for the log line — useful when // diagnosing a Composio connect/revoke that should have rebuilt diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index 6025577e0..a33a26d41 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -73,6 +73,75 @@ fn append_subagent_role_contract(base_prompt: String, agent_id: &str) -> String prompt } +/// Resolve a sub-agent's `(provider, model)` based on its declarative +/// `[model]` spec. +/// +/// - `Inherit` — use the parent's provider AND model. Literally +/// "do what the parent does". +/// - `Hint(workload)` — build a fresh provider via the per-workload +/// factory (e.g. `integrations_agent`'s `[model] hint = "agentic"` +/// resolves to whatever `agentic_provider` is routed to in +/// AI Settings). The factory returns the *exact* model id for that +/// workload — the OpenHuman backend and every third-party provider +/// accept exact model names, so there's no `{hint}-v1` synthesis +/// anywhere on this path. +/// - `Exact(name)` — escape hatch: use the parent's provider with +/// this model name overriding the parent's. Callers are expected +/// to know the model is valid for the parent's provider; the enum +/// is the wrong place to encode provider switching, which belongs +/// to `Hint` + AI-settings routing. +/// +/// `config` is `None` when the live `Config::load_or_init()` failed +/// (rare — transient I/O). Both `None` config and factory build errors +/// fall back to `(parent_provider, parent_model)` so a config glitch +/// can't sink sub-agent execution entirely. +/// +/// The async part (config load) is hoisted out of the caller so this +/// helper stays sync and can be exercised by a focused unit test +/// without spinning up a `tokio::test` runtime per case. +pub(super) fn resolve_subagent_provider( + spec: &crate::openhuman::agent::harness::definition::ModelSpec, + agent_id: &str, + config: Option<&crate::openhuman::config::Config>, + parent_provider: std::sync::Arc, + parent_model: String, +) -> (std::sync::Arc, String) { + use crate::openhuman::agent::harness::definition::ModelSpec; + match spec { + ModelSpec::Hint(workload) => match config { + Some(cfg) => match crate::openhuman::providers::create_chat_provider(workload, cfg) { + Ok((p, m)) => { + log::info!( + "[subagent_runner] role={} agent_id={} resolved via workload factory model={}", + workload, agent_id, m + ); + (std::sync::Arc::from(p), m) + } + Err(e) => { + log::warn!( + "[subagent_runner] workload '{}' provider build failed ({}) for agent_id={} — \ + falling back to parent provider + parent model '{}'", + workload, e, agent_id, parent_model + ); + (parent_provider, parent_model) + } + }, + None => { + log::warn!( + "[subagent_runner] config load failed for workload '{}' (agent_id={}) — \ + falling back to parent provider + parent model '{}'", + workload, + agent_id, + parent_model + ); + (parent_provider, parent_model) + } + }, + ModelSpec::Inherit => (parent_provider, parent_model), + ModelSpec::Exact(name) => (parent_provider, name.clone()), + } +} + /// Lazy resolver that lets `integrations_agent` recover when the model /// calls a Composio action slug that exists in the bound toolkit's full /// catalogue but was filtered out of the up-front fuzzy top-K. On a @@ -205,8 +274,18 @@ async fn run_typed_mode( ) -> Result { let started = Instant::now(); - // ── Resolve model + temperature ──────────────────────────────────── - let model = definition.model.resolve(&parent.model_name); + // Resolve provider + model. See `resolve_subagent_provider` for the + // semantics of each ModelSpec variant. `Config::load_or_init()` is + // async so the load is hoisted out of the helper — the helper itself + // is sync and unit-tested. + let config_loaded = crate::openhuman::config::Config::load_or_init().await; + let (subagent_provider, model) = resolve_subagent_provider( + &definition.model, + &definition.id, + config_loaded.as_ref().ok(), + parent.provider.clone(), + parent.model_name.clone(), + ); let temperature = definition.temperature; // Archetype prompt loading is deferred until AFTER tool filtering so @@ -873,7 +952,7 @@ async fn run_typed_mode( // provider response), mirroring the main-agent turn loop in // `session/turn.rs`. No post-loop write needed here. let (output, iterations, _agg_usage) = run_inner_loop( - parent.provider.as_ref(), + subagent_provider.as_ref(), &mut history, &parent.all_tools, dynamic_tools, diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 56b1dc32e..31ce7b02b 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -687,6 +687,134 @@ async fn typed_mode_progress_emission_is_a_noop_without_sink() { // Truncation tests live in ops_truncation_tests.rs to keep this file // under the ~500-line guideline. +// ── resolve_subagent_provider ───────────────────────────────────────── + +/// `Arc` identity helper — every test below uses a fresh +/// `ScriptedProvider` and we want to assert "is this the *same* Arc as +/// the parent's" without leaning on `PartialEq` on dyn trait objects. +fn arc_ptr_eq(a: &std::sync::Arc

, b: &std::sync::Arc

) -> bool { + std::sync::Arc::ptr_eq(a, b) +} + +#[test] +fn resolve_subagent_provider_inherit_uses_parent_provider_and_model() { + let parent: Arc = ScriptedProvider::new(vec![]); + let (resolved_provider, resolved_model) = super::resolve_subagent_provider( + &ModelSpec::Inherit, + "test_agent", + None, + parent.clone(), + "parent-model-x".to_string(), + ); + assert!( + arc_ptr_eq(&parent, &resolved_provider), + "Inherit must return the parent's Arc unchanged" + ); + assert_eq!(resolved_model, "parent-model-x"); +} + +#[test] +fn resolve_subagent_provider_exact_overrides_only_model() { + // Exact keeps the parent's provider but replaces the model name. + // This is the explicit "I want a cheaper tier on the same backend" + // escape hatch. + let parent: Arc = ScriptedProvider::new(vec![]); + let (resolved_provider, resolved_model) = super::resolve_subagent_provider( + &ModelSpec::Exact("haiku-mini".to_string()), + "test_agent", + None, + parent.clone(), + "parent-model-x".to_string(), + ); + assert!( + arc_ptr_eq(&parent, &resolved_provider), + "Exact must keep the parent's provider — only the model name changes" + ); + assert_eq!(resolved_model, "haiku-mini"); +} + +#[test] +fn resolve_subagent_provider_hint_with_no_config_falls_back() { + // The async config load failed (transient I/O, missing file, etc.). + // The Hint arm must NOT silently swallow the failure and synthesise + // `{workload}-v1` — that's the OpenHuman-only naming that breaks + // Anthropic/OpenAI. Fall back to the parent's known-good + // (provider, model) instead. + let parent: Arc = ScriptedProvider::new(vec![]); + let (resolved_provider, resolved_model) = super::resolve_subagent_provider( + &ModelSpec::Hint("agentic".to_string()), + "test_agent", + None, // no config loaded + parent.clone(), + "real-claude-id".to_string(), + ); + assert!( + arc_ptr_eq(&parent, &resolved_provider), + "config-load failure must fall back to parent provider, not synthesize a new one" + ); + assert_eq!( + resolved_model, "real-claude-id", + "model must be parent's current model — NOT '{{workload}}-v1'" + ); +} + +#[test] +fn resolve_subagent_provider_hint_with_config_routes_via_factory() { + // The Hint arm with a real config takes the workload-factory path. + // We don't assert the *resulting* provider identity here (the + // factory may return a fresh OpenHuman backend or whatever + // primary_cloud resolves to), but we DO assert the resolved model + // matches the workload's configured exact id — not the legacy + // `{workload}-v1` synthesis. + use crate::openhuman::config::Config; + let mut config = Config::default(); + // Route `agentic` to OpenHuman backend explicitly. The backend + // returns the configured default_model, which we set to a known + // string so the assertion is meaningful. + config.agentic_provider = Some("openhuman".to_string()); + config.default_model = Some("agentic-specific-model".to_string()); + + let parent: Arc = ScriptedProvider::new(vec![]); + let (_resolved_provider, resolved_model) = super::resolve_subagent_provider( + &ModelSpec::Hint("agentic".to_string()), + "test_agent", + Some(&config), + parent.clone(), + "parent-model-ignored-on-hint".to_string(), + ); + assert_eq!( + resolved_model, "agentic-specific-model", + "Hint must use the factory-resolved exact model, not synthesise `agentic-v1` \ + and not fall back to parent's model" + ); +} + +#[test] +fn resolve_subagent_provider_hint_falls_back_on_factory_error() { + // An invalid provider string in the workload config (e.g. a typo + // like "groq:something") makes the factory return Err. The Hint + // arm must fall back to the parent provider rather than + // propagating — sub-agent execution should degrade to "use what + // the parent uses" not crash entirely. + use crate::openhuman::config::Config; + let mut config = Config::default(); + config.agentic_provider = Some("groq:not-a-real-prefix".to_string()); + + let parent: Arc = ScriptedProvider::new(vec![]); + let (resolved_provider, resolved_model) = super::resolve_subagent_provider( + &ModelSpec::Hint("agentic".to_string()), + "test_agent", + Some(&config), + parent.clone(), + "fallback-model".to_string(), + ); + assert!( + arc_ptr_eq(&parent, &resolved_provider), + "factory error must fall back to parent provider" + ); + assert_eq!(resolved_model, "fallback-model"); +} + // ── Probe regression tests (#1710 Wave 2) ────────────────────────── // // `user_is_signed_in_to_composio` replaces the legacy diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index 7d30dc37e..46e6c6055 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -33,21 +33,47 @@ pub fn publish_web_channel_event(event: WebChannelEvent) { let _ = EVENT_BUS.send(event); } +/// All inputs that the cached `SessionEntry`'s `Agent` was built from, +/// captured at build time. The cache-hit predicate is a single +/// `entry.fingerprint == current_fingerprint` comparison — pulling the +/// fields into a named struct (instead of inlining four `&&`s) makes +/// the predicate testable in isolation and makes "what invalidates the +/// cache?" answerable in one place. +/// +/// Adding a new dimension that should force a rebuild = add a field +/// here and populate it both at insert time and at the call-site +/// fingerprint construction. +#[derive(PartialEq, Debug, Clone)] +struct SessionCacheFingerprint { + /// Per-message `model_override` (clients can override the model + /// for an individual chat call). + model_override: Option, + /// Per-message `temperature` override (same channel as + /// `model_override`). + temperature: Option, + /// Which agent definition was used to build `agent`. Without this + /// the cache hit short-circuited the welcome→orchestrator routing + /// fix — the very first turn picked welcome, welcome called + /// `complete_onboarding(complete)`, the flag flipped, but the next + /// turn read the cached welcome agent instead of invoking + /// `build_session_agent` to re-resolve the target. + target_agent_id: String, + /// `reasoning_provider` config value at build time. The cached + /// agent's provider was constructed from this string via + /// `create_chat_provider("reasoning", &config)`; without it the + /// next turn would reuse the stale provider after a Settings → + /// AI → LLM routing change until the cache evicts. + /// + /// Other workloads (`agentic`, `coding`, `memory`, …) are read + /// per call inside the factory, so they don't need to participate + /// in cache invalidation — only the orchestrator's reasoning + /// provider is bound to the cached `Agent`. + reasoning_provider: Option, +} + struct SessionEntry { agent: Agent, - model_override: Option, - temperature: Option, - /// Which agent definition was used to build `agent`. Recorded so - /// that the cache hit predicate in `run_chat_task` can detect - /// when the routing decision (welcome vs orchestrator) flips - /// between turns and rebuild instead of reusing a stale agent. - /// Without this field the cache hit short-circuited the routing - /// fix from Commit 8 — the very first turn picked welcome, - /// welcome called `complete_onboarding(complete)`, the flag - /// flipped, but the next turn read the cached welcome agent - /// instead of invoking `build_session_agent` to re-resolve the - /// target. - target_agent_id: String, + fingerprint: SessionCacheFingerprint, } /// Decide which agent definition this turn should run with. @@ -624,6 +650,12 @@ async fn run_chat_task( // turn from the cached welcome agent — the cache hit predicate // didn't know about the routing decision before Commit 13. let target_agent_id = pick_target_agent_id(&config).to_string(); + let current_fp = SessionCacheFingerprint { + model_override: model_override.clone(), + temperature, + target_agent_id: target_agent_id.clone(), + reasoning_provider: config.reasoning_provider.clone(), + }; let prior = { let mut sessions = THREAD_SESSIONS.lock().await; @@ -631,11 +663,7 @@ async fn run_chat_task( }; let (mut agent, was_built_fresh) = match prior { - Some(entry) - if entry.model_override == model_override - && entry.temperature == temperature - && entry.target_agent_id == target_agent_id => - { + Some(entry) if entry.fingerprint == current_fp => { log::info!( "[web-channel] reusing cached session agent id={} for client={} thread={}", target_agent_id, @@ -646,9 +674,13 @@ async fn run_chat_task( } Some(prior_entry) => { log::info!( - "[web-channel] cache miss — rebuilding session agent (was id={}, now id={}) for client={} thread={}", - prior_entry.target_agent_id, + "[web-channel] cache miss — rebuilding session agent \ + (was id={}, now id={}; prior_reasoning_provider={:?}, now={:?}) \ + for client={} thread={}", + prior_entry.fingerprint.target_agent_id, target_agent_id, + prior_entry.fingerprint.reasoning_provider, + current_fp.reasoning_provider, client_id, thread_id ); @@ -781,9 +813,7 @@ async fn run_chat_task( map_key, SessionEntry { agent, - model_override, - temperature, - target_agent_id, + fingerprint: current_fp, }, ); } diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index 45d9f701f..817afb11f 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -301,3 +301,94 @@ fn json_output_is_required_json_field() { assert!(f.required); assert!(matches!(f.ty, TypeSchema::Json)); } + +// ── SessionCacheFingerprint (thread-session cache invalidation) ─────── + +use super::SessionCacheFingerprint; + +fn fp( + model_override: Option<&str>, + temperature: Option, + target: &str, + reasoning_provider: Option<&str>, +) -> SessionCacheFingerprint { + SessionCacheFingerprint { + model_override: model_override.map(String::from), + temperature, + target_agent_id: target.to_string(), + reasoning_provider: reasoning_provider.map(String::from), + } +} + +#[test] +fn fingerprint_identical_inputs_are_cache_hit() { + let a = fp( + None, + None, + "orchestrator", + Some("anthropic:claude-sonnet-4-6"), + ); + let b = fp( + None, + None, + "orchestrator", + Some("anthropic:claude-sonnet-4-6"), + ); + assert_eq!( + a, b, + "identical fingerprints must compare equal (cache hit)" + ); +} + +#[test] +fn fingerprint_reasoning_provider_change_forces_rebuild() { + // The whole point of adding reasoning_provider to the fingerprint: + // changing the workload routing in Settings → AI → LLM mid-thread + // must invalidate the cached agent so the next turn rebuilds with + // the new provider. + let warm = fp(None, None, "orchestrator", Some("cloud")); + let after_settings_change = fp( + None, + None, + "orchestrator", + Some("anthropic:claude-sonnet-4-6"), + ); + assert_ne!( + warm, after_settings_change, + "reasoning_provider change must produce a different fingerprint (cache miss → rebuild)" + ); +} + +#[test] +fn fingerprint_reasoning_provider_none_vs_some_differs() { + // Unset → explicitly-set is also a routing change (None resolves to + // 'cloud' via the factory, an explicit value does not). + let unset = fp(None, None, "orchestrator", None); + let set = fp(None, None, "orchestrator", Some("cloud")); + assert_ne!(unset, set); +} + +#[test] +fn fingerprint_target_agent_flip_forces_rebuild() { + // welcome → orchestrator routing flip (onboarding completion) must + // still invalidate — regression guard for the original cache bug + // this struct also protects. + let welcome = fp(None, None, "welcome", Some("cloud")); + let orchestrator = fp(None, None, "orchestrator", Some("cloud")); + assert_ne!(welcome, orchestrator); +} + +#[test] +fn fingerprint_model_override_and_temperature_participate() { + let base = fp(None, None, "orchestrator", Some("cloud")); + assert_ne!( + base, + fp(Some("gpt-4o"), None, "orchestrator", Some("cloud")), + "per-message model_override must invalidate" + ); + assert_ne!( + base, + fp(None, Some(0.9), "orchestrator", Some("cloud")), + "per-message temperature must invalidate" + ); +} diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 0b9d106bb..ec28831f9 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -185,9 +185,10 @@ pub async fn start_channels(config: Config) -> Result<()> { .clone() .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into()); let temperature = config.default_temperature; + let local_embedding = config.workload_local_model("embeddings"); let mem: Arc = Arc::from(memory::create_memory_with_local_ai( &config.memory, - &config.local_ai, + local_embedding.as_deref(), &[], Some(&config.storage.provider.config), &config.workspace_dir, diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 4a5f18f25..a01fb4858 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -181,6 +181,23 @@ pub struct ModelSettingsPatch { /// router picks per-task models on its own). Leave `None` to keep the /// current routes untouched. pub model_routes: Option>, + /// When `Some`, REPLACES the entire `config.cloud_providers` array with + /// the supplied entries (each lacking the API key — those live in + /// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`]). + /// Pass `Some(vec![])` to clear all third-party cloud providers. + pub cloud_providers: + Option>, + /// Id of the `cloud_providers` entry used when a workload routes to + /// `"cloud"`. Empty string clears (factory falls back to OpenHuman). + pub primary_cloud: Option, + pub reasoning_provider: Option, + pub agentic_provider: Option, + pub coding_provider: Option, + pub memory_provider: Option, + pub embeddings_provider: Option, + pub heartbeat_provider: Option, + pub learning_provider: Option, + pub subconscious_provider: Option, } #[derive(Debug, Clone, Default)] @@ -236,6 +253,11 @@ pub struct MeetSettingsPatch { #[derive(Debug, Clone, Default)] pub struct LocalAiSettingsPatch { pub runtime_enabled: Option, + /// MVP opt-in marker. Bootstrap hard-overrides status to "disabled" + /// when this is `false`, regardless of `runtime_enabled`. The unified + /// AI panel ties the two together (both flip on enable, both flip + /// off on disable) so a single toggle gives the user the obvious + /// behaviour without needing to apply a preset first. pub opt_in_confirmed: Option, pub provider: Option, pub base_url: Option, @@ -315,6 +337,51 @@ pub async fn apply_model_settings( // (or an empty vec when switching back to the OpenHuman in-built router). config.model_routes = routes; } + if let Some(providers) = update.cloud_providers { + config.cloud_providers = providers; + } + if let Some(primary) = update.primary_cloud { + config.primary_cloud = if primary.trim().is_empty() { + None + } else { + Some(primary) + }; + } + + // Per-workload provider strings. Empty / blank → None (factory default). + let normalise_provider = |s: String| -> Option { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }; + if let Some(s) = update.reasoning_provider { + config.reasoning_provider = normalise_provider(s); + } + if let Some(s) = update.agentic_provider { + config.agentic_provider = normalise_provider(s); + } + if let Some(s) = update.coding_provider { + config.coding_provider = normalise_provider(s); + } + if let Some(s) = update.memory_provider { + config.memory_provider = normalise_provider(s); + } + if let Some(s) = update.embeddings_provider { + config.embeddings_provider = normalise_provider(s); + } + if let Some(s) = update.heartbeat_provider { + config.heartbeat_provider = normalise_provider(s); + } + if let Some(s) = update.learning_provider { + config.learning_provider = normalise_provider(s); + } + if let Some(s) = update.subconscious_provider { + config.subconscious_provider = normalise_provider(s); + } + config.save().await.map_err(|e| e.to_string())?; let snapshot = snapshot_config_json(config)?; Ok(RpcOutcome::new( diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index c43513418..a385baef7 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -225,6 +225,7 @@ async fn apply_model_settings_updates_fields_and_persists_snapshot() { default_model: Some("gpt-4o".into()), default_temperature: Some(0.25), model_routes: None, + ..Default::default() }; let outcome = apply_model_settings(&mut cfg, patch).await.expect("apply"); assert_eq!(cfg.api_url.as_deref(), Some("https://api.example.test")); @@ -249,6 +250,7 @@ async fn apply_model_settings_stores_api_key_and_clears_when_empty() { default_model: Some("gpt-4o-mini".into()), default_temperature: None, model_routes: None, + ..Default::default() }; let _ = apply_model_settings(&mut cfg, set).await.expect("set"); assert_eq!(cfg.api_key.as_deref(), Some("sk-test-1234")); @@ -260,6 +262,7 @@ async fn apply_model_settings_stores_api_key_and_clears_when_empty() { default_model: None, default_temperature: None, model_routes: None, + ..Default::default() }; let _ = apply_model_settings(&mut cfg, clear).await.expect("clear"); assert!(cfg.api_key.is_none()); @@ -292,6 +295,7 @@ async fn apply_model_settings_replaces_model_routes_when_some_and_keeps_when_non model: "gpt-4o".into(), }, ]), + ..Default::default() }; let _ = apply_model_settings(&mut cfg, set_routes) .await @@ -307,6 +311,7 @@ async fn apply_model_settings_replaces_model_routes_when_some_and_keeps_when_non default_model: None, default_temperature: None, model_routes: None, + ..Default::default() }; let _ = apply_model_settings(&mut cfg, touch_other) .await @@ -322,6 +327,7 @@ async fn apply_model_settings_replaces_model_routes_when_some_and_keeps_when_non default_model: None, default_temperature: None, model_routes: Some(vec![]), + ..Default::default() }; let _ = apply_model_settings(&mut cfg, clear_routes) .await @@ -341,6 +347,7 @@ async fn apply_model_settings_empty_strings_clear_optional_fields() { default_model: Some("".into()), default_temperature: None, model_routes: None, + ..Default::default() }; let _ = apply_model_settings(&mut cfg, patch).await.expect("apply"); assert!(cfg.api_url.is_none()); diff --git a/src/openhuman/config/schema/cloud_providers.rs b/src/openhuman/config/schema/cloud_providers.rs new file mode 100644 index 000000000..7d71bb7d2 --- /dev/null +++ b/src/openhuman/config/schema/cloud_providers.rs @@ -0,0 +1,121 @@ +//! Cloud provider credential schema. +//! +//! Each entry in `Config::cloud_providers` represents one configured LLM +//! backend (OpenHuman, OpenAI, Anthropic, OpenRouter, or a custom +//! OpenAI-compatible endpoint). The factory in +//! `crate::openhuman::providers::factory` resolves workload-to-provider +//! strings against this list at runtime. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Discriminator for a cloud provider entry. +/// +/// Wire format is lowercase (e.g. `"openai"`). Dictates the default endpoint +/// and the auth header style used by the chat factory. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CloudProviderType { + Openhuman, + Openai, + Anthropic, + Openrouter, + Custom, +} + +impl CloudProviderType { + /// Well-known default base URL for each provider type. + pub fn default_endpoint(&self) -> &'static str { + match self { + Self::Openhuman => "https://api.openhuman.ai/v1", + Self::Openai => "https://api.openai.com/v1", + Self::Anthropic => "https://api.anthropic.com/v1", + Self::Openrouter => "https://openrouter.ai/api/v1", + Self::Custom => "", + } + } + + /// Human-readable label used in logs and error messages. + pub fn label(&self) -> &'static str { + match self { + Self::Openhuman => "OpenHuman", + Self::Openai => "OpenAI", + Self::Anthropic => "Anthropic", + Self::Openrouter => "OpenRouter", + Self::Custom => "Custom", + } + } + + /// Lowercase wire-format string (matches JSON serialisation). + pub fn as_str(&self) -> &'static str { + match self { + Self::Openhuman => "openhuman", + Self::Openai => "openai", + Self::Anthropic => "anthropic", + Self::Openrouter => "openrouter", + Self::Custom => "custom", + } + } +} + +/// Endpoint config for one cloud LLM provider. +/// +/// **Note on secrets**: API keys are NOT stored on this struct. They live in +/// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`], +/// encrypted at rest under the workspace's `.secret_key`. The factory looks +/// up the bearer token at call time by `provider_type.as_str()` (e.g. +/// `"openai"`, `"anthropic"`), mirroring how the Composio integration stores +/// its key under `"composio-direct:default"`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(default)] +pub struct CloudProviderCreds { + /// Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in the UI. + /// Generated once by [`generate_provider_id`] and never changes. + pub id: String, + /// Provider type determines default endpoint, the auth-profile lookup + /// key, and the human-readable label. + #[serde(rename = "type")] + pub r#type: CloudProviderType, + /// OpenAI-compatible base URL (`/v1/chat/completions` is appended). + pub endpoint: String, + /// Default model id sent to this provider when no per-workload override + /// is configured. + pub default_model: String, +} + +impl Default for CloudProviderCreds { + fn default() -> Self { + Self { + id: String::new(), + r#type: CloudProviderType::Openhuman, + endpoint: CloudProviderType::Openhuman.default_endpoint().to_string(), + default_model: "reasoning-v1".to_string(), + } + } +} + +/// Generate a short opaque id for a new provider entry. +/// +/// Format: `"p__<5 random alphanumerics>"`, e.g. `"p_openai_a8c3f"`. +/// The random suffix is not cryptographically strong — it only needs to be +/// unique within a single user's config file. +pub fn generate_provider_id(t: &CloudProviderType) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + // Cheap pseudo-random from timestamp nanoseconds — adequate for local + // config uniqueness without pulling in a PRNG crate. + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut suffix = String::with_capacity(5); + let mut seed = nanos as usize; + for _ in 0..5 { + suffix.push(chars[seed % chars.len()] as char); + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + seed = (seed >> 33) ^ seed; + } + format!("p_{}_{}", t.as_str(), suffix) +} diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs index a1a9c4d31..4b039f48d 100644 --- a/src/openhuman/config/schema/local_ai.rs +++ b/src/openhuman/config/schema/local_ai.rs @@ -232,22 +232,29 @@ impl LocalAiConfig { self.runtime_enabled } - /// Use the local model for embedding generation. + /// **Deprecated** — read from `Config::workload_uses_local("embeddings")` + /// instead. This helper only consults the legacy `usage.*` booleans, which + /// are no longer the source of truth after the unified AI settings + /// migration (schema_version >= 2). + #[deprecated(note = "Use Config::workload_uses_local(\"embeddings\")")] pub fn use_local_for_embeddings(&self) -> bool { self.runtime_enabled && self.usage.embeddings } - /// Use the local model inside the heartbeat loop. + /// **Deprecated** — read from `Config::workload_uses_local("heartbeat")`. + #[deprecated(note = "Use Config::workload_uses_local(\"heartbeat\")")] pub fn use_local_for_heartbeat(&self) -> bool { self.runtime_enabled && self.usage.heartbeat } - /// Use the local model for learning/reflection passes. + /// **Deprecated** — read from `Config::workload_uses_local("learning")`. + #[deprecated(note = "Use Config::workload_uses_local(\"learning\")")] pub fn use_local_for_learning(&self) -> bool { self.runtime_enabled && self.usage.learning_reflection } - /// Use the local model for subconscious evaluation and execution. + /// **Deprecated** — read from `Config::workload_uses_local("subconscious")`. + #[deprecated(note = "Use Config::workload_uses_local(\"subconscious\")")] pub fn use_local_for_subconscious(&self) -> bool { self.runtime_enabled && self.usage.subconscious } diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 846b52797..40eb8eb56 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -2,6 +2,8 @@ //! //! Split into submodules; this module re-exports the main `Config` and all public types. +pub mod cloud_providers; +pub use cloud_providers::{generate_provider_id, CloudProviderCreds, CloudProviderType}; mod accessibility; mod agent; mod autocomplete; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index d9a6bbc5e..6e63bd4c6 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -164,6 +164,64 @@ pub struct Config { #[serde(default)] pub local_ai: LocalAiConfig, + // ── Unified AI provider routing ────────────────────────────────────────── + // + // Provider-string grammar (consumed by `providers::factory`): + // + // "cloud" → resolves to `primary_cloud`; if primary is + // openhuman, behaves identically to "openhuman" + // "openhuman" → OpenHuman backend (api_url + api_key session JWT) + // "openai:" → look up cloud_providers entry of type=openai; + // build OpenAiCompatibleProvider with Bearer auth + // "anthropic:" → type=anthropic; Bearer auth on the compat endpoint + // "openrouter:" → type=openrouter; Bearer auth + // "custom:" → type=custom; Bearer auth + // "ollama:" → local Ollama at config.local_ai.base_url + // + // Per-workload fields default to None, which the factory treats as "cloud". + // Changing `primary_cloud` instantly re-routes every "cloud" workload. + /// Registered cloud providers. Index 0 is always the built-in OpenHuman + /// entry; additional entries are user-added third-party backends. + #[serde(default)] + pub cloud_providers: Vec, + + /// Id of the `cloud_providers` entry that "cloud" and "primary" resolve to. + /// When `None`, the factory falls back to the OpenHuman entry. + #[serde(default)] + pub primary_cloud: Option, + + /// Provider string for the main reasoning / chat workload. + #[serde(default)] + pub reasoning_provider: Option, + + /// Provider string for sub-agent execution and tool-loop workloads. + #[serde(default)] + pub agentic_provider: Option, + + /// Provider string for code generation and refactor workloads. + #[serde(default)] + pub coding_provider: Option, + + /// Provider string for memory-tree extract + summarise workloads. + #[serde(default)] + pub memory_provider: Option, + + /// Provider string for embedding generation. + #[serde(default)] + pub embeddings_provider: Option, + + /// Provider string for the heartbeat background-reasoning loop. + #[serde(default)] + pub heartbeat_provider: Option, + + /// Provider string for learning / reflection passes. + #[serde(default)] + pub learning_provider: Option, + + /// Provider string for subconscious evaluation and drift checks. + #[serde(default)] + pub subconscious_provider: Option, + /// Node.js managed runtime configuration (skills that need `node`/`npm`). #[serde(default)] pub node: NodeConfig, @@ -269,6 +327,47 @@ impl Config { .clone() .unwrap_or_else(|| self.workspace_dir.join("memory_tree").join("content")) } + + /// Read the per-workload provider string and return the local model id + /// when the workload is routed to Ollama. + /// + /// Recognised workload names: + /// `"reasoning"`, `"agentic"`, `"coding"`, `"memory"`, `"embeddings"`, + /// `"heartbeat"`, `"learning"`, `"subconscious"`. + /// + /// Returns `None` when the provider isn't `"ollama:"` (including + /// when the field is unset, blank, `"cloud"`, or any other prefix). + /// This is the single source of truth for "is this workload local?" — + /// callers MUST NOT consult the legacy `local_ai.usage.*` booleans or + /// `memory_tree.llm_backend`. Those fields are deprecated zombies kept + /// for migration only. + pub fn workload_local_model(&self, workload: &str) -> Option { + let raw = match workload { + "reasoning" => self.reasoning_provider.as_deref(), + "agentic" => self.agentic_provider.as_deref(), + "coding" => self.coding_provider.as_deref(), + "memory" => self.memory_provider.as_deref(), + "embeddings" => self.embeddings_provider.as_deref(), + "heartbeat" => self.heartbeat_provider.as_deref(), + "learning" => self.learning_provider.as_deref(), + "subconscious" => self.subconscious_provider.as_deref(), + _ => None, + }?; + let trimmed = raw.trim(); + let model = trimmed.strip_prefix("ollama:")?.trim(); + if model.is_empty() { + None + } else { + Some(model.to_string()) + } + } + + /// `true` when `workload_local_model` returns `Some` for the named + /// workload. Convenience wrapper for the common "do I dispatch + /// locally?" branch. + pub fn workload_uses_local(&self, workload: &str) -> bool { + self.workload_local_model(workload).is_some() + } } impl Default for Config { @@ -328,6 +427,16 @@ impl Default for Config { computer_control: ComputerControlConfig::default(), agents: HashMap::new(), local_ai: LocalAiConfig::default(), + cloud_providers: Vec::new(), + primary_cloud: None, + reasoning_provider: None, + agentic_provider: None, + coding_provider: None, + memory_provider: None, + embeddings_provider: None, + heartbeat_provider: None, + learning_provider: None, + subconscious_provider: None, node: NodeConfig::default(), voice_server: VoiceServerConfig::default(), integrations: IntegrationsConfig::default(), diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 3d4dc2ffb..85a5266a7 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -15,6 +15,16 @@ struct ModelRouteUpdate { model: String, } +#[derive(Debug, Deserialize)] +struct CloudProviderUpdate { + /// Opaque stable id. Empty / missing → server generates a new id. + id: Option, + /// "openhuman" | "openai" | "anthropic" | "openrouter" | "custom" + r#type: String, + endpoint: String, + default_model: Option, +} + #[derive(Debug, Deserialize)] struct ModelSettingsUpdate { /// OpenHuman product backend URL. Used for auth, billing, voice, and @@ -38,6 +48,19 @@ struct ModelSettingsUpdate { /// picks per-task models on its own). Omit to leave existing routes /// untouched. model_routes: Option>, + /// When present, REPLACES `config.cloud_providers` wholesale. The keys + /// themselves live in `auth-profiles.json` via + /// `cloud_provider_set_key` — they are NOT carried here. + cloud_providers: Option>, + primary_cloud: Option, + reasoning_provider: Option, + agentic_provider: Option, + coding_provider: Option, + memory_provider: Option, + embeddings_provider: Option, + heartbeat_provider: Option, + learning_provider: Option, + subconscious_provider: Option, } #[derive(Debug, Deserialize)] @@ -89,6 +112,10 @@ struct MeetSettingsUpdate { #[derive(Debug, Deserialize)] struct LocalAiSettingsUpdate { runtime_enabled: Option, + /// MVP opt-in marker. Tied to `runtime_enabled` from the unified AI + /// panel toggle (both flip on enable, both flip off on disable) so + /// the user gets local AI working with a single click instead of + /// having to also apply a tier preset. opt_in_confirmed: Option, provider: Option, base_url: Option, @@ -372,6 +399,21 @@ pub fn schemas(function: &str) -> ControllerSchema { comment: "Optional list of {hint, model} pairs mapping task hints (reasoning, agentic, coding, summarization) to provider-specific model ids. Replaces config.model_routes wholesale; send [] to clear (e.g. when switching back to the OpenHuman built-in router).", required: false, }, + FieldSchema { + name: "cloud_providers", + ty: TypeSchema::Option(Box::new(TypeSchema::Json)), + comment: "Optional list of cloud provider entries {id, type, endpoint, default_model}. API keys are stored separately via cloud_provider_set_key. Replaces config.cloud_providers wholesale.", + required: false, + }, + optional_string("primary_cloud", "id of the cloud_providers entry used when a workload routes to 'cloud'. Empty string clears."), + optional_string("reasoning_provider", "Provider string for the main reasoning workload (e.g. 'cloud', 'ollama:llama3.1:8b', 'openai:gpt-4o')."), + optional_string("agentic_provider", "Provider string for sub-agent / tool-loop workloads."), + optional_string("coding_provider", "Provider string for code-generation workloads."), + optional_string("memory_provider", "Provider string for memory-tree extract + summarise."), + optional_string("embeddings_provider", "Provider string for embedding generation."), + optional_string("heartbeat_provider", "Provider string for the heartbeat background-reasoning loop."), + optional_string("learning_provider", "Provider string for learning / reflection passes."), + optional_string("subconscious_provider", "Provider string for subconscious evaluation."), ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, @@ -471,7 +513,9 @@ pub fn schemas(function: &str) -> ControllerSchema { ), optional_bool( "opt_in_confirmed", - "Explicit local AI opt-in marker required by bootstrap.", + "MVP opt-in marker. Bootstrap hard-overrides to disabled when this is false, \ + regardless of `runtime_enabled`. Set in tandem with `runtime_enabled` from the \ + unified AI panel.", ), optional_string( "provider", @@ -821,10 +865,29 @@ fn handle_get_client_config(_params: Map) -> ControllerFuture { .iter() .map(|r| serde_json::json!({ "hint": r.hint, "model": r.model })) .collect(); + + // Surface the new unified AI routing surface (cloud_providers + the + // 8 per-workload provider strings + primary_cloud) so the AI + // settings panel doesn't have to round-trip the full Config blob. + let cloud_providers: Vec = config + .cloud_providers + .iter() + .map(|c| { + serde_json::json!({ + "id": c.id, + "type": c.r#type.as_str(), + "endpoint": c.endpoint, + "default_model": c.default_model, + }) + }) + .collect(); + log::debug!( - "[config][rpc] get_client_config ok api_key_set={} model_routes_count={}", + "[config][rpc] get_client_config ok api_key_set={} model_routes_count={} \ + cloud_providers_count={}", api_key_set, - model_routes.len() + model_routes.len(), + cloud_providers.len(), ); to_json(RpcOutcome::new( serde_json::json!({ @@ -834,6 +897,16 @@ fn handle_get_client_config(_params: Map) -> ControllerFuture { "app_version": app_version, "api_key_set": api_key_set, "model_routes": model_routes, + "cloud_providers": cloud_providers, + "primary_cloud": config.primary_cloud, + "reasoning_provider": config.reasoning_provider, + "agentic_provider": config.agentic_provider, + "coding_provider": config.coding_provider, + "memory_provider": config.memory_provider, + "embeddings_provider": config.embeddings_provider, + "heartbeat_provider": config.heartbeat_provider, + "learning_provider": config.learning_provider, + "subconscious_provider": config.subconscious_provider, }), vec!["client config read".to_string()], )) @@ -858,6 +931,53 @@ fn handle_update_model_settings(params: Map) -> ControllerFuture }) .collect() }), + cloud_providers: update + .cloud_providers + .map(|entries| { + use crate::openhuman::config::schema::cloud_providers::{ + generate_provider_id, CloudProviderCreds, CloudProviderType, + }; + entries + .into_iter() + .map(|e| { + let r#type = match e.r#type.to_ascii_lowercase().as_str() { + "openhuman" => CloudProviderType::Openhuman, + "openai" => CloudProviderType::Openai, + "anthropic" => CloudProviderType::Anthropic, + "openrouter" => CloudProviderType::Openrouter, + "custom" => CloudProviderType::Custom, + other => { + return Err(format!( + "unknown cloud provider type '{}'; \ + valid values: openhuman, openai, anthropic, \ + openrouter, custom", + other + )) + } + }; + let id = + e.id.filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| generate_provider_id(&r#type)); + let default_model = e.default_model.unwrap_or_default(); + Ok(CloudProviderCreds { + id, + r#type, + endpoint: e.endpoint, + default_model, + }) + }) + .collect::, String>>() + }) + .transpose()?, + primary_cloud: update.primary_cloud, + reasoning_provider: update.reasoning_provider, + agentic_provider: update.agentic_provider, + coding_provider: update.coding_provider, + memory_provider: update.memory_provider, + embeddings_provider: update.embeddings_provider, + heartbeat_provider: update.heartbeat_provider, + learning_provider: update.learning_provider, + subconscious_provider: update.subconscious_provider, }; to_json(config_rpc::load_and_apply_model_settings(patch).await?) }) diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index cb4dd715f..2c94b9d1a 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -233,15 +233,60 @@ impl AuthProfilesStore { fn load_locked(&self) -> Result { let mut persisted = self.read_persisted_locked()?; let mut migrated = false; + let mut dropped_ids: Vec = Vec::new(); let mut profiles = BTreeMap::new(); for (id, p) in &mut persisted.profiles { - let (access_token, access_migrated) = - self.decrypt_optional(p.access_token.as_deref())?; - let (refresh_token, refresh_migrated) = - self.decrypt_optional(p.refresh_token.as_deref())?; - let (id_token, id_migrated) = self.decrypt_optional(p.id_token.as_deref())?; - let (token, token_migrated) = self.decrypt_optional(p.token.as_deref())?; + // Decrypt all four optional secret fields. A decryption + // failure here means the secret was encrypted with a + // `.secret_key` that no longer exists (manual deletion, + // partial workspace restore, key rotation across machines). + // The profile is unrecoverable — drop it from the store + // instead of poisoning every reader. The user falls back + // to a clean "logged out" state and the next login + // re-encrypts cleanly under the current key. + let decrypted = (|| -> Result<_> { + let (access_token, access_migrated) = + self.decrypt_optional(p.access_token.as_deref())?; + let (refresh_token, refresh_migrated) = + self.decrypt_optional(p.refresh_token.as_deref())?; + let (id_token, id_migrated) = self.decrypt_optional(p.id_token.as_deref())?; + let (token, token_migrated) = self.decrypt_optional(p.token.as_deref())?; + Ok(( + access_token, + access_migrated, + refresh_token, + refresh_migrated, + id_token, + id_migrated, + token, + token_migrated, + )) + })(); + + let ( + access_token, + access_migrated, + refresh_token, + refresh_migrated, + id_token, + id_migrated, + token, + token_migrated, + ) = match decrypted { + Ok(v) => v, + Err(e) => { + log::warn!( + "[auth] dropping unrecoverable profile provider={}: {e}. \ + Most likely cause: .secret_key was regenerated after this profile \ + was stored. The store will be rewritten without this entry; \ + re-authenticate to restore the session.", + p.provider + ); + dropped_ids.push(id.clone()); + continue; + } + }; if let Some(value) = access_migrated { p.access_token = Some(value); @@ -296,7 +341,25 @@ impl AuthProfilesStore { ); } - if migrated { + // Purge dropped profiles from the on-disk persisted view AND + // any `active_profiles` pointers that referenced them, so the + // next read returns a clean "no active session" state. + if !dropped_ids.is_empty() { + for id in &dropped_ids { + persisted.profiles.remove(id); + } + persisted + .active_profiles + .retain(|_, profile_id| !dropped_ids.contains(profile_id)); + persisted.updated_at = Utc::now().to_rfc3339(); + log::warn!( + "[auth] purged {} unrecoverable profile(s) from store at {} \ + (provider list redacted to avoid leaking PII)", + dropped_ids.len(), + self.path.display(), + ); + self.write_persisted_locked(&persisted)?; + } else if migrated { self.write_persisted_locked(&persisted)?; } diff --git a/src/openhuman/credentials/profiles_tests.rs b/src/openhuman/credentials/profiles_tests.rs index b522d6085..a17688542 100644 --- a/src/openhuman/credentials/profiles_tests.rs +++ b/src/openhuman/credentials/profiles_tests.rs @@ -154,6 +154,68 @@ fn corrupt_store_is_quarantined_and_reset() { assert_eq!(reloaded.profiles.len(), 1); } +/// When the encrypted-secrets key file has rotated between writes and reads +/// (e.g. `.secret_key` got regenerated underneath an existing +/// auth-profiles.json — observed when a workspace gets partially restored +/// or when OPENHUMAN_WORKSPACE points at a half-populated test dir), the +/// store must silently drop the unrecoverable profile and rewrite the +/// file. Without this, `app_state_snapshot` polls infinite-loop on +/// "Decryption failed — wrong key or tampered data" and the user can +/// never log in cleanly because every read pre-empts before reaching +/// the "no profile" code path. +#[test] +fn load_drops_profiles_whose_decryption_fails_under_rotated_key() { + // The SecretStore caches keys by canonicalised path in a process-wide + // OnceCell. Use a fresh temp dir per test so we don't pick up a + // sibling test's cached key. + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), true); + + // Seed two profiles. One ("doomed") will be made unrecoverable by + // rewriting the encrypted token under a new key; the other + // ("plain-fine") uses kind=Token with a plaintext token that the + // legacy `enc:` / plaintext branch decrypts trivially, so even + // after key rotation it survives. + let doomed = AuthProfile::new_token("app-session", "default", "real-jwt-payload".into()); + store.upsert_profile(doomed.clone(), true).unwrap(); + + // Manually corrupt the persisted token: rewrite it as a syntactically + // valid enc2: hex blob that the *current* key cannot decrypt. + // (Easier than rotating the key file because the SecretStore caches + // by canonical path.) + let path = store.path().to_path_buf(); + let mut data: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let profile_id = doomed.id.clone(); + data["profiles"][&profile_id]["token"] = serde_json::Value::String( + // 12-byte nonce + 32 bytes of "ciphertext" that won't authenticate + // under any random key — hex-encoded, prefixed with enc2:. + "enc2:000102030405060708090a0b\ + deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .to_string(), + ); + std::fs::write(&path, serde_json::to_string_pretty(&data).unwrap()).unwrap(); + + // First load: should silently drop the doomed profile rather than + // bubbling the decrypt error and breaking every poll. + let loaded = store.load().expect( + "load must succeed by dropping unrecoverable profiles, not by propagating decrypt errors", + ); + assert!( + !loaded.profiles.contains_key(&profile_id), + "doomed profile must be purged from the in-memory view" + ); + assert!( + !loaded.active_profiles.values().any(|v| v == &profile_id), + "active_profiles pointer to the doomed profile must also be cleared" + ); + + // Subsequent load: file was rewritten without the bad profile, so + // there's nothing to drop on the second pass — same clean state. + let loaded2 = store.load().unwrap(); + assert!(!loaded2.profiles.contains_key(&profile_id)); +} + #[test] fn remove_nonexistent_profile_returns_false() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 48c8b7ddf..3a90ab359 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -235,11 +235,53 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< max_iterations = def.max_iterations, "[cron] applying agent definition overrides" ); + // Resolve the agent definition's model spec into an + // exact model id. `ModelSpec::resolve` synthesises + // `{hint}-v1` for Hint specs, which only the OpenHuman + // backend understands as a tier hint — Anthropic and + // every other provider 404 on names like `agentic-v1`. + // Route Hint specs through the per-workload factory so + // we get the exact model the user has configured for + // that workload, regardless of which provider it lives + // on. Inherit / Exact keep their literal `resolve()` + // behaviour because neither relies on the `-v1` trick. + use crate::openhuman::agent::harness::definition::ModelSpec; let fallback_model = effective .default_model .clone() .unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.to_string()); - effective.default_model = Some(def.model.resolve(&fallback_model)); + let resolved_model = match &def.model { + ModelSpec::Hint(workload) => { + match crate::openhuman::providers::create_chat_provider( + workload, &effective, + ) { + Ok((_, m)) => { + tracing::debug!( + job_id = %job.id, + agent_id = %agent_id, + workload = %workload, + model = %m, + "[cron] resolved Hint via workload factory" + ); + m + } + Err(e) => { + tracing::warn!( + job_id = %job.id, + agent_id = %agent_id, + workload = %workload, + error = %e, + fallback = %fallback_model, + "[cron] workload factory failed; using fallback model" + ); + fallback_model.clone() + } + } + } + ModelSpec::Inherit => fallback_model.clone(), + ModelSpec::Exact(name) => name.clone(), + }; + effective.default_model = Some(resolved_model); effective.agent.max_tool_iterations = def.max_iterations; } else { tracing::warn!( diff --git a/src/openhuman/learning/reflection.rs b/src/openhuman/learning/reflection.rs index ffcc291c4..a9ec12a27 100644 --- a/src/openhuman/learning/reflection.rs +++ b/src/openhuman/learning/reflection.rs @@ -160,7 +160,7 @@ impl ReflectionHook { // Gate: local reflection requires the per-feature flag. // When off, fall back to a cloud provider if one is configured; // otherwise no-op silently rather than erroring the turn. - if !self.full_config.local_ai.use_local_for_learning() { + if !self.full_config.workload_uses_local("learning") { if let Some(provider) = self.provider.as_ref() { tracing::info!( "[learning::reflection] local_ai.usage.learning_reflection not enabled — \ diff --git a/src/openhuman/local_ai/ops.rs b/src/openhuman/local_ai/ops.rs index 2f0f57953..1602f8bd5 100644 --- a/src/openhuman/local_ai/ops.rs +++ b/src/openhuman/local_ai/ops.rs @@ -153,6 +153,82 @@ pub async fn local_ai_status( )) } +/// Stop the local-AI runtime, killing the Ollama daemon ONLY if OpenHuman +/// spawned it, and shift any workload routed to `ollama:` back to +/// `"cloud"` (= primary). +/// +/// Three coordinated effects: +/// +/// 1. **Daemon shutdown** — `shutdown_owned_ollama` kills the child process +/// only when the spawn marker matches. External daemons (system service, +/// user-launched `ollama serve`, daemons from another OpenHuman workspace) +/// are left untouched, per the same friendly-fire-avoidance rule +/// `ensure_ollama_server` follows at startup. +/// +/// 2. **Routing shift** — every `*_provider` field starting with `ollama:` +/// is cleared (set to `None`, which resolves to `"cloud"` at the factory). +/// Without this, the next chat call routed to `reasoning` (or any other +/// workload the user had set to `ollama:`) would fail at factory +/// build time. The shift is one-way: re-enabling local AI does NOT +/// restore the previous Ollama routes — the user re-picks. +/// +/// 3. **Status forced to disabled** so the UI reflects the gate immediately. +pub async fn local_ai_shutdown_owned( + config: &mut Config, +) -> Result, String> { + let service = local_ai::global(config); + service.shutdown_owned_ollama(config).await; + + // Shift any ollama-routed workload back to "cloud" (= primary). + let cleared = clear_ollama_workload_routes(config); + if cleared > 0 { + log::info!( + "[local_ai] shutdown_owned: shifted {cleared} ollama-routed workload(s) back to cloud" + ); + config.save().await.map_err(|e| e.to_string())?; + } + + service.mark_disabled(config); + Ok(RpcOutcome::single_log( + service.status(), + "local ai runtime gated off (owned daemon killed if any)", + )) +} + +/// Clear every per-workload `*_provider` field whose stored value starts +/// with `"ollama:"`. Returns the count of fields actually changed so the +/// caller can decide whether to persist. +fn clear_ollama_workload_routes(config: &mut Config) -> usize { + fn clear_if_ollama(field: &mut Option) -> bool { + let is_ollama = field + .as_deref() + .map(|s| s.trim().starts_with("ollama:")) + .unwrap_or(false); + if is_ollama { + *field = None; + true + } else { + false + } + } + let mut changed = 0; + for field in [ + &mut config.reasoning_provider, + &mut config.agentic_provider, + &mut config.coding_provider, + &mut config.memory_provider, + &mut config.embeddings_provider, + &mut config.heartbeat_provider, + &mut config.learning_provider, + &mut config.subconscious_provider, + ] { + if clear_if_ollama(field) { + changed += 1; + } + } + changed +} + /// Triggers a full download of all required local AI models. pub async fn local_ai_download( config: &Config, diff --git a/src/openhuman/local_ai/schemas.rs b/src/openhuman/local_ai/schemas.rs index a99e4437a..cf8c56f45 100644 --- a/src/openhuman/local_ai/schemas.rs +++ b/src/openhuman/local_ai/schemas.rs @@ -138,6 +138,7 @@ pub fn all_controller_schemas() -> Vec { schemas("agent_chat"), schemas("agent_chat_simple"), schemas("local_ai_status"), + schemas("local_ai_shutdown_owned"), schemas("local_ai_download"), schemas("local_ai_download_all_assets"), schemas("local_ai_summarize"), @@ -181,6 +182,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("local_ai_status"), handler: handle_local_ai_status, }, + RegisteredController { + schema: schemas("local_ai_shutdown_owned"), + handler: handle_local_ai_shutdown_owned, + }, RegisteredController { schema: schemas("local_ai_download"), handler: handle_local_ai_download, @@ -319,6 +324,16 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Local AI status payload.")], }, + "local_ai_shutdown_owned" => ControllerSchema { + namespace: "local_ai", + function: "shutdown_owned", + description: + "Gate off the local AI runtime. Kills the Ollama daemon only \ + if OpenHuman spawned it (external daemons are left running). \ + Forces status to \"disabled\" so the UI flips immediately.", + inputs: vec![], + outputs: vec![json_output("status", "Local AI status after shutdown.")], + }, "local_ai_download" => ControllerSchema { namespace: "local_ai", function: "download", @@ -634,6 +649,13 @@ fn handle_local_ai_status(_params: Map) -> ControllerFuture { }) } +fn handle_local_ai_shutdown_owned(_params: Map) -> ControllerFuture { + Box::pin(async move { + let mut config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::local_ai::rpc::local_ai_shutdown_owned(&mut config).await?) + }) +} + fn handle_local_ai_download(params: Map) -> ControllerFuture { Box::pin(async move { let p = deserialize_params::(params)?; diff --git a/src/openhuman/local_ai/service/bootstrap.rs b/src/openhuman/local_ai/service/bootstrap.rs index 5e21d7bf5..843f8d947 100644 --- a/src/openhuman/local_ai/service/bootstrap.rs +++ b/src/openhuman/local_ai/service/bootstrap.rs @@ -117,6 +117,16 @@ impl LocalAiService { status.warning = Some(warning); } + /// Force the status field to `"disabled"`. Used by the + /// `local_ai_shutdown_owned` RPC so the UI flips to the disabled + /// state immediately after the user toggles local AI off — without + /// waiting for the natural `local_ai_status` poll to re-bootstrap + /// (which it never does from the `"ready"` state). + pub fn mark_disabled(&self, config: &Config) { + log::info!("[local_ai] mark_disabled: status forced to disabled by gate toggle"); + *self.status.lock() = LocalAiStatus::disabled(config); + } + pub async fn bootstrap(&self, config: &Config) { let _guard = self.bootstrap_lock.lock().await; let device = crate::openhuman::local_ai::device::detect_device_profile(); diff --git a/src/openhuman/memory/store/factories.rs b/src/openhuman/memory/store/factories.rs index 07d3ca971..7f889ed30 100644 --- a/src/openhuman/memory/store/factories.rs +++ b/src/openhuman/memory/store/factories.rs @@ -183,20 +183,18 @@ pub(crate) async fn probe_ollama_reachable(base_url: &str) -> bool { /// [`effective_embedding_settings_probed`]. pub fn effective_embedding_settings( memory: &MemoryConfig, - local_ai: Option<&LocalAiConfig>, + local_embedding_model: Option<&str>, ) -> (String, String, usize) { - if local_ai - .map(LocalAiConfig::use_local_for_embeddings) - .unwrap_or(false) - { + if let Some(raw) = local_embedding_model { // Trim once and reuse — the emptiness check and the final model // string must agree, otherwise a value like " bge-m3 " would pass // through to Ollama with surrounding whitespace and 404. - let model = local_ai - .map(|c| c.embedding_model_id.trim()) - .filter(|m| !m.is_empty()) - .unwrap_or(DEFAULT_OLLAMA_MODEL) - .to_string(); + let trimmed = raw.trim(); + let model = if trimmed.is_empty() { + DEFAULT_OLLAMA_MODEL.to_string() + } else { + trimmed.to_string() + }; return ("ollama".to_string(), model, DEFAULT_OLLAMA_DIMENSIONS); } ( @@ -223,9 +221,9 @@ pub fn effective_embedding_settings( /// genuinely down. pub async fn effective_embedding_settings_probed( memory: &MemoryConfig, - local_ai: Option<&LocalAiConfig>, + local_embedding_model: Option<&str>, ) -> (String, String, usize) { - let intended = effective_embedding_settings(memory, local_ai); + let intended = effective_embedding_settings(memory, local_embedding_model); if intended.0 != "ollama" { return intended; } @@ -278,14 +276,17 @@ pub fn create_memory_with_storage( create_memory_full(config, &[], storage_provider, None, workspace_dir) } -/// Create a memory instance honoring both the `memory` and `local_ai` sections. +/// Create a memory instance honouring the unified per-workload embedding +/// provider. /// -/// Used by top-level entry points (agent harness, channels runtime) that have -/// the full `Config` in scope and want the local-AI opt-in to flip the -/// embedder to Ollama. +/// `local_embedding_model` is the parsed Ollama model id when +/// `Config::workload_local_model("embeddings")` returned `Some`, otherwise +/// `None`. Used by top-level entry points (agent harness, channels runtime) +/// that have the full `Config` in scope. The local-AI opt-in flips the +/// embedder to Ollama when `Some`. pub fn create_memory_with_local_ai( memory: &MemoryConfig, - local_ai: &LocalAiConfig, + local_embedding_model: Option<&str>, embedding_routes: &[EmbeddingRouteConfig], storage_provider: Option<&StorageProviderConfig>, workspace_dir: &Path, @@ -294,7 +295,7 @@ pub fn create_memory_with_local_ai( memory, embedding_routes, storage_provider, - Some(local_ai), + local_embedding_model, workspace_dir, ) } @@ -361,7 +362,7 @@ fn create_memory_full( config: &MemoryConfig, _embedding_routes: &[EmbeddingRouteConfig], _storage_provider: Option<&StorageProviderConfig>, - local_ai: Option<&LocalAiConfig>, + local_embedding_model: Option<&str>, workspace_dir: &Path, ) -> anyhow::Result> { // 0. Short-circuit the unified path when the user has explicitly @@ -384,9 +385,9 @@ fn create_memory_full( } // 1. Resolve the intended provider from config. - let intended = effective_embedding_settings(config, local_ai); - let local_ai_opt_in = local_ai - .map(LocalAiConfig::use_local_for_embeddings) + let intended = effective_embedding_settings(config, local_embedding_model); + let local_ai_opt_in = local_embedding_model + .map(|s| !s.trim().is_empty()) .unwrap_or(false); // 2. Health-gate: if the user has opted into Ollama embeddings but the @@ -503,17 +504,14 @@ mod tests { } #[test] - fn embedding_settings_uses_memory_config_when_local_ai_disabled() { + fn embedding_settings_uses_memory_config_when_local_disabled() { let mut mem = MemoryConfig::default(); mem.embedding_provider = "openai".to_string(); mem.embedding_model = "text-embedding-3-small".to_string(); mem.embedding_dimensions = 1536; - let mut local_ai = LocalAiConfig::default(); - local_ai.runtime_enabled = true; - local_ai.usage.embeddings = false; // explicitly disabled - - let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai)); + // Local embedding model = None means workload routes to cloud. + let (provider, model, dims) = effective_embedding_settings(&mem, None); assert_eq!( provider, "openai", "when local embeddings disabled, memory config must be used" @@ -523,19 +521,15 @@ mod tests { } #[test] - fn embedding_settings_local_ai_opt_in_overrides_memory_config() { - // memory.embedding_provider says "cloud" — but local_ai.usage.embeddings + fn embedding_settings_local_overrides_memory_config() { + // memory.embedding_provider says "cloud" — but a Some(local_model) // is the stronger signal and must override it. let mem = MemoryConfig::default(); // cloud by default - let mut local_ai = LocalAiConfig::default(); - local_ai.runtime_enabled = true; - local_ai.usage.embeddings = true; - local_ai.embedding_model_id = "nomic-embed-text:latest".to_string(); - - let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai)); + let (provider, model, dims) = + effective_embedding_settings(&mem, Some("nomic-embed-text:latest")); assert_eq!( provider, "ollama", - "local-AI opt-in must override memory.embedding_provider" + "Some(local_model) must override memory.embedding_provider" ); assert_eq!(model, "nomic-embed-text:latest"); assert_eq!( @@ -546,16 +540,11 @@ mod tests { } #[test] - fn embedding_settings_local_ai_opt_in_with_empty_model_uses_default() { + fn embedding_settings_local_with_empty_model_uses_default() { // When the user has opted in but the model field is empty/whitespace, // the default Ollama model must be used rather than passing "" to Ollama. let mem = MemoryConfig::default(); - let mut local_ai = LocalAiConfig::default(); - local_ai.runtime_enabled = true; - local_ai.usage.embeddings = true; - local_ai.embedding_model_id = " ".to_string(); // whitespace only - - let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai)); + let (provider, model, dims) = effective_embedding_settings(&mem, Some(" ")); assert_eq!(provider, "ollama"); assert_eq!( model, @@ -603,11 +592,12 @@ mod tests { format!("http://127.0.0.1:{}", addr.port()) } - fn local_ai_with_embeddings_on() -> LocalAiConfig { - let mut cfg = LocalAiConfig::default(); - cfg.runtime_enabled = true; - cfg.usage.embeddings = true; - cfg + /// The parsed local-embedding model string that + /// `Config::workload_local_model("embeddings")` would have produced when + /// the legacy `local_ai.usage.embeddings = true` flag was set. Used so + /// the existing test scenarios continue to drive the local code path. + fn local_embedding_for_test() -> &'static str { + crate::openhuman::embeddings::DEFAULT_OLLAMA_MODEL } #[tokio::test] @@ -657,10 +647,9 @@ mod tests { reset_health_gate_for_test(); let mem = MemoryConfig::default(); - let local_ai = local_ai_with_embeddings_on(); let (provider, model, dims) = - effective_embedding_settings_probed(&mem, Some(&local_ai)).await; + effective_embedding_settings_probed(&mem, Some(local_embedding_for_test())).await; assert_eq!( provider, "cloud", @@ -676,10 +665,9 @@ mod tests { let _env = EnvGuard::set(&url); let mem = MemoryConfig::default(); - let local_ai = local_ai_with_embeddings_on(); let (provider, _model, dims) = - effective_embedding_settings_probed(&mem, Some(&local_ai)).await; + effective_embedding_settings_probed(&mem, Some(local_embedding_for_test())).await; assert_eq!(provider, "ollama", "healthy Ollama must be honoured"); assert_eq!(dims, DEFAULT_OLLAMA_DIMENSIONS); diff --git a/src/openhuman/memory/tree/chat/mod.rs b/src/openhuman/memory/tree/chat/mod.rs index 009c62cb3..087648a6b 100644 --- a/src/openhuman/memory/tree/chat/mod.rs +++ b/src/openhuman/memory/tree/chat/mod.rs @@ -98,14 +98,17 @@ pub trait ChatProvider: Send + Sync { } } -/// Build the [`ChatProvider`] dictated by `config.memory_tree.llm_backend`. +/// Build the [`ChatProvider`] dictated by the unified +/// `Config::workload_local_model("memory")`. /// -/// - `Cloud` (default): wires [`cloud::CloudChatProvider`] against the -/// OpenHuman backend with `cloud_llm_model` (defaulting to -/// `summarization-v1`). -/// - `Local`: wires [`local::OllamaChatProvider`] against the legacy -/// `llm_extractor_endpoint` / `llm_extractor_model` config — the same -/// knobs that drove the Ollama-direct path before this refactor. +/// - When that returns `None` (i.e. `memory_provider` is unset / `"cloud"`): +/// wires [`cloud::CloudChatProvider`] against the OpenHuman backend with +/// `cloud_llm_model` (defaulting to `summarization-v1`). +/// - When it returns `Some(model)` (i.e. `memory_provider = "ollama:"`): +/// wires [`local::OllamaChatProvider`] against the legacy +/// `llm_extractor_endpoint` / `llm_summariser_endpoint` (the daemon +/// endpoints stay in the `memory_tree` block — only the cloud/local +/// routing decision moves to the unified `memory_provider`). /// /// `consumer` is one of `"extract"` / `"summarise"` and selects the local /// endpoint+model pair (extract uses `llm_extractor_*`, summarise uses @@ -114,67 +117,75 @@ pub fn build_chat_provider( config: &Config, consumer: ChatConsumer, ) -> Result> { - match config.memory_tree.llm_backend { - LlmBackend::Cloud => { - let model = config - .memory_tree - .cloud_llm_model - .clone() - .unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()); - // The `auth-profiles.json` lives next to `config.toml`, so the - // openhuman_dir is the parent of config_path. Without this the - // inner OpenHumanBackendProvider falls back to `~/.openhuman` - // and fails with "No backend session" on any workspace not - // located at the home default — the bug observed when running - // with `OPENHUMAN_WORKSPACE` pointed elsewhere. - let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from); - log::debug!( - "[memory_tree::chat] building Cloud provider consumer={} model={} \ - openhuman_dir={:?}", - consumer.as_str(), - model, - openhuman_dir - ); - Ok(Arc::new(cloud::CloudChatProvider::new( - config.api_url.clone(), - model, - openhuman_dir, - config.secrets.encrypt, - ))) - } - LlmBackend::Local => { - let (endpoint, model, timeout_ms) = match consumer { - ChatConsumer::Extract => ( - config.memory_tree.llm_extractor_endpoint.clone(), - config.memory_tree.llm_extractor_model.clone(), - config - .memory_tree - .llm_extractor_timeout_ms - .unwrap_or(15_000), - ), - ChatConsumer::Summarise => ( - config.memory_tree.llm_summariser_endpoint.clone(), - config.memory_tree.llm_summariser_model.clone(), - config - .memory_tree - .llm_summariser_timeout_ms - .unwrap_or(120_000), - ), - }; - log::debug!( - "[memory_tree::chat] building Local (Ollama) provider consumer={} \ - endpoint_set={} model_set={} timeout_ms={}", - consumer.as_str(), - endpoint.is_some(), - model.is_some(), - timeout_ms - ); - Ok(Arc::new(local::OllamaChatProvider::new( - endpoint, - model, - std::time::Duration::from_millis(timeout_ms), - )?)) - } + if let Some(routed_model) = config.workload_local_model("memory") { + let (endpoint, model, timeout_ms) = match consumer { + ChatConsumer::Extract => ( + config.memory_tree.llm_extractor_endpoint.clone(), + // Prefer the legacy per-path model for back-compat; fall back + // to the unified workload_local_model from memory_provider. + config + .memory_tree + .llm_extractor_model + .clone() + .or_else(|| Some(routed_model.clone())), + config + .memory_tree + .llm_extractor_timeout_ms + .unwrap_or(15_000), + ), + ChatConsumer::Summarise => ( + config.memory_tree.llm_summariser_endpoint.clone(), + // Same fallback for the summarise path. + config + .memory_tree + .llm_summariser_model + .clone() + .or_else(|| Some(routed_model)), + config + .memory_tree + .llm_summariser_timeout_ms + .unwrap_or(120_000), + ), + }; + log::debug!( + "[memory_tree::chat] building Local (Ollama) provider consumer={} \ + endpoint_set={} model_set={} timeout_ms={}", + consumer.as_str(), + endpoint.is_some(), + model.is_some(), + timeout_ms + ); + Ok(Arc::new(local::OllamaChatProvider::new( + endpoint, + model, + std::time::Duration::from_millis(timeout_ms), + )?)) + } else { + let model = config + .memory_tree + .cloud_llm_model + .clone() + .unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()); + // The `auth-profiles.json` lives next to `config.toml`, so the + // openhuman_dir is the parent of config_path. Without this the + // inner OpenHumanBackendProvider falls back to `~/.openhuman` + // and fails with "No backend session" on any workspace not + // located at the home default — the bug observed when running + // with `OPENHUMAN_WORKSPACE` pointed elsewhere. + let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from); + log::debug!( + "[memory_tree::chat] building Cloud provider consumer={} model={} \ + openhuman_dir={:?}", + consumer.as_str(), + model, + openhuman_dir + ); + Ok(Arc::new(cloud::CloudChatProvider::new( + config.api_url.clone(), + model, + openhuman_dir, + config.secrets.encrypt, + ))) } } @@ -242,8 +253,14 @@ mod tests { #[test] fn build_provider_returns_local_when_configured() { + // After #1710 the local-vs-cloud decision is driven by + // `memory_provider` (via `Config::workload_uses_local("memory")`), + // not the legacy `memory_tree.llm_backend` flag — so the test + // needs to set the new workload field. Endpoint + model on + // `memory_tree` are still consumed for endpoint/model resolution + // inside the local branch. let mut cfg = Config::default(); - cfg.memory_tree.llm_backend = LlmBackend::Local; + cfg.memory_provider = Some("ollama:qwen2.5:0.5b".into()); cfg.memory_tree.llm_extractor_endpoint = Some("http://localhost:11434".into()); cfg.memory_tree.llm_extractor_model = Some("qwen2.5:0.5b".into()); let provider = build_chat_provider(&cfg, ChatConsumer::Extract).unwrap(); diff --git a/src/openhuman/memory/tree/jobs/worker.rs b/src/openhuman/memory/tree/jobs/worker.rs index 835a31661..14e814059 100644 --- a/src/openhuman/memory/tree/jobs/worker.rs +++ b/src/openhuman/memory/tree/jobs/worker.rs @@ -149,12 +149,18 @@ pub async fn run_once(config: &Config) -> Result { // drop the permit so multiple workers can run cloud // extract/summarise calls in parallel (the worker pool // itself, sized to `WORKER_COUNT`, is the upstream bound). - match config.memory_tree.llm_backend { - crate::openhuman::config::LlmBackend::Local => gate_permit, - crate::openhuman::config::LlmBackend::Cloud => { - drop(gate_permit); - None - } + let memory_uses_local = config.workload_uses_local("memory"); + log::trace!( + "[memory_tree::jobs] llm permit routing job_id={} kind={} memory_uses_local={}", + job.id, + job.kind.as_str(), + memory_uses_local + ); + if memory_uses_local { + gate_permit + } else { + drop(gate_permit); + None } } else { // Non-LLM jobs don't need the global slot; release it so an diff --git a/src/openhuman/memory/tree/read_rpc.rs b/src/openhuman/memory/tree/read_rpc.rs index 81506956f..cc1bd2d32 100644 --- a/src/openhuman/memory/tree/read_rpc.rs +++ b/src/openhuman/memory/tree/read_rpc.rs @@ -1723,6 +1723,23 @@ pub async fn set_llm_rpc( changed_models.push("summariser_model"); } + // Mirror to the unified memory_provider AFTER optional model overrides so + // the persisted routing reflects the final staged values. The extract + // model isn't applied to memory_provider — it's a hint for the separate + // extractor path, not a top-level provider switch. + staged.memory_provider = Some(match parsed { + crate::openhuman::config::schema::LlmBackend::Local => { + let m = staged + .memory_tree + .llm_summariser_model + .clone() + .or_else(|| staged.memory_tree.llm_extractor_model.clone()) + .unwrap_or_else(|| staged.local_ai.chat_model_id.clone()); + format!("ollama:{m}") + } + crate::openhuman::config::schema::LlmBackend::Cloud => "cloud".to_string(), + }); + // Persist the staged version to config.toml. Atomic write-temp + // rename per Config::save. Commit to the live config only after a // successful write. diff --git a/src/openhuman/memory/tree/score/embed/factory.rs b/src/openhuman/memory/tree/score/embed/factory.rs index 6990b4492..d57004185 100644 --- a/src/openhuman/memory/tree/score/embed/factory.rs +++ b/src/openhuman/memory/tree/score/embed/factory.rs @@ -80,16 +80,15 @@ pub fn build_embedder_from_config(config: &Config) -> Result> ))) } _ => { - // Honor the Local AI Settings "Memory embeddings" checkbox. - // `use_local_for_embeddings()` is `runtime_enabled && usage.embeddings` - // so we never route to a disabled local runtime. - if config.local_ai.use_local_for_embeddings() { - let model = config.local_ai.embedding_model_id.clone(); + // Honour the unified AI settings: `embeddings_provider` is the + // single source of truth. When it parses as `ollama:` we + // route locally; otherwise we fall back to the cloud session. + if let Some(model) = config.workload_local_model("embeddings") { let endpoint = ollama_base_url(); let timeout_ms = tree_cfg.embedding_timeout_ms.unwrap_or(0); log::debug!( - "[memory_tree::embed::factory] usage.embeddings=true — using local Ollama endpoint={} model={} timeout_ms={}", - endpoint, model, timeout_ms + "[memory_tree::embed::factory] embeddings_provider=ollama:{} — using local Ollama endpoint={} timeout_ms={}", + model, endpoint, timeout_ms ); Ok(Box::new(OllamaEmbedder::new(endpoint, model, timeout_ms))) } else if cloud_session_available(config) { @@ -213,16 +212,17 @@ mod tests { #[test] fn local_ai_usage_embeddings_routes_to_ollama() { - // When the Local AI Settings "Memory embeddings" checkbox is on - // (runtime_enabled && usage.embeddings), memory tree routes to - // Ollama using the user's chosen `embedding_model_id`. The - // explicit endpoint/model override is left unset so we exercise - // the use_local_for_embeddings() branch. + // After #1710 the local-vs-cloud decision for embeddings is + // driven by `embeddings_provider` (via + // `Config::workload_uses_local("embeddings")`), not the legacy + // `local_ai.usage.embeddings` flag. Set the new workload field + // so the local branch is taken; `embedding_model_id` is still + // the model name source for the Ollama provider. let (_tmp, mut cfg) = test_config(); cfg.memory_tree.embedding_endpoint = None; cfg.memory_tree.embedding_model = None; + cfg.embeddings_provider = Some("ollama:all-minilm:latest".into()); cfg.local_ai.runtime_enabled = true; - cfg.local_ai.usage.embeddings = true; cfg.local_ai.embedding_model_id = "all-minilm:latest".to_string(); let e = build_embedder_from_config(&cfg).expect("ollama path should build"); assert_eq!(e.name(), "ollama"); diff --git a/src/openhuman/memory/tree/score/extract/mod.rs b/src/openhuman/memory/tree/score/extract/mod.rs index 786fde0b8..951b2fbc8 100644 --- a/src/openhuman/memory/tree/score/extract/mod.rs +++ b/src/openhuman/memory/tree/score/extract/mod.rs @@ -38,8 +38,8 @@ pub fn build_summary_extractor(config: &Config) -> Arc { let Some(model) = model else { log::debug!( "[memory_tree::extract] summary extractor: LLM model not resolvable for \ - llm_backend={} — using regex-only", - config.memory_tree.llm_backend.as_str() + memory_provider={:?} — using regex-only", + config.memory_provider.as_deref().unwrap_or("cloud") ); return Arc::new(CompositeExtractor::regex_only()); }; @@ -76,37 +76,37 @@ pub fn build_summary_extractor(config: &Config) -> Arc { /// Resolve the model identifier the extractor's [`ChatProvider`] should /// target, returning `None` when the configured backend can't be served: /// -/// - `Cloud`: always returns the configured `cloud_llm_model` or its -/// `summarization-v1` default. -/// - `Local`: returns `Some(model)` only when both -/// `llm_extractor_endpoint` AND `llm_extractor_model` are set — -/// otherwise the legacy regex-only path engages. +/// - Cloud (i.e. `Config::workload_uses_local("memory")` is false): always +/// returns the configured `cloud_llm_model` or its `summarization-v1` +/// default. +/// - Local (i.e. `memory_provider = "ollama:"`): returns `Some(model)` +/// only when both `llm_extractor_endpoint` AND `llm_extractor_model` are +/// set — otherwise the legacy regex-only path engages. pub(super) fn resolve_extractor_model(config: &Config) -> Option { - match config.memory_tree.llm_backend { - LlmBackend::Cloud => Some( + if config.workload_uses_local("memory") { + let endpoint = config + .memory_tree + .llm_extractor_endpoint + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let model = config + .memory_tree + .llm_extractor_model + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match (endpoint, model) { + (Some(_), Some(m)) => Some(m.to_string()), + _ => None, + } + } else { + Some( config .memory_tree .cloud_llm_model .clone() .unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()), - ), - LlmBackend::Local => { - let endpoint = config - .memory_tree - .llm_extractor_endpoint - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - let model = config - .memory_tree - .llm_extractor_model - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match (endpoint, model) { - (Some(_), Some(m)) => Some(m.to_string()), - _ => None, - } - } + ) } } diff --git a/src/openhuman/memory/tree/score/mod.rs b/src/openhuman/memory/tree/score/mod.rs index b1521676f..f57b5964f 100644 --- a/src/openhuman/memory/tree/score/mod.rs +++ b/src/openhuman/memory/tree/score/mod.rs @@ -125,9 +125,9 @@ impl ScoringConfig { Some(m) => m, None => { log::debug!( - "[memory_tree::score] llm_extractor not resolvable for llm_backend={} \ + "[memory_tree::score] llm_extractor not resolvable for memory_provider={:?} \ — using regex-only", - config.memory_tree.llm_backend.as_str() + config.memory_provider.as_deref().unwrap_or("cloud") ); return Self::default_regex_only(); } diff --git a/src/openhuman/memory/tree/tree_source/summariser/mod.rs b/src/openhuman/memory/tree/tree_source/summariser/mod.rs index b0cd3af15..93bb94c51 100644 --- a/src/openhuman/memory/tree/tree_source/summariser/mod.rs +++ b/src/openhuman/memory/tree/tree_source/summariser/mod.rs @@ -82,45 +82,47 @@ pub trait Summariser: Send + Sync { /// by reference to `append_leaf` and `route_leaf_to_topic_trees` /// without threading a generic type parameter through every caller. pub fn build_summariser(config: &Config) -> Arc { - use crate::openhuman::config::{LlmBackend, DEFAULT_CLOUD_LLM_MODEL}; + use crate::openhuman::config::DEFAULT_CLOUD_LLM_MODEL; use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer}; // Resolve the model identifier to log alongside the provider name. - // Returns None (→ inert fallback) only when llm_backend=local and the legacy - // llm_summariser_endpoint/_model fields are not both set. - let model: Option = match config.memory_tree.llm_backend { - LlmBackend::Cloud => Some( + // When memory_provider is local (ollama:*), prefer the legacy + // llm_summariser_endpoint/_model pair when both are set (for back-compat), + // then fall back to the unified workload_local_model so that users who + // configure memory_provider=ollama: without the legacy fields still get + // an LLM summariser instead of the inert fallback. + let model: Option = if config.workload_uses_local("memory") { + let endpoint = config + .memory_tree + .llm_summariser_endpoint + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let legacy_model = config + .memory_tree + .llm_summariser_model + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match (endpoint, legacy_model) { + (Some(_), Some(m)) => Some(m.to_string()), + _ => config.workload_local_model("memory"), + } + } else { + Some( config .memory_tree .cloud_llm_model .clone() .unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()), - ), - LlmBackend::Local => { - let endpoint = config - .memory_tree - .llm_summariser_endpoint - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - let m = config - .memory_tree - .llm_summariser_model - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match (endpoint, m) { - (Some(_), Some(m)) => Some(m.to_string()), - _ => None, - } - } + ) }; let Some(model) = model else { log::debug!( - "[tree_source::summariser] llm_summariser not configured for llm_backend={} \ + "[tree_source::summariser] llm_summariser not configured for memory_provider={:?} \ — using InertSummariser", - config.memory_tree.llm_backend.as_str() + config.memory_provider.as_deref().unwrap_or("cloud") ); return Arc::new(inert::InertSummariser::new()); }; diff --git a/src/openhuman/migrations/mod.rs b/src/openhuman/migrations/mod.rs index d507d8d7d..bd17b9d69 100644 --- a/src/openhuman/migrations/mod.rs +++ b/src/openhuman/migrations/mod.rs @@ -24,9 +24,10 @@ use crate::openhuman::config::Config; mod phase_out_profile_md; +mod unify_ai_provider_settings; /// Current target schema version. Bumped alongside every new migration. -pub const CURRENT_SCHEMA_VERSION: u32 = 1; +pub const CURRENT_SCHEMA_VERSION: u32 = 2; /// Run any migrations whose `schema_version` gate hasn't yet been /// crossed for this workspace. @@ -103,6 +104,43 @@ pub async fn run_pending(config: &mut Config) { } } } + + // 1 -> 2: unify scattered AI provider settings into per-workload + // provider strings and seed the cloud_providers list. Pure in-memory + // mutation of the Config struct — no I/O — so we run it inline. + // Guard on `== 1` (not `< 2`) so a failed 0→1 migration doesn't + // accidentally get skipped: if schema_version is still 0 here the 0→1 + // step did not complete and we must not advance to 2. + if config.schema_version == 1 { + match unify_ai_provider_settings::run(config) { + Ok(stats) => { + let previous_version = config.schema_version; + config.schema_version = 2; + if let Err(err) = config.save().await { + config.schema_version = previous_version; + log::warn!( + "[migrations] unify_ai_provider_settings ran but config.save failed: \ + {err:#} — rolled in-memory schema_version back to {previous_version}, \ + will retry on next launch" + ); + return; + } + log::info!( + "[migrations] schema_version bumped to 2 (unify_ai_provider_settings \ + seeded={} primary_set={} workload_fields={})", + stats.cloud_providers_seeded, + stats.primary_cloud_set, + stats.workload_fields_filled + ); + } + Err(err) => { + log::warn!( + "[migrations] unify_ai_provider_settings failed: {err:#} — \ + will retry on next launch" + ); + } + } + } } #[cfg(test)] diff --git a/src/openhuman/migrations/mod_tests.rs b/src/openhuman/migrations/mod_tests.rs index 1b2f20f91..3256c2089 100644 --- a/src/openhuman/migrations/mod_tests.rs +++ b/src/openhuman/migrations/mod_tests.rs @@ -74,7 +74,7 @@ async fn run_pending_runs_phase_out_when_version_zero() { assert_eq!(config.schema_version, 0); run_pending(&mut config).await; - assert_eq!(config.schema_version, 1); + assert_eq!(config.schema_version, 2); let session = read_transcript(&path).unwrap(); assert!( !session.messages[0].content.contains("### PROFILE.md"), @@ -84,8 +84,8 @@ async fn run_pending_runs_phase_out_when_version_zero() { let on_disk = std::fs::read_to_string(&config.config_path).unwrap(); assert!( - on_disk.contains("schema_version = 1"), - "saved config.toml must record schema_version=1, got:\n{on_disk}" + on_disk.contains("schema_version = 2"), + "saved config.toml must record schema_version=2, got:\n{on_disk}" ); } @@ -98,9 +98,9 @@ async fn run_pending_bumps_version_on_fresh_install() { let mut config = config_in(&tmp); run_pending(&mut config).await; - assert_eq!(config.schema_version, 1); + assert_eq!(config.schema_version, 2); let on_disk = std::fs::read_to_string(&config.config_path).unwrap(); - assert!(on_disk.contains("schema_version = 1")); + assert!(on_disk.contains("schema_version = 2")); } #[tokio::test] @@ -132,7 +132,7 @@ async fn run_pending_is_a_no_op_on_second_invocation() { let mut config = config_in(&tmp); run_pending(&mut config).await; - assert_eq!(config.schema_version, 1); + assert_eq!(config.schema_version, 2); // Mutate the config file timestamp marker by reading + comparing // before vs after the second invocation. @@ -141,7 +141,7 @@ async fn run_pending_is_a_no_op_on_second_invocation() { run_pending(&mut config).await; let after = fs::metadata(&config.config_path).unwrap().modified().ok(); - assert_eq!(config.schema_version, 1); + assert_eq!(config.schema_version, 2); assert_eq!( before, after, "config.toml must not be re-saved on second run" diff --git a/src/openhuman/migrations/unify_ai_provider_settings.rs b/src/openhuman/migrations/unify_ai_provider_settings.rs new file mode 100644 index 000000000..86b500244 --- /dev/null +++ b/src/openhuman/migrations/unify_ai_provider_settings.rs @@ -0,0 +1,251 @@ +//! Migration 1 → 2: unify the scattered AI provider settings into the new +//! per-workload provider-string fields, and seed the `cloud_providers` list. +//! +//! ## What this migration consolidates +//! +//! Pre-unification config carried five different vocabularies for the same +//! question — *"where does this LLM workload run?"*: +//! +//! - `inference_url` + `model_routes` — global cloud preset +//! - `reasoning_provider` / `agentic_provider` / `coding_provider` +//! — per-role chat (#1710, partial) +//! - `local_ai.usage.{embeddings,heartbeat,learning_reflection,subconscious}` +//! — local-vs-cloud booleans +//! - `memory_tree.llm_backend` (+ `cloud_llm_model`) — memory summariser +//! +//! After this migration there is one grammar — provider strings parsed by +//! [`crate::openhuman::providers::factory`] — addressing all eight workloads +//! uniformly: +//! +//! ```text +//! reasoning_provider, agentic_provider, coding_provider, +//! memory_provider, embeddings_provider, heartbeat_provider, +//! learning_provider, subconscious_provider +//! ``` +//! +//! plus `cloud_providers: Vec` and `primary_cloud` for +//! the credential side. +//! +//! ## Behaviour +//! +//! - Pure in-memory mutation of `Config`. The caller (`migrations::run_pending`) +//! persists the result via `Config::save()` and bumps `schema_version`. +//! - Idempotent: gated on `*.is_none()` per field. A re-run after a previous +//! successful run is a no-op. +//! - Never touches keys / secrets. API keys remain in +//! `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`]. +//! - Always seeds an `Openhuman` entry into `cloud_providers` (idempotent — +//! only when the list is empty). +//! - Migrates `inference_url` into a `Custom` cloud provider entry when the +//! URL doesn't look like the OpenHuman backend. + +use crate::openhuman::config::schema::cloud_providers::{ + generate_provider_id, CloudProviderCreds, CloudProviderType, +}; +use crate::openhuman::config::Config; + +/// Counters returned by [`run`] for diagnostics. Logged at INFO once per +/// successful migration run. +#[derive(Debug, Default, Clone)] +pub struct MigrationStats { + pub cloud_providers_seeded: usize, + pub primary_cloud_set: bool, + pub workload_fields_filled: usize, +} + +/// Run the AI-provider unification migration on the given `Config`. +/// +/// Synchronous because the body is pure config mutation — no I/O. The caller +/// is responsible for persisting via `Config::save()` once the runner has +/// also bumped `schema_version`. +pub fn run(config: &mut Config) -> anyhow::Result { + let mut stats = MigrationStats::default(); + + seed_cloud_providers(config, &mut stats); + set_primary_cloud(config, &mut stats); + derive_workload_providers(config, &mut stats); + + log::info!( + "[migrations][unify-ai] done seeded_providers={} primary_set={} workload_fields_filled={}", + stats.cloud_providers_seeded, + stats.primary_cloud_set, + stats.workload_fields_filled, + ); + Ok(stats) +} + +/// Seed `cloud_providers` with an OpenHuman entry (and optionally a Custom +/// entry derived from a legacy `inference_url`). +fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) { + if !config.cloud_providers.is_empty() { + log::debug!( + "[migrations][unify-ai] cloud_providers already populated ({} entries), skipping seed", + config.cloud_providers.len() + ); + return; + } + + // Always seed the OpenHuman entry — even if api_url is None, the factory + // resolves a sensible default at runtime. + let oh_endpoint = config + .api_url + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| CloudProviderType::Openhuman.default_endpoint().to_string()); + let oh_default_model = config + .default_model + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "reasoning-v1".to_string()); + config.cloud_providers.push(CloudProviderCreds { + id: generate_provider_id(&CloudProviderType::Openhuman), + r#type: CloudProviderType::Openhuman, + endpoint: oh_endpoint, + default_model: oh_default_model, + }); + stats.cloud_providers_seeded += 1; + + // If there's a legacy `inference_url` pointing at a non-OpenHuman + // endpoint, surface it as a Custom entry so the user keeps their + // configuration. The actual key continues to live in auth-profiles.json + // (or in `api_key` on Config — which is OpenHuman's session JWT and + // doesn't apply here; users will re-enter via the new UI). + if let Some(raw) = config.inference_url.as_deref() { + let trimmed = raw.trim(); + if !trimmed.is_empty() && !looks_like_openhuman(trimmed) { + // Derive a sensible default model from the legacy model_routes + // (prefer "reasoning" hint, fall back to whatever is set). + let default_model = config + .model_routes + .iter() + .find(|r| r.hint.eq_ignore_ascii_case("reasoning")) + .or_else(|| config.model_routes.first()) + .map(|r| r.model.clone()) + .unwrap_or_default(); + config.cloud_providers.push(CloudProviderCreds { + id: generate_provider_id(&CloudProviderType::Custom), + r#type: CloudProviderType::Custom, + endpoint: trimmed.to_string(), + default_model, + }); + stats.cloud_providers_seeded += 1; + log::info!( + "[migrations][unify-ai] seeded Custom cloud_providers entry from legacy \ + inference_url_present=true" + ); + } + } +} + +/// Default `primary_cloud` to the OpenHuman entry (the first one we just +/// seeded, by construction). Idempotent — only sets if currently `None`. +fn set_primary_cloud(config: &mut Config, stats: &mut MigrationStats) { + if config.primary_cloud.is_some() { + return; + } + let oh = config + .cloud_providers + .iter() + .find(|e| e.r#type == CloudProviderType::Openhuman); + if let Some(entry) = oh { + config.primary_cloud = Some(entry.id.clone()); + stats.primary_cloud_set = true; + log::debug!( + "[migrations][unify-ai] primary_cloud set to openhuman entry id={}", + entry.id + ); + } +} + +/// Derive each per-workload `*_provider` field from the legacy flags. +/// +/// All fields are gated on `is_none()` — a partially-migrated config skips +/// fields that were already set by a previous run or a hand-edit. +fn derive_workload_providers(config: &mut Config, stats: &mut MigrationStats) { + let runtime_on = config.local_ai.runtime_enabled; + let chat_model = config.local_ai.chat_model_id.clone(); + let embed_model = config.local_ai.embedding_model_id.clone(); + + let set_field = |field: &mut Option, value: String, stats: &mut MigrationStats| { + if field.is_none() { + *field = Some(value); + stats.workload_fields_filled += 1; + } + }; + + // Memory summariser — `memory_tree.llm_backend` is `LlmBackend::Cloud | Local`. + let memory_value = match config.memory_tree.llm_backend { + crate::openhuman::config::schema::LlmBackend::Local + if runtime_on && !chat_model.is_empty() => + { + format!("ollama:{}", chat_model) + } + _ => "cloud".to_string(), + }; + set_field(&mut config.memory_provider, memory_value, stats); + + // Embeddings — uses the embedding_model_id, not chat_model_id. + let embeddings_value = + if config.local_ai.usage.embeddings && runtime_on && !embed_model.is_empty() { + format!("ollama:{}", embed_model) + } else { + "cloud".to_string() + }; + set_field(&mut config.embeddings_provider, embeddings_value, stats); + + // The remaining three use the chat model when local. + let heartbeat_value = if config.local_ai.usage.heartbeat && runtime_on && !chat_model.is_empty() + { + format!("ollama:{}", chat_model) + } else { + "cloud".to_string() + }; + set_field(&mut config.heartbeat_provider, heartbeat_value, stats); + + let learning_value = + if config.local_ai.usage.learning_reflection && runtime_on && !chat_model.is_empty() { + format!("ollama:{}", chat_model) + } else { + "cloud".to_string() + }; + set_field(&mut config.learning_provider, learning_value, stats); + + let subconscious_value = + if config.local_ai.usage.subconscious && runtime_on && !chat_model.is_empty() { + format!("ollama:{}", chat_model) + } else { + "cloud".to_string() + }; + set_field(&mut config.subconscious_provider, subconscious_value, stats); + + // The three chat workloads (reasoning/agentic/coding) intentionally + // stay None — the factory treats unset as "cloud" which routes to + // primary_cloud. No equivalent of "force this to local" existed in the + // legacy config for chat, so there's nothing to derive. +} + +/// Heuristic: does the URL look like a configured OpenHuman backend? +/// +/// Used to decide whether a non-empty `inference_url` should be migrated +/// into a Custom cloud provider entry. The default OpenHuman backend lives +/// at api.openhuman.ai; staging and dev URLs use the same host pattern. +/// +/// Matches only on the host component to avoid false positives from custom +/// endpoints that happen to contain "openhuman" in a path or query string. +fn looks_like_openhuman(url: &str) -> bool { + let lower = url.trim().to_ascii_lowercase(); + // Strip scheme if present. + let without_scheme = lower.split("://").nth(1).unwrap_or(&lower); + // Strip userinfo and take only the host[:port] part before any path. + let authority = without_scheme.split('/').next().unwrap_or(""); + let host = authority.split('@').last().unwrap_or(authority); + let host_no_port = host.split(':').next().unwrap_or(host); + host_no_port == "api.openhuman.ai" + || host_no_port.ends_with(".openhuman.ai") + // Allow bare "openhuman" for local/dev names (e.g. Docker compose service names). + || host_no_port == "openhuman" +} + +#[cfg(test)] +#[path = "unify_ai_provider_settings_tests.rs"] +mod tests; diff --git a/src/openhuman/migrations/unify_ai_provider_settings_tests.rs b/src/openhuman/migrations/unify_ai_provider_settings_tests.rs new file mode 100644 index 000000000..2c1f04a76 --- /dev/null +++ b/src/openhuman/migrations/unify_ai_provider_settings_tests.rs @@ -0,0 +1,170 @@ +//! Tests for the 1 → 2 AI-provider unification migration. + +use super::*; +use crate::openhuman::config::schema::cloud_providers::CloudProviderType; +use crate::openhuman::config::schema::{LocalAiConfig, LocalAiUsage}; +use crate::openhuman::config::Config; + +fn make_legacy_config_local_on() -> Config { + let mut c = Config::default(); + c.local_ai = LocalAiConfig { + runtime_enabled: true, + chat_model_id: "llama3.1:8b".into(), + embedding_model_id: "bge-m3".into(), + usage: LocalAiUsage { + embeddings: true, + heartbeat: true, + learning_reflection: false, + subconscious: true, + }, + ..LocalAiConfig::default() + }; + c.memory_tree.llm_backend = crate::openhuman::config::schema::LlmBackend::Local; + c +} + +#[test] +fn empty_config_seeds_openhuman_entry() { + let mut c = Config::default(); + let stats = run(&mut c).expect("migration must succeed"); + + assert_eq!(stats.cloud_providers_seeded, 1); + assert_eq!(c.cloud_providers.len(), 1); + assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman); + assert!(c.cloud_providers[0].id.starts_with("p_openhuman_")); +} + +#[test] +fn primary_cloud_defaults_to_openhuman_id() { + let mut c = Config::default(); + let stats = run(&mut c).expect("migration must succeed"); + + assert!(stats.primary_cloud_set); + assert_eq!(c.primary_cloud, Some(c.cloud_providers[0].id.clone())); +} + +#[test] +fn legacy_inference_url_becomes_custom_entry() { + let mut c = Config::default(); + c.inference_url = Some("https://api.example.com/v1".into()); + c.model_routes + .push(crate::openhuman::config::schema::ModelRouteConfig { + hint: "reasoning".into(), + model: "gpt-4o".into(), + }); + + let stats = run(&mut c).expect("migration must succeed"); + + assert_eq!(stats.cloud_providers_seeded, 2); + let custom = c + .cloud_providers + .iter() + .find(|e| e.r#type == CloudProviderType::Custom) + .expect("custom entry must be seeded"); + assert_eq!(custom.endpoint, "https://api.example.com/v1"); + assert_eq!(custom.default_model, "gpt-4o"); +} + +#[test] +fn openhuman_inference_url_does_not_seed_custom() { + let mut c = Config::default(); + c.inference_url = Some("https://api.openhuman.ai/v1".into()); + let _ = run(&mut c).expect("migration must succeed"); + // Only the openhuman entry should be seeded — no Custom entry. + assert_eq!(c.cloud_providers.len(), 1); + assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman); +} + +#[test] +fn embeddings_provider_derived_from_legacy_usage() { + let mut c = make_legacy_config_local_on(); + let stats = run(&mut c).expect("migration must succeed"); + assert!(stats.workload_fields_filled >= 5); + assert_eq!(c.embeddings_provider.as_deref(), Some("ollama:bge-m3")); +} + +#[test] +fn heartbeat_provider_derived_from_legacy_usage() { + let mut c = make_legacy_config_local_on(); + let _ = run(&mut c).unwrap(); + assert_eq!(c.heartbeat_provider.as_deref(), Some("ollama:llama3.1:8b")); +} + +#[test] +fn subconscious_provider_derived_from_legacy_usage() { + let mut c = make_legacy_config_local_on(); + let _ = run(&mut c).unwrap(); + assert_eq!( + c.subconscious_provider.as_deref(), + Some("ollama:llama3.1:8b") + ); +} + +#[test] +fn learning_provider_defaults_to_cloud_when_flag_off() { + // learning_reflection is `false` in our fixture. + let mut c = make_legacy_config_local_on(); + let _ = run(&mut c).unwrap(); + assert_eq!(c.learning_provider.as_deref(), Some("cloud")); +} + +#[test] +fn memory_provider_local_when_llm_backend_local() { + let mut c = make_legacy_config_local_on(); + let _ = run(&mut c).unwrap(); + assert_eq!(c.memory_provider.as_deref(), Some("ollama:llama3.1:8b")); +} + +#[test] +fn memory_provider_cloud_when_llm_backend_cloud() { + let mut c = Config::default(); + // default backend is Cloud + let _ = run(&mut c).unwrap(); + assert_eq!(c.memory_provider.as_deref(), Some("cloud")); +} + +#[test] +fn chat_workload_providers_left_unset() { + let mut c = make_legacy_config_local_on(); + let _ = run(&mut c).unwrap(); + // Reasoning/agentic/coding have no legacy equivalent — they stay None + // and the factory defaults them to "cloud" at runtime. + assert_eq!(c.reasoning_provider, None); + assert_eq!(c.agentic_provider, None); + assert_eq!(c.coding_provider, None); +} + +#[test] +fn idempotent_second_run_is_noop() { + let mut c = make_legacy_config_local_on(); + let first = run(&mut c).expect("first run must succeed"); + let providers_after_first = c.cloud_providers.len(); + let primary_after_first = c.primary_cloud.clone(); + let heartbeat_after_first = c.heartbeat_provider.clone(); + + let second = run(&mut c).expect("second run must succeed"); + + // Second run must not seed extras nor flip any field. + assert_eq!(second.cloud_providers_seeded, 0); + assert!(!second.primary_cloud_set); + assert_eq!(second.workload_fields_filled, 0); + assert_eq!(c.cloud_providers.len(), providers_after_first); + assert_eq!(c.primary_cloud, primary_after_first); + assert_eq!(c.heartbeat_provider, heartbeat_after_first); + + // Sanity: stats from the first run say we did do work. + assert!(first.cloud_providers_seeded >= 1); + assert!(first.workload_fields_filled >= 1); +} + +#[test] +fn runtime_disabled_falls_back_to_cloud_even_with_usage_flags() { + let mut c = make_legacy_config_local_on(); + c.local_ai.runtime_enabled = false; + let _ = run(&mut c).unwrap(); + // With runtime off, every workload routes to cloud regardless of usage.* + assert_eq!(c.heartbeat_provider.as_deref(), Some("cloud")); + assert_eq!(c.subconscious_provider.as_deref(), Some("cloud")); + assert_eq!(c.embeddings_provider.as_deref(), Some("cloud")); + assert_eq!(c.memory_provider.as_deref(), Some("cloud")); +} diff --git a/src/openhuman/providers/factory.rs b/src/openhuman/providers/factory.rs new file mode 100644 index 000000000..0fefc6306 --- /dev/null +++ b/src/openhuman/providers/factory.rs @@ -0,0 +1,716 @@ +//! Unified chat-provider factory. +//! +//! Resolves workload names (e.g. `"reasoning"`, `"heartbeat"`) to a +//! `(Box, String)` tuple where the second element is the model +//! id to pass into `chat_with_history` / `simple_chat`. +//! +//! ## Provider-string grammar +//! +//! ```text +//! "cloud" → resolves to primary_cloud entry; if that entry has +//! type=openhuman, behaves as "openhuman" +//! "openhuman" → OpenHumanBackendProvider; model = config.default_model +//! "openai:" → cloud_providers entry of type=openai + Bearer auth +//! "anthropic:" → cloud_providers entry of type=anthropic + Bearer auth +//! "openrouter:" → cloud_providers entry of type=openrouter + Bearer auth +//! "custom:" → cloud_providers entry of type=custom + Bearer auth +//! "ollama:" → local Ollama at config.local_ai.base_url +//! ``` +//! +//! Unknown strings and missing-creds configurations produce actionable errors. + +use crate::openhuman::config::schema::cloud_providers::CloudProviderType; +use crate::openhuman::config::Config; +use crate::openhuman::credentials::AuthService; +use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider}; +use crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider; +use crate::openhuman::providers::traits::Provider; +use crate::openhuman::providers::ProviderRuntimeOptions; + +/// Sentinel meaning "use whatever primary_cloud resolves to". +pub const PROVIDER_CLOUD: &str = "cloud"; +/// Sentinel meaning "use the OpenHuman backend session JWT". +pub const PROVIDER_OPENHUMAN: &str = "openhuman"; +/// Prefix for Ollama-local providers: `"ollama:"`. +pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:"; +/// Prefix for OpenAI-compatible providers: `"openai:"`. +pub const OPENAI_PROVIDER_PREFIX: &str = "openai:"; +/// Prefix for Anthropic-compatible providers: `"anthropic:"`. +pub const ANTHROPIC_PROVIDER_PREFIX: &str = "anthropic:"; +/// Prefix for OpenRouter providers: `"openrouter:"`. +pub const OPENROUTER_PROVIDER_PREFIX: &str = "openrouter:"; +/// Prefix for custom OpenAI-compatible providers: `"custom:"`. +pub const CUSTOM_PROVIDER_PREFIX: &str = "custom:"; + +/// Return the configured provider string for a named workload role. +/// +/// Returns `"cloud"` when the workload has no explicit override, which causes +/// the factory to resolve via `primary_cloud`. +pub fn provider_for_role(role: &str, config: &Config) -> String { + let opt = match role { + "reasoning" => config.reasoning_provider.as_deref(), + "agentic" => config.agentic_provider.as_deref(), + "coding" => config.coding_provider.as_deref(), + // `memory_provider` covers both the memory-tree extract path and + // the summarizer sub-agent (whose definition declares + // `hint = "summarization"`). Both are "produce a condensed + // representation of input text" — same model class, no reason + // for a separate config knob. + "memory" | "summarization" => config.memory_provider.as_deref(), + "embeddings" => config.embeddings_provider.as_deref(), + "heartbeat" => config.heartbeat_provider.as_deref(), + "learning" => config.learning_provider.as_deref(), + "subconscious" => config.subconscious_provider.as_deref(), + _ => None, + }; + let s = opt.unwrap_or("").trim(); + if s.is_empty() { + PROVIDER_CLOUD.to_string() + } else { + s.to_string() + } +} + +/// Build a `(Provider, model)` for the given workload role. +/// +/// Equivalent to: +/// ```rust,ignore +/// let s = provider_for_role(role, config); +/// create_chat_provider_from_string(role, &s, config) +/// ``` +pub fn create_chat_provider( + role: &str, + config: &Config, +) -> anyhow::Result<(Box, String)> { + let s = provider_for_role(role, config); + log::debug!( + "[providers][chat-factory] create_chat_provider role={} resolved_string={}", + role, + s + ); + create_chat_provider_from_string(role, &s, config) +} + +/// Build a `(Provider, model)` from an explicit provider string and config. +/// +/// See module-level grammar documentation for valid formats. +pub fn create_chat_provider_from_string( + role: &str, + provider: &str, + config: &Config, +) -> anyhow::Result<(Box, String)> { + let p = provider.trim(); + log::debug!( + "[providers][chat-factory] create_chat_provider_from_string role={} provider={}", + role, + p + ); + + if p == PROVIDER_CLOUD || p.is_empty() { + return resolve_cloud_primary(role, config); + } + + if p == PROVIDER_OPENHUMAN { + return make_openhuman_backend(config); + } + + if let Some(model) = p.strip_prefix(OLLAMA_PROVIDER_PREFIX) { + if model.trim().is_empty() { + anyhow::bail!( + "[chat-factory] provider string '{}' for role '{}' has an empty model — \ + use 'ollama:'", + p, + role + ); + } + return make_ollama_provider(model.trim(), config); + } + + // Third-party cloud providers — look up the matching entry by type. + let (type_str, model) = parse_typed_prefix(p).ok_or_else(|| { + anyhow::anyhow!( + "[chat-factory] unrecognised provider string '{}' for role '{}'. \ + Valid forms: cloud, openhuman, ollama:, openai:, \ + anthropic:, openrouter:, custom:", + p, + role + ) + })?; + + if model.trim().is_empty() { + anyhow::bail!( + "[chat-factory] provider string '{}' for role '{}' has an empty model", + p, + role + ); + } + + let provider_type = match type_str { + "openai" => CloudProviderType::Openai, + "anthropic" => CloudProviderType::Anthropic, + "openrouter" => CloudProviderType::Openrouter, + "custom" => CloudProviderType::Custom, + other => anyhow::bail!( + "[chat-factory] unknown provider type '{}' in provider string '{}' for role '{}'", + other, + p, + role + ), + }; + + make_cloud_provider_by_type(role, &provider_type, model.trim(), config) +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Resolve the `"cloud"` sentinel by consulting `primary_cloud`. +fn resolve_cloud_primary( + role: &str, + config: &Config, +) -> anyhow::Result<(Box, String)> { + // Find the primary entry (or fall back to an OpenHuman entry / first entry). + let entry = if let Some(ref primary_id) = config.primary_cloud { + config.cloud_providers.iter().find(|e| &e.id == primary_id) + } else { + None + }; + + let entry = entry.or_else(|| { + // Implicit fallback: first openhuman entry, then any entry. + config + .cloud_providers + .iter() + .find(|e| e.r#type == CloudProviderType::Openhuman) + .or_else(|| config.cloud_providers.first()) + }); + + match entry { + None => { + // No cloud_providers configured at all — route to the OpenHuman backend. + log::debug!( + "[providers][chat-factory] no cloud_providers entries, \ + falling back to openhuman backend for role={}", + role + ); + make_openhuman_backend(config) + } + Some(e) if e.r#type == CloudProviderType::Openhuman => { + log::debug!( + "[providers][chat-factory] primary resolves to openhuman backend for role={}", + role + ); + make_openhuman_backend(config) + } + Some(e) => { + let model = e.default_model.clone(); + if model.trim().is_empty() { + anyhow::bail!( + "[chat-factory] primary cloud provider '{}' (type={}) has an empty \ + default_model — set a model for role '{}'", + e.id, + e.r#type.label(), + role + ); + } + log::info!( + "[providers][chat-factory] role={} resolved cloud→{}:{} endpoint_host={}", + role, + e.r#type.label(), + model, + redact_endpoint(&e.endpoint) + ); + let key = lookup_provider_key(&e.r#type, config)?; + let p = make_openai_compatible_provider(&e.endpoint, &key)?; + Ok((p, model)) + } + } +} + +/// Build the OpenHuman backend provider (session-JWT auth). +fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box, String)> { + let model = config + .default_model + .clone() + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| "reasoning-v1".to_string()); + // Critical: pass the *config's* workspace directory through so the + // provider's `AuthService` reads `auth-profiles.json` from the + // same dir login wrote to. Without this, `ProviderRuntimeOptions::default()` + // leaves `openhuman_dir = None`, the provider falls back to + // `~/.openhuman`, and reads an unrelated (or empty) + // profile store — surfacing as "No backend session: store a JWT + // via auth (app-session)" even though login just succeeded in the + // user's actual workspace (e.g. test workspaces under OPENHUMAN_WORKSPACE). + let options = ProviderRuntimeOptions { + openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), + secrets_encrypt: config.secrets.encrypt, + ..ProviderRuntimeOptions::default() + }; + log::debug!( + "[providers][chat-factory] building openhuman backend provider model={} state_dir={:?} secrets_encrypt={}", + model, + options.openhuman_dir, + options.secrets_encrypt + ); + // Translate `hint:` model strings into the OpenHuman backend's + // canonical tier names. The legacy `create_intelligent_routing_provider` + // path did this inside `IntelligentRoutingProvider::resolve_remote_model`; + // #1710's factory bypassed that wrapper, which broke the web-chat + // `model_override` contract (json_rpc_e2e `routing_cases`): + // `hint:reasoning` was reaching the backend verbatim instead of + // `reasoning-v1`. We apply ONLY the model-name mapping here — not the + // full routing wrapper, which also injects local-AI health probing and + // a streaming shim that the web-chat SSE path doesn't tolerate (it + // hangs `chat_done`). Mapping mirrors `resolve_remote_model`'s + // heavy-tier arm exactly: known heavy tiers map to `-v1`; + // lightweight hints (`hint:reaction`, …) and already-exact tier names + // pass through untouched. Third-party cloud providers never see + // `hint:` strings, so this stays scoped to the OpenHuman backend. + let model = match model.strip_prefix("hint:") { + Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(), + Some("chat") => crate::openhuman::config::MODEL_REASONING_QUICK_V1.to_string(), + Some("agentic") => crate::openhuman::config::MODEL_AGENTIC_V1.to_string(), + Some("coding") => crate::openhuman::config::MODEL_CODING_V1.to_string(), + _ => model, + }; + let p = Box::new(OpenHumanBackendProvider::new( + config.api_url.as_deref(), + &options, + )); + Ok((p, model)) +} + +/// Build an Ollama local provider. +fn make_ollama_provider( + model: &str, + config: &Config, +) -> anyhow::Result<(Box, String)> { + let base_url = config + .local_ai + .base_url + .as_deref() + .unwrap_or("http://localhost:11434"); + // Ollama exposes an OpenAI-compatible endpoint at /v1. + let endpoint = format!("{}/v1", base_url.trim_end_matches('/')); + log::info!( + "[providers][chat-factory] building ollama provider model={} endpoint_host={}", + model, + redact_endpoint(&endpoint) + ); + let p = make_openai_compatible_provider(&endpoint, "")?; + Ok((p, model.to_string())) +} + +/// Look up a cloud_providers entry by type and build the provider. +fn make_cloud_provider_by_type( + role: &str, + provider_type: &CloudProviderType, + model: &str, + config: &Config, +) -> anyhow::Result<(Box, String)> { + let entry = config + .cloud_providers + .iter() + .find(|e| &e.r#type == provider_type); + + let entry = entry.ok_or_else(|| { + anyhow::anyhow!( + "[chat-factory] no cloud provider configured for type '{}' (role '{}') — \ + add a {} entry to cloud_providers in config.toml", + provider_type.as_str(), + role, + provider_type.label() + ) + })?; + + log::info!( + "[providers][chat-factory] role={} type={} model={} endpoint_host={}", + role, + provider_type.label(), + model, + redact_endpoint(&entry.endpoint) + ); + let key = lookup_provider_key(provider_type, config)?; + let p = make_openai_compatible_provider(&entry.endpoint, &key)?; + Ok((p, model.to_string())) +} + +/// Fetch the encrypted bearer token for a cloud provider type from the +/// workspace `auth-profiles.json` via the shared [`AuthService`]. +/// +/// Each provider type maps to a default profile named `":default"` +/// (e.g. `"openai:default"`), mirroring how the Composio integration stores +/// `"composio-direct:default"`. Missing or empty keys return `Ok(String::new())` +/// — callers (and `make_openai_compatible_provider`) treat that as "no auth", +/// which surfaces an authentication error at first call rather than at factory +/// build time. This keeps the factory testable without forcing every test to +/// seed an auth profile. +fn lookup_provider_key( + provider_type: &CloudProviderType, + config: &Config, +) -> anyhow::Result { + // OpenHuman uses the session JWT path; no separate key here. + if matches!(provider_type, CloudProviderType::Openhuman) { + return Ok(String::new()); + } + let auth = AuthService::from_config(config); + let key = auth + .get_provider_bearer_token(provider_type.as_str(), None) + .map_err(|e| { + anyhow::anyhow!( + "[chat-factory] failed to read API key for provider '{}': {}", + provider_type.as_str(), + e + ) + })? + .unwrap_or_default(); + log::debug!( + "[providers][chat-factory] auth lookup type={} key_present={}", + provider_type.as_str(), + !key.is_empty() + ); + Ok(key) +} + +/// Build an `OpenAiCompatibleProvider` with Bearer auth. +fn make_openai_compatible_provider( + endpoint: &str, + api_key: &str, +) -> anyhow::Result> { + let key = if api_key.trim().is_empty() { + None + } else { + Some(api_key) + }; + Ok(Box::new(OpenAiCompatibleProvider::new( + "cloud", + endpoint, + key, + AuthStyle::Bearer, + ))) +} + +/// Return a safe-to-log representation of a URL endpoint: `scheme://host` only. +/// +/// User-configured endpoints can embed API keys or tokens in the query string +/// or even in the authority (e.g. `https://key@host/`). Logging the raw URL +/// violates the "never log secrets" rule. This helper strips everything except +/// the scheme and host so logs are still useful for debugging routing issues +/// without leaking credentials. +fn redact_endpoint(url: &str) -> String { + let trimmed = url.trim(); + // Try to extract scheme://host by splitting on "://" and then on "/", "@", "?". + if let Some(rest) = trimmed.split_once("://") { + let scheme = rest.0; + // Strip any userinfo (user:pass@host) and take only up to the first path/query. + let authority = rest.1.split('/').next().unwrap_or(""); + let host = authority.split('@').last().unwrap_or(authority); + let host_no_query = host.split('?').next().unwrap_or(host); + return format!("{}://{}", scheme, host_no_query); + } + // No "://" — probably a bare host or unrecognised; log a placeholder. + "".to_string() +} + +/// Split `"openai:gpt-4o"` → `("openai", "gpt-4o")`, or `None` if unrecognised. +fn parse_typed_prefix(s: &str) -> Option<(&str, &str)> { + for prefix in &[ + OPENAI_PROVIDER_PREFIX, + ANTHROPIC_PROVIDER_PREFIX, + OPENROUTER_PROVIDER_PREFIX, + CUSTOM_PROVIDER_PREFIX, + ] { + if let Some(model) = s.strip_prefix(prefix) { + let type_str = prefix.trim_end_matches(':'); + return Some((type_str, model)); + } + } + None +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::schema::cloud_providers::{ + CloudProviderCreds, CloudProviderType, + }; + use crate::openhuman::config::Config; + + fn config_with_providers( + providers: Vec, + primary: Option, + ) -> Config { + let mut c = Config::default(); + c.cloud_providers = providers; + c.primary_cloud = primary; + c + } + + fn oh_entry(id: &str) -> CloudProviderCreds { + CloudProviderCreds { + id: id.to_string(), + r#type: CloudProviderType::Openhuman, + endpoint: "https://api.openhuman.ai/v1".to_string(), + default_model: "reasoning-v1".to_string(), + } + } + + fn openai_entry(id: &str, model: &str) -> CloudProviderCreds { + CloudProviderCreds { + id: id.to_string(), + r#type: CloudProviderType::Openai, + endpoint: "https://api.openai.com/v1".to_string(), + default_model: model.to_string(), + } + } + + fn anthropic_entry(id: &str, model: &str) -> CloudProviderCreds { + CloudProviderCreds { + id: id.to_string(), + r#type: CloudProviderType::Anthropic, + endpoint: "https://api.anthropic.com/v1".to_string(), + default_model: model.to_string(), + } + } + + // ── Grammar: all recognised forms ──────────────────────────────────────── + + #[test] + fn openhuman_literal() { + let config = Config::default(); + let (_, model) = create_chat_provider_from_string("reasoning", "openhuman", &config) + .expect("openhuman literal must build"); + assert!(!model.is_empty(), "model must not be empty"); + } + + #[test] + fn cloud_no_providers_falls_back_to_openhuman() { + let config = Config::default(); + let result = create_chat_provider_from_string("reasoning", "cloud", &config); + assert!( + result.is_ok(), + "cloud fallback must succeed: {:?}", + result.err() + ); + } + + #[test] + fn cloud_with_openhuman_primary() { + let config = config_with_providers(vec![oh_entry("p_oh")], Some("p_oh".to_string())); + let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config) + .expect("cloud→openhuman primary must build"); + assert!(!model.is_empty()); + } + + #[test] + fn cloud_with_openai_primary() { + let config = config_with_providers( + vec![oh_entry("p_oh"), openai_entry("p_oai", "gpt-4o")], + Some("p_oai".to_string()), + ); + let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config) + .expect("cloud→openai primary must build"); + assert_eq!(model, "gpt-4o"); + } + + #[test] + fn openai_prefix() { + let config = config_with_providers(vec![openai_entry("p_oai", "gpt-4o")], None); + let (_, model) = create_chat_provider_from_string("agentic", "openai:gpt-4o-mini", &config) + .expect("openai: must build"); + assert_eq!(model, "gpt-4o-mini"); + } + + #[test] + fn anthropic_prefix() { + let config = + config_with_providers(vec![anthropic_entry("p_ant", "claude-sonnet-4-6")], None); + let (_, model) = + create_chat_provider_from_string("coding", "anthropic:claude-sonnet-4-6", &config) + .expect("anthropic: must build"); + assert_eq!(model, "claude-sonnet-4-6"); + } + + #[test] + fn openrouter_prefix() { + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_or".to_string(), + r#type: CloudProviderType::Openrouter, + endpoint: "https://openrouter.ai/api/v1".to_string(), + default_model: "openai/gpt-4o".to_string(), + }); + let (_, model) = create_chat_provider_from_string( + "agentic", + "openrouter:meta-llama/llama-3.1-8b", + &config, + ) + .expect("openrouter: must build"); + assert_eq!(model, "meta-llama/llama-3.1-8b"); + } + + #[test] + fn ollama_prefix() { + let config = Config::default(); + let (_, model) = + create_chat_provider_from_string("heartbeat", "ollama:llama3.1:8b", &config) + .expect("ollama: must build"); + assert_eq!(model, "llama3.1:8b"); + } + + // ── Workload routing ────────────────────────────────────────────────────── + + #[test] + fn all_workloads_default_to_cloud() { + let config = Config::default(); + for role in &[ + "reasoning", + "agentic", + "coding", + "memory", + "embeddings", + "heartbeat", + "learning", + "subconscious", + ] { + assert_eq!( + provider_for_role(role, &config), + "cloud", + "role={role} must default to cloud" + ); + } + } + + #[test] + fn workload_override_respected() { + let mut config = Config::default(); + config.heartbeat_provider = Some("ollama:llama3.2:3b".to_string()); + assert_eq!( + provider_for_role("heartbeat", &config), + "ollama:llama3.2:3b" + ); + assert_eq!(provider_for_role("reasoning", &config), "cloud"); + } + + #[test] + fn create_chat_provider_uses_role() { + let mut config = Config::default(); + config.cloud_providers.push(openai_entry("p_oai", "gpt-4o")); + config.primary_cloud = Some("p_oai".to_string()); + config.reasoning_provider = Some("openai:gpt-4o-mini".to_string()); + let (_, model) = + create_chat_provider("reasoning", &config).expect("create_chat_provider must succeed"); + assert_eq!(model, "gpt-4o-mini"); + } + + // ── Error cases ─────────────────────────────────────────────────────────── + + #[test] + fn unknown_provider_string_rejected() { + // `Result<(Box, String), _>` can't use `.expect_err` + // because `dyn Provider` doesn't implement `Debug` — drop the + // Ok via `.err()` and pattern on the Option instead. + let config = Config::default(); + let err = create_chat_provider_from_string("reasoning", "groq:llama3", &config) + .err() + .expect("unknown provider string must fail"); + assert!( + err.to_string().contains("unrecognised provider string"), + "{err}" + ); + } + + #[test] + fn empty_model_in_ollama_rejected() { + let config = Config::default(); + let err = create_chat_provider_from_string("reasoning", "ollama:", &config) + .err() + .expect("empty model must fail"); + assert!(err.to_string().contains("empty model"), "{err}"); + } + + #[test] + fn missing_creds_for_openai_gives_clear_error() { + // No openai entry in cloud_providers. + let config = Config::default(); + let err = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config) + .err() + .expect("missing creds must fail"); + let msg = err.to_string(); + assert!( + msg.contains("no cloud provider configured for type 'openai'"), + "{msg}" + ); + } + + #[test] + fn primary_cloud_defaults_to_openhuman_when_none() { + // primary_cloud=None → factory must still succeed by falling back + // to either an openhuman entry or the openhuman backend. + let config = Config::default(); + assert!(create_chat_provider("reasoning", &config).is_ok()); + } + + // ── Summarization alias ─────────────────────────────────────────────────── + + #[test] + fn summarization_aliases_memory_provider() { + // The summarizer sub-agent declares `[model] hint = "summarization"` + // but there's no `summarization_provider` config field — the workload + // is a synonym for `memory` since both are "condense input" tasks. + // `provider_for_role("summarization", ...)` must read `memory_provider`. + let mut config = Config::default(); + config.memory_provider = Some("ollama:llama3.1:8b".to_string()); + assert_eq!(provider_for_role("memory", &config), "ollama:llama3.1:8b"); + assert_eq!( + provider_for_role("summarization", &config), + "ollama:llama3.1:8b", + "summarization must alias memory_provider" + ); + } + + #[test] + fn summarization_defaults_to_cloud_like_memory() { + // No memory_provider → both `memory` and `summarization` fall through + // to "cloud", consistent with every other workload. + let config = Config::default(); + assert_eq!(provider_for_role("memory", &config), "cloud"); + assert_eq!(provider_for_role("summarization", &config), "cloud"); + } + + #[test] + fn unknown_workload_falls_back_to_cloud() { + // The wildcard arm in provider_for_role's match must keep + // unrecognised workloads on the primary so a typo in an agent + // TOML doesn't surface as a NoneProvider crash. + let config = Config::default(); + assert_eq!(provider_for_role("nope-not-a-workload", &config), "cloud"); + assert_eq!(provider_for_role("", &config), "cloud"); + } + + // ── OpenHuman backend state_dir wiring ──────────────────────────────────── + + #[test] + fn openhuman_backend_uses_config_path_parent_as_state_dir() { + // Regression test for #1710: when the user's workspace lives outside + // ~/.openhuman (e.g. OPENHUMAN_WORKSPACE override, or a test + // worktree), the factory must propagate config.config_path.parent() + // into ProviderRuntimeOptions.openhuman_dir so the backend's + // AuthService reads `auth-profiles.json` from the same workspace + // login wrote to. Without this, login succeeds but every chat + // call bails with "No backend session". + let mut config = Config::default(); + config.config_path = std::path::PathBuf::from("/tmp/oh-test-workspace/config.toml"); + // Build via the chat-factory entrypoint — make_openhuman_backend + // is private and called transitively when there are no + // cloud_providers entries. + let (_provider, model) = create_chat_provider("reasoning", &config) + .expect("openhuman backend must build with no cloud_providers"); + // Sanity: the model resolution path also goes through the + // backend ctor, so a successful build implies state_dir was + // wired without panic. + assert!(!model.is_empty(), "model must be set"); + } +} diff --git a/src/openhuman/providers/mod.rs b/src/openhuman/providers/mod.rs index 6d41a64c4..d603adb9d 100644 --- a/src/openhuman/providers/mod.rs +++ b/src/openhuman/providers/mod.rs @@ -1,5 +1,6 @@ pub mod billing_error; pub mod compatible; +pub mod factory; pub mod openhuman_backend; pub mod ops; pub mod reliable; @@ -14,4 +15,5 @@ pub use traits::{ }; pub use billing_error::is_budget_exhausted_message; +pub use factory::{create_chat_provider, provider_for_role}; pub use ops::*; diff --git a/src/openhuman/subconscious/executor.rs b/src/openhuman/subconscious/executor.rs index d22fa5188..a9e830520 100644 --- a/src/openhuman/subconscious/executor.rs +++ b/src/openhuman/subconscious/executor.rs @@ -105,7 +105,7 @@ pub async fn execute_task( } else { // Simple text-only task. Use local model if configured for subconscious // tasks, otherwise fall back to the cloud agentic analysis path. - if config.local_ai.use_local_for_subconscious() { + if config.workload_uses_local("subconscious") { debug!( "[subconscious:executor] text task: id={} — using local model", task.id diff --git a/src/openhuman/tools/impl/cron/run.rs b/src/openhuman/tools/impl/cron/run.rs index 43850d4d9..949491334 100644 --- a/src/openhuman/tools/impl/cron/run.rs +++ b/src/openhuman/tools/impl/cron/run.rs @@ -145,24 +145,31 @@ mod tests { let result = tool.execute(json!({ "job_id": job.id })).await.unwrap(); if cfg!(windows) { - // `echo` may not be available as a standalone executable on Windows; - // verify the expected failure mode without short-circuiting the test. - assert!(result.is_error); - assert!( - result.output().contains("spawn error"), - "{:?}", - result.output() - ); + // Windows is platform-dependent for `echo`: cmd.exe treats it + // as a shell built-in (no standalone executable), but a dev + // box with Git Bash on PATH exposes a real `echo.exe` that + // succeeds. Both outcomes are valid; assert only that we + // get a deterministic ToolResult and that the runs ledger + // matches the success/failure decision. + if result.is_error { + assert!( + result.output().contains("spawn error"), + "expected spawn-error explanation on Windows failure path: {:?}", + result.output() + ); + let runs = cron::list_runs(&cfg, &job.id, 10).unwrap(); + assert_eq!(runs.len(), 0, "spawn failure must not persist a run"); + } else { + let runs = cron::list_runs(&cfg, &job.id, 10).unwrap(); + assert_eq!( + runs.len(), + 1, + "successful run must persist exactly one entry" + ); + } } else { assert!(!result.is_error, "{:?}", result.output()); - } - - // History persistence should be verified on all platforms. - let runs = cron::list_runs(&cfg, &job.id, 10).unwrap(); - if cfg!(windows) { - // On Windows the job fails to spawn, so no run record is expected. - assert_eq!(runs.len(), 0); - } else { + let runs = cron::list_runs(&cfg, &job.id, 10).unwrap(); assert_eq!(runs.len(), 1); } } diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index 06568010c..cdd3664b5 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -158,12 +158,23 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> { on_progress: None, }; - let def = + let mut def = openhuman_core::openhuman::agent::harness::definition::AgentDefinitionRegistry::global() .unwrap() .get("integrations_agent") .unwrap() .clone(); + // `integrations_agent` ships with `[model] hint = "agentic"`. After + // #1710, a Hint sub-agent builds a fresh provider via the workload + // factory instead of inheriting `parent.provider` — which here would + // resolve to the OpenHuman backend and fail with "No backend session" + // before the MockCalendarProvider ever sees a request. This test only + // asserts prompt construction (the "Current Date & Time" context), so + // override the model spec to Inherit to keep the real integrations_agent + // definition (prompt, tools, scope) while routing through the captured + // mock provider. Provider *routing* for Hint sub-agents is covered by + // `subagent_runner::ops::tests::resolve_subagent_provider_*`. + def.model = openhuman_core::openhuman::agent::harness::definition::ModelSpec::Inherit; let _ = openhuman_core::openhuman::agent::harness::with_parent_context(parent, async { openhuman_core::openhuman::agent::harness::run_subagent(