feat(ai): unified per-workload provider routing + chat-provider factory (#1710) (#1858)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
sanil-23
2026-05-15 15:54:06 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 2d54e46296
commit e052aadfe9
58 changed files with 5367 additions and 2244 deletions
+4 -1
View File
@@ -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",
+4 -4
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -150,7 +150,7 @@ const SettingsHome = () => {
/>
</svg>
),
onClick: () => navigateToSettings('ai-models'),
onClick: () => navigateToSettings('ai'),
},
],
},
@@ -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 () => {
@@ -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':
File diff suppressed because it is too large Load Diff
@@ -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<RoleHint, { label: string; help: string }> = {
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<RoleHint, string>;
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 Anthropics 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<ClientConfig | null>(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<RoleModels>(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<string>(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 (
<div>
<SettingsHeader
title="LLM Provider"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-5">
<p className="text-sm text-stone-500 leading-relaxed">
Pick where inference runs. Any OpenAI-compatible provider (OpenAI, Anthropic, OpenRouter,
Ollama, your own gateway).
</p>
{!loaded ? (
<div className="text-sm text-stone-400 animate-pulse">Loading current settings</div>
) : (
<>
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
Provider
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{PROVIDER_PRESETS.map(preset => {
const selected = preset.id === activePreset.id;
return (
<button
key={preset.id}
type="button"
onClick={() => applyPreset(preset)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors ${
selected ? preset.tint.selected : `bg-white ${preset.tint.idle}`
}`}
aria-pressed={selected}>
<span
className={`h-2 w-2 shrink-0 rounded-full ${preset.tint.dot}`}
aria-hidden="true"
/>
<span className="text-sm font-medium text-stone-800 truncate">
{preset.label}
</span>
</button>
);
})}
</div>
<p className="text-xs text-stone-400">{activePreset.note}</p>
</section>
{isOpenHuman && (
<div className="rounded-lg border border-sage-300 bg-sage-50 p-3 flex gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-sage-700">
Congrats! Youre using the most optimized setup
</p>
<p className="mt-1 text-sm text-sage-900 leading-relaxed">
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.
</p>
</div>
</div>
)}
{!isOpenHuman && (
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3">
<p className="mt-1 text-sm text-primary-900 leading-relaxed">
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.
</p>
</div>
)}
{activePreset.id === 'custom' && (
<section className="space-y-2">
<label
htmlFor="llm-api-url"
className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
API URL
</label>
<input
id="llm-api-url"
type="url"
value={apiUrl}
onChange={e => {
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}
/>
<p className="text-xs text-stone-400">
Full URL of the OpenAI-compatible chat-completions endpoint for your gateway.
</p>
</section>
)}
{!isOpenHuman && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="llm-api-key"
className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
API Key
</label>
{client?.api_key_set && (
<button
type="button"
onClick={handleClearKey}
disabled={saving}
className="text-xs text-coral-600 hover:text-coral-700 disabled:opacity-50">
Clear stored key
</button>
)}
</div>
<input
id="llm-api-key"
type="password"
value={apiKey}
onChange={e => {
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}
/>
<p className="text-xs text-stone-400">
{client?.api_key_set ? 'A key is currently saved.' : 'No key is currently saved.'}
</p>
</section>
)}
{!isOpenHuman && (
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wide text-stone-500">
Models by Role
</label>
<p className="text-xs text-stone-400">
The core router dispatches each task to the right model. Leave a field blank to
skip routing for that role.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ROLE_HINTS.map(hint => (
<div key={hint} className="space-y-1">
<label
htmlFor={`llm-role-${hint}`}
className="block text-xs font-medium text-stone-700">
{ROLE_LABELS[hint].label}
</label>
<input
id={`llm-role-${hint}`}
type="text"
value={roleModels[hint]}
onChange={e => {
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}
/>
</div>
))}
</div>
</section>
)}
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50">
{saving ? 'Saving' : 'Save'}
</button>
{status.kind === 'ok' && (
<span className="text-sm text-sage-700">{status.message}</span>
)}
{status.kind === 'error' && (
<span className="text-sm text-coral-700">{status.message}</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default BackendProviderPanel;
@@ -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<LocalAiStatus | null>(null);
const [downloads, setDownloads] = useState<LocalAiDownloadsProgress | null>(null);
const [statusError, setStatusError] = useState<string>('');
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const [presetsData, setPresetsData] = useState<PresetsResponse | null>(null);
const [presetsLoading, setPresetsLoading] = useState(true);
const [presetError, setPresetError] = useState('');
const [presetSuccess, setPresetSuccess] = useState<ApplyPresetResult | null>(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<LlmBackend>('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<string, unknown>;
const usage = (localAi.usage ?? {}) as Record<string, unknown>;
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<typeof usageFlags>) => {
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(<real tier>)` 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 (
<div>
<SettingsHeader
title="Local AI Model"
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<DeviceCapabilitySection
presetsData={presetsData}
presetsLoading={presetsLoading}
presetError={presetError}
presetSuccess={presetSuccess}
formatRamGb={formatRamGb}
ollamaAvailable={downloads?.ollama_available ?? true}
onTriggerOllamaInstall={() => 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) && (
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Model Status</h3>
<div className="text-sm text-stone-600">
State:{' '}
<span
className={`font-medium ${
currentState === 'ready'
? 'text-green-600'
: currentState === 'downloading' || currentState === 'installing'
? 'text-primary-600'
: currentState === 'degraded'
? 'text-amber-700'
: 'text-stone-700'
}`}>
{currentState ?? 'unknown'}
</span>
</div>
{(currentState === 'downloading' || isInstalling) && (
<div className="space-y-2">
<div className="w-full h-2 rounded-full bg-stone-200 overflow-hidden">
{isIndeterminateDownload ? (
<div className="h-full bg-primary-500 animate-pulse rounded-full w-1/2" />
) : (
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{ width: `${String(Math.min(progress ?? 0, 100))}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs text-stone-500">
<span>
{typeof downloadedBytes === 'number'
? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: ''}
</span>
<span>
{typeof speedBps === 'number' && speedBps > 0
? `${formatBytes(speedBps)}/s`
: ''}
{etaSeconds ? ` · ${formatEta(etaSeconds)}` : ''}
</span>
</div>
</div>
)}
{bootstrapMessage && <div className="text-xs text-green-700">{bootstrapMessage}</div>}
<div className="flex gap-2">
<button
type="button"
onClick={() => void triggerDownload(false)}
disabled={!runtimeEnabled || isTriggeringDownload}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isTriggeringDownload ? 'Downloading…' : 'Download Models'}
</button>
<button
type="button"
onClick={() => void loadStatus()}
className="rounded-lg border border-stone-300 bg-stone-100 px-3 py-2 text-sm text-stone-700">
Refresh
</button>
</div>
{statusError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{statusError}
</div>
)}
</section>
)}
<section className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-stone-900">Usage</h3>
<p className="text-xs text-stone-500 mt-0.5">
Choose which subsystems run on the local model. Anything off uses the cloud.
</p>
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={usageFlags.runtime_enabled}
disabled={usageSaving}
onChange={e => void updateUsage({ runtime_enabled: e.target.checked })}
/>
<div>
<div className="text-sm text-stone-900">Enable local AI runtime</div>
<div className="text-xs text-stone-500">
Master switch. Off by default Ollama stays idle. When on, the tree summarizer,
screen intelligence, and autocomplete always use the local model.
</div>
</div>
</label>
<div className={`space-y-2 pl-6 ${usageFlags.runtime_enabled ? '' : 'opacity-50'}`}>
{/* 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. */}
<label
className="flex items-start gap-3 cursor-pointer"
data-testid="local-ai-usage-memory-summarizer">
<input
type="checkbox"
className="mt-0.5"
checked={summarizerBackend === 'local'}
disabled={!usageFlags.runtime_enabled || summarizerSaving}
onChange={e => void updateSummarizerBackend(e.target.checked ? 'local' : 'cloud')}
/>
<div>
<div className="text-sm text-stone-900">Memory summarizer</div>
<div className="text-xs text-stone-500">
Run memory-tree extract + summarise locally instead of in the cloud. The local
model used comes from the Model Tier preset above.
</div>
</div>
</label>
{(
[
{
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 }) => (
<label key={key} className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={usageFlags[key]}
disabled={!usageFlags.runtime_enabled || usageSaving}
onChange={e => void updateUsage({ [key]: e.target.checked })}
/>
<div>
<div className="text-sm text-stone-900">{label}</div>
<div className="text-xs text-stone-500">{hint}</div>
</div>
</label>
))}
</div>
{usageError && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-600">
{usageError}
</div>
)}
</section>
<button
type="button"
onClick={() => {
if (runtimeEnabled) navigateToSettings('local-model-debug');
}}
disabled={!runtimeEnabled}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors disabled:opacity-50 disabled:hover:text-stone-400">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
};
export default LocalModelPanel;
@@ -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<LocalAiStatus>);
vi.mocked(openhumanLocalAiDownload).mockResolvedValue({
result: disabledStatus,
logs: [],
} as CommandResponse<LocalAiStatus>);
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(<AIPanel />, { initialEntries: ['/settings/ai'] });
it('renders the three section labels', async () => {
renderWithProviders(<AIPanel />);
// 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(<AIPanel />);
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(<AIPanel />);
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();
}
});
});
@@ -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<string, unknown> = {}) {
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<ReturnType<typeof openhumanGetClientConfig>>);
}
function mockUpdateOk() {
vi.mocked(openhumanUpdateModelSettings).mockResolvedValue({
result: { config: {}, workspace_dir: '', config_path: '' },
messages: [],
} as unknown as Awaited<ReturnType<typeof openhumanUpdateModelSettings>>);
}
describe('BackendProviderPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateOk();
});
it('renders all provider preset chips and the OpenHuman success banner by default', async () => {
mockClientConfig();
renderWithProviders(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
// 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(<BackendProviderPanel />);
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(<BackendProviderPanel />);
await waitFor(() => {
expect(screen.getByText(/Failed to load current settings: offline/i)).toBeTruthy();
});
});
});
@@ -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<ConfigSnapshot> => ({
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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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(<LocalModelPanel />, { 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();
});
});
+13 -33
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -197,27 +196,11 @@ const aiModelsSettingsItems = [
</svg>
),
},
{
id: 'backend-provider',
title: 'LLM Provider',
description: 'Point inference at the OpenHuman backend or any OpenAI-compatible provider',
route: 'backend-provider',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V7zm0 10a2 2 0 012-2h12a2 2 0 012 2v0a2 2 0 01-2 2H6a2 2 0 01-2-2v0zM7 9h.01M7 17h.01"
/>
</svg>
),
},
{
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -279,12 +262,12 @@ const Settings = () => {
)}
/>
<Route
path="ai-models"
path="ai"
element={wrapSettingsPage(
<SettingsSectionPage
title="AI & Models"
description="Local AI model setup and backend provider configuration."
items={aiModelsSettingsItems}
title="AI"
description="Language model providers, local Ollama, and voice (STT / TTS)."
items={aiSettingsItems}
/>
)}
/>
@@ -314,16 +297,13 @@ const Settings = () => {
<Route path="notifications" element={wrapSettingsPage(<NotificationsPanel />)} />
<Route path="mascot" element={wrapSettingsPage(<MascotPanel />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
{/* AI & Models leaf panels */}
<Route path="local-model" element={wrapSettingsPage(<LocalModelPanel />)} />
<Route path="backend-provider" element={wrapSettingsPage(<BackendProviderPanel />)} />
{/* Developer Options */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route
path="notification-routing"
element={wrapSettingsPage(<NotificationRoutingPanel />)}
/>
<Route path="ai" element={wrapSettingsPage(<AIPanel />)} />
<Route path="llm" element={wrapSettingsPage(<AIPanel />)} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route
+344
View File
@@ -0,0 +1,344 @@
/**
* AI settings façade for the unified Settings → AI panel.
*
* Sits between the panel's React state and the Rust JSON-RPC core. Three
* orthogonal surfaces in one place:
*
* 1. Cloud providers + per-workload routing → `openhuman.update_model_settings`
* 2. API keys for cloud providers → `openhuman.auth_*_provider_credentials`
* (encrypted at rest in
* `auth-profiles.json`)
* 3. Local provider (Ollama) status + models → existing `localAi.ts` exports
* (re-exported here for symmetry)
*
* The panel itself never imports `coreRpcClient` directly — every call goes
* through this file. Keeps the wiring testable and the panel focused on
* presentation.
*/
import {
authListProviderCredentials,
type AuthProfileSummary,
authRemoveProviderCredentials,
authStoreProviderCredentials,
} from '../../utils/tauriCommands/auth';
import {
type ClientConfig,
type CloudProviderCreds,
type CloudProviderType,
type ModelSettingsUpdate,
openhumanGetClientConfig,
openhumanUpdateLocalAiSettings,
openhumanUpdateModelSettings,
} from '../../utils/tauriCommands/config';
import {
type LocalAiDiagnostics,
type LocalAiStatus,
type ModelPresetResult,
openhumanLocalAiApplyPreset,
openhumanLocalAiDiagnostics,
openhumanLocalAiDownload,
openhumanLocalAiPresets,
openhumanLocalAiSetOllamaPath,
openhumanLocalAiShutdownOwned,
openhumanLocalAiStatus,
type PresetsResponse,
} from '../../utils/tauriCommands/localAi';
// ─── Domain types — what the AIPanel consumes ──────────────────────────────
export type WorkloadId =
| 'reasoning'
| 'agentic'
| 'coding'
| 'memory'
| 'embeddings'
| 'heartbeat'
| 'learning'
| 'subconscious';
export const CHAT_WORKLOADS: WorkloadId[] = ['reasoning', 'agentic', 'coding'];
export const BACKGROUND_WORKLOADS: WorkloadId[] = [
'memory',
'embeddings',
'heartbeat',
'learning',
'subconscious',
];
export const ALL_WORKLOADS: WorkloadId[] = [...CHAT_WORKLOADS, ...BACKGROUND_WORKLOADS];
/** Provider reference parsed from a stored provider-string. */
export type ProviderRef =
| { kind: 'primary' }
| { kind: 'cloud'; providerType: CloudProviderType; model: string }
| { kind: 'local'; model: string };
/**
* Cloud provider entry as the UI sees it — endpoint config plus a derived
* `has_api_key` flag (true when a key is stored in `auth-profiles.json`).
*/
export interface CloudProviderView extends CloudProviderCreds {
has_api_key: boolean;
}
/** Single in-memory snapshot the AI panel renders against. */
export interface AISettings {
cloudProviders: CloudProviderView[];
primaryCloudId: string | null;
routing: Record<WorkloadId, ProviderRef>;
}
// ─── Read path: load + parse ───────────────────────────────────────────────
const PROVIDER_PREFIXES: Record<string, CloudProviderType> = {
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<AISettings> {
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<WorkloadId, ProviderRef> = {
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<void> {
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<void> {
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<void> {
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<LocalProviderSnapshot> {
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<void> {
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<void> {
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:<model>` 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<void> {
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 };
+1 -1
View File
@@ -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';
+65
View File
@@ -90,3 +90,68 @@ export async function openhumanDecryptSecret(ciphertext: string): Promise<Comman
params: { ciphertext },
});
}
/**
* Summary of one stored provider credential profile. Mirrors the Rust
* `credentials::responses::AuthProfileSummary` — no secret material is
* carried over the wire; only existence + metadata.
*/
export interface AuthProfileSummary {
id: string;
provider: string;
profile_name: string;
kind: 'token' | 'oauth';
account_id?: string | null;
workspace_id?: string | null;
has_token?: boolean;
metadata?: Record<string, string>;
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<string, string>;
setActive?: boolean;
}): Promise<CommandResponse<AuthProfileSummary>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AuthProfileSummary>>({
method: 'openhuman.auth_store_provider_credentials',
params: args,
});
}
/** Remove a stored provider credential profile. */
export async function authRemoveProviderCredentials(args: {
provider: string;
profile?: string;
}): Promise<CommandResponse<{ removed: boolean; provider: string; profile: string }>> {
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<CommandResponse<AuthProfileSummary[]>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<AuthProfileSummary[]>>({
method: 'openhuman.auth_list_provider_credentials',
params: provider ? { provider } : {},
});
}
+56 -7
View File
@@ -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<CommandResponse<ClientConfig>> {
+12
View File
@@ -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<CommandResponse<LocalAiStatus>> {
return await callCoreRpc<CommandResponse<LocalAiStatus>>({
method: 'openhuman.local_ai_shutdown_owned',
params: {},
});
}
+529
View File
@@ -0,0 +1,529 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenHuman · AI settings (preview)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'Consolas', 'monospace'],
},
colors: {
primary: { 50: '#EFF6FF', 100: '#DBEAFE', 200: '#BFDBFE', 300: '#93C5FD', 400: '#60A5FA', 500: '#2F6EF4', 600: '#2563EB', 700: '#1D4ED8' },
sage: { 50: '#F0FDF4', 100: '#DCFCE7', 200: '#BBF7D0', 500: '#34C759', 600: '#16A34A', 700: '#15803D' },
amber: { 50: '#FFFBEB', 100: '#FEF3C7', 200: '#FDE68A', 500: '#E8A728', 600: '#D97706', 700: '#B45309' },
coral: { 50: '#FEF2F2', 100: '#FEE2E2', 200: '#FECACA', 500: '#EF4444', 600: '#DC2626' },
stone: { 50: '#FAFAFA', 100: '#F5F5F5', 200: '#E5E5E5', 300: '#D4D4D4', 400: '#A3A3A3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717' },
slate: { 50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 500: '#64748B' },
},
boxShadow: {
subtle: '0 1px 2px 0 rgba(0,0,0,0.03), 0 1px 3px 0 rgba(0,0,0,0.04)',
soft: '0 2px 8px -2px rgba(0,0,0,0.08), 0 4px 12px -4px rgba(0,0,0,0.08)',
float: '0 12px 32px -8px rgba(0,0,0,0.12), 0 24px 48px -12px rgba(0,0,0,0.12)',
},
animation: {
'fade-up': 'fadeUp 0.32s cubic-bezier(0.4,0,0.2,1)',
'glow-pulse': 'glowPulse 2s cubic-bezier(0.4,0,0.6,1) infinite',
},
keyframes: {
fadeUp: { '0%': { opacity: 0, transform: 'translateY(8px)' }, '100%': { opacity: 1, transform: 'translateY(0)' } },
glowPulse: { '0%,100%': { opacity: 1 }, '50%': { opacity: 0.55 } },
},
},
},
};
</script>
<script defer src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js"></script>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
html, body {
font-family: 'Inter', system-ui, sans-serif;
background: #F5F5F5;
color: #171717;
}
[x-cloak] { display: none !important; }
.ring-inset { --tw-ring-inset: inset; }
</style>
</head>
<body class="min-h-screen py-8">
<!-- Approximates the WrappedSettingsPage container: max-w-lg, rounded-2xl, shadow-soft -->
<div class="mx-auto max-w-lg overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft">
<!-- SettingsHeader (matches real component: px-5 pt-5 pb-3, text-sm font-semibold) -->
<div class="px-5 pb-3 pt-5">
<div class="flex items-center">
<button class="mr-2 flex h-6 w-6 items-center justify-center rounded-full text-stone-500 hover:bg-stone-100">
<i data-lucide="chevron-left" class="h-4 w-4"></i>
</button>
<nav class="mr-1">
<ol class="flex items-center gap-1">
<li class="flex items-center gap-1">
<span class="text-xs text-stone-400">Settings</span>
<i data-lucide="chevron-right" class="h-3 w-3 text-stone-300"></i>
</li>
</ol>
</nav>
<h2 class="text-sm font-semibold text-stone-900">AI</h2>
<span class="ml-auto font-mono text-[9px] uppercase tracking-widest text-stone-300">preview</span>
</div>
</div>
<div x-data="aiSettings()" x-init="$nextTick(() => lucide.createIcons())" x-cloak class="space-y-4 p-4">
<!-- ─── Cloud providers ─────────────────────────────────────── -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold uppercase tracking-wide text-stone-500">Cloud providers</h3>
<button class="inline-flex items-center gap-1 rounded-md border border-stone-200 px-2 py-1 text-xs font-medium text-stone-700 hover:border-primary-300 hover:bg-primary-50/40 hover:text-primary-700">
<i data-lucide="plus" class="h-3 w-3"></i> Add
</button>
</div>
<div class="space-y-2">
<template x-for="p in draft.cloudProviders" :key="p.id">
<div
class="group relative flex overflow-hidden rounded-lg border"
:class="
p.id === draft.primaryCloudId
? 'border-primary-300 bg-primary-50/30 ring-1 ring-primary-100'
: 'border-stone-200 bg-stone-50'
">
<div class="w-0.5 shrink-0" :class="providerMeta[p.type].rail"></div>
<div class="flex flex-1 flex-col gap-2 p-3">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-sm font-semibold text-stone-900" x-text="p.label"></span>
<span
x-show="p.id === draft.primaryCloudId"
class="rounded bg-primary-500 px-1.5 py-0.5 font-mono text-[9px] font-medium uppercase tracking-widest text-white">
Primary
</span>
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset"
:class="providerMeta[p.type].pill">
<i :data-lucide="providerMeta[p.type].icon" class="h-3 w-3"></i>
<span x-text="providerMeta[p.type].label"></span>
</span>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<button
x-show="p.id !== draft.primaryCloudId"
@click="draft.primaryCloudId = p.id; recordDirty();"
class="rounded px-1.5 py-0.5 text-[11px] font-medium text-primary-600 hover:bg-primary-100/60">
Set primary
</button>
<button class="rounded p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-700">
<i data-lucide="pencil-line" class="h-3 w-3"></i>
</button>
<button
x-show="p.type !== 'openhuman'"
class="rounded p-1 text-stone-400 hover:bg-coral-50 hover:text-coral-600">
<i data-lucide="trash-2" class="h-3 w-3"></i>
</button>
</div>
</div>
<template x-if="p.type === 'openhuman'">
<div class="text-xs text-stone-500">Signed-in default · no configuration needed</div>
</template>
<template x-if="p.type !== 'openhuman'">
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-[11px]">
<dt class="text-stone-400">Endpoint</dt>
<dd class="truncate font-mono text-stone-700" x-text="p.endpoint"></dd>
<dt class="text-stone-400">Key</dt>
<dd class="flex items-center gap-1 truncate font-mono text-stone-700">
<i data-lucide="key" class="h-2.5 w-2.5 text-stone-400"></i>
<span class="truncate" x-text="p.maskedKey"></span>
</dd>
<dt class="text-stone-400">Model</dt>
<dd class="truncate font-mono text-stone-700" x-text="p.defaultModel"></dd>
</dl>
</template>
</div>
</div>
</template>
</div>
</section>
<!-- ─── Local provider ──────────────────────────────────────── -->
<section class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-stone-500">Local provider</h3>
<div class="overflow-hidden rounded-lg border border-stone-200 bg-stone-50">
<div class="flex items-center gap-2 border-b border-stone-200 px-3 py-2.5">
<span class="relative inline-flex h-2 w-2 items-center justify-center">
<span class="absolute inset-0 animate-glow-pulse rounded-full bg-sage-500"></span>
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium capitalize text-stone-900">running</div>
<div class="font-mono text-[10px] text-stone-400">ollama · v0.3.14</div>
</div>
<button class="inline-flex items-center gap-1 rounded-md border border-stone-200 bg-white px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50">
<i data-lucide="power" class="h-3 w-3"></i> Stop
</button>
<button class="inline-flex items-center gap-1 rounded-md border border-stone-200 bg-white px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50">
<i data-lucide="refresh-cw" class="h-3 w-3"></i> Update
</button>
</div>
<ul class="divide-y divide-stone-200">
<template x-for="m in installedModels" :key="m.id">
<li class="flex items-center gap-2 px-3 py-2">
<i data-lucide="cpu" class="h-3 w-3 text-stone-400"></i>
<span class="flex-1 truncate font-mono text-xs text-stone-800" x-text="m.id"></span>
<span class="font-mono text-[10px] text-stone-400" x-text="formatBytes(m.sizeBytes)"></span>
<button class="rounded p-1 text-stone-400 hover:bg-coral-50 hover:text-coral-600">
<i data-lucide="trash-2" class="h-3 w-3"></i>
</button>
</li>
</template>
</ul>
<div class="border-t border-stone-200 px-3 py-2">
<button class="inline-flex items-center gap-1 rounded-md border border-dashed border-stone-300 bg-white px-2 py-1 text-xs font-medium text-stone-600 hover:border-primary-400 hover:text-primary-700">
<i data-lucide="download" class="h-3 w-3"></i> Pull a model
</button>
</div>
</div>
</section>
<!-- ─── Workload routing ────────────────────────────────────── -->
<section class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wide text-stone-500">Workload routing</h3>
<div class="inline-flex items-center rounded-full bg-stone-100 p-0.5">
<button @click="applyPreset('cloud')" class="rounded-full px-2 py-0.5 text-[10px] font-medium text-stone-600 hover:text-stone-900">
Cloud
</button>
<button @click="applyPreset('local')" class="rounded-full px-2 py-0.5 text-[10px] font-medium text-stone-600 hover:text-stone-900">
Local
</button>
<button @click="applyPreset('mixed')" class="rounded-full bg-white px-2 py-0.5 text-[10px] font-medium text-stone-900 shadow-subtle ring-1 ring-stone-200">
Mixed
</button>
</div>
</div>
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3">
<div class="pt-3">
<div class="text-[10px] font-semibold uppercase tracking-wide text-stone-400">Chat</div>
<div class="divide-y divide-stone-200">
<template x-for="w in chatWorkloads" :key="w.id">
<div class="space-y-2 py-2.5" x-data="{ w: w }">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-sm font-medium text-stone-900" x-text="w.label"></div>
<div class="truncate text-xs text-stone-500" x-text="w.description"></div>
</div>
<div class="inline-flex shrink-0 items-center rounded bg-stone-100 p-0.5">
<button @click="setRouting(w.id, { kind: 'primary' })" :class="tabClass(draft.routing[w.id].kind === 'primary')">Primary</button>
<button @click="setCloud(w.id)" :class="tabClass(draft.routing[w.id].kind === 'cloud')">Cloud</button>
<button @click="setLocal(w.id)" :class="tabClass(draft.routing[w.id].kind === 'local')">Local</button>
</div>
</div>
<template x-if="draft.routing[w.id].kind === 'primary'">
<div class="text-right font-mono text-[11px] text-stone-400" x-text="'↳ ' + resolvedLabel(w.id)"></div>
</template>
<template x-if="draft.routing[w.id].kind === 'cloud'">
<div class="flex items-center justify-end gap-1.5">
<select
@change="onCloudProviderChange($event, w.id)"
class="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">
<template x-for="cp in draft.cloudProviders" :key="cp.id">
<option :value="cp.id" :selected="draft.routing[w.id].providerId === cp.id" x-text="providerMeta[cp.type].label"></option>
</template>
</select>
<template x-if="selectedCloudType(w.id) !== 'openhuman'">
<input
:value="draft.routing[w.id].model"
@input="draft.routing[w.id].model = $event.target.value; recordDirty();"
class="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" />
</template>
</div>
</template>
<template x-if="draft.routing[w.id].kind === 'local'">
<div class="flex justify-end">
<select
@change="draft.routing[w.id].model = $event.target.value; recordDirty();"
class="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">
<template x-for="m in installedModels" :key="m.id">
<option :value="m.id" :selected="draft.routing[w.id].model === m.id" x-text="m.id"></option>
</template>
</select>
</div>
</template>
</div>
</template>
</div>
</div>
<div class="pb-3 pt-3">
<div class="text-[10px] font-semibold uppercase tracking-wide text-stone-400">Background</div>
<div class="divide-y divide-stone-200">
<template x-for="w in bgWorkloads" :key="w.id">
<div class="space-y-2 py-2.5" x-data="{ w: w }">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-sm font-medium text-stone-900" x-text="w.label"></div>
<div class="truncate text-xs text-stone-500" x-text="w.description"></div>
</div>
<div class="inline-flex shrink-0 items-center rounded bg-stone-100 p-0.5">
<button @click="setRouting(w.id, { kind: 'primary' })" :class="tabClass(draft.routing[w.id].kind === 'primary')">Primary</button>
<button @click="setCloud(w.id)" :class="tabClass(draft.routing[w.id].kind === 'cloud')">Cloud</button>
<button @click="setLocal(w.id)" :class="tabClass(draft.routing[w.id].kind === 'local')">Local</button>
</div>
</div>
<template x-if="draft.routing[w.id].kind === 'primary'">
<div class="text-right font-mono text-[11px] text-stone-400" x-text="'↳ ' + resolvedLabel(w.id)"></div>
</template>
<template x-if="draft.routing[w.id].kind === 'cloud'">
<div class="flex items-center justify-end gap-1.5">
<select
@change="onCloudProviderChange($event, w.id)"
class="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">
<template x-for="cp in draft.cloudProviders" :key="cp.id">
<option :value="cp.id" :selected="draft.routing[w.id].providerId === cp.id" x-text="providerMeta[cp.type].label"></option>
</template>
</select>
<template x-if="selectedCloudType(w.id) !== 'openhuman'">
<input
:value="draft.routing[w.id].model"
@input="draft.routing[w.id].model = $event.target.value; recordDirty();"
class="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" />
</template>
</div>
</template>
<template x-if="draft.routing[w.id].kind === 'local'">
<div class="flex justify-end">
<select
@change="draft.routing[w.id].model = $event.target.value; recordDirty();"
class="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">
<template x-for="m in installedModels" :key="m.id">
<option :value="m.id" :selected="draft.routing[w.id].model === m.id" x-text="m.id"></option>
</template>
</select>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<div class="text-[11px] text-stone-500">
Primary resolves to
<span class="font-mono text-stone-700" x-text="primaryResolvedLabel()"></span>
</div>
</section>
<!-- Sticky save bar -->
<div
x-show="isDirty"
x-transition:enter="transition transform duration-300 ease-out"
x-transition:enter-start="opacity-0 translate-y-3"
x-transition:enter-end="opacity-100 translate-y-0"
class="pointer-events-none sticky bottom-3 z-20 flex justify-center px-1">
<div class="pointer-events-auto flex w-full items-center gap-2 rounded-lg border border-stone-200 bg-white/95 px-3 py-2 shadow-float backdrop-blur-md">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-amber-50 text-amber-600">
<i data-lucide="circle-alert" class="h-3.5 w-3.5"></i>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-stone-900">
<span x-text="diffSummary.length"></span>
unsaved change<span x-text="diffSummary.length === 1 ? '' : 's'"></span>
</div>
<div class="truncate font-mono text-[10px] text-stone-500" x-text="diffSummaryText()"></div>
</div>
<button
@click="discard()"
class="rounded-md border border-stone-200 px-2 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50">
Discard
</button>
<button
@click="save()"
class="inline-flex items-center gap-1 rounded-md bg-primary-500 px-2.5 py-1 text-xs font-medium text-white hover:bg-primary-600">
<i data-lucide="check" class="h-3 w-3"></i> Save
</button>
</div>
</div>
</div>
</div>
<script>
function aiSettings() {
const providerMeta = {
openhuman: { label: 'OpenHuman', icon: 'shield', rail: 'bg-primary-500', pill: 'bg-primary-50 text-primary-700 ring-primary-200' },
openai: { label: 'OpenAI', icon: 'wand', rail: 'bg-sage-500', pill: 'bg-sage-50 text-sage-700 ring-sage-200' },
anthropic: { label: 'Anthropic', icon: 'zap', rail: 'bg-amber-500', pill: 'bg-amber-50 text-amber-700 ring-amber-200' },
openrouter: { label: 'OpenRouter', icon: 'cloud', rail: 'bg-slate-500', pill: 'bg-slate-50 text-slate-700 ring-slate-200' },
custom: { label: 'Custom', icon: 'server', rail: 'bg-stone-500', pill: 'bg-stone-100 text-stone-700 ring-stone-200' },
};
const workloads = [
{ 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 turns' },
{ id: 'learning', group: 'background', label: 'Learning · Reflections', description: 'Periodic reflection over history' },
{ id: 'subconscious', group: 'background', label: 'Subconscious', description: 'Eventfulness scoring + drift checks' },
];
const initial = {
cloudProviders: [
{ id: 'p_oh', type: 'openhuman', label: 'OpenHuman', endpoint: 'https://api.openhuman.ai/v1', maskedKey: 'oh_••••••••••••a31f', defaultModel: 'openhuman-default' },
{ id: 'p_oai', type: 'openai', label: 'OpenAI', endpoint: 'https://api.openai.com/v1', maskedKey: 'sk-••••••••••••••••7Q2', defaultModel: 'gpt-4o' },
{ id: 'p_anth', type: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com/v1', maskedKey: 'sk-ant-•••••••••••e9c', defaultModel: 'claude-sonnet-4-6' },
],
primaryCloudId: 'p_oh',
routing: {
reasoning: { kind: 'primary' },
agentic: { kind: 'cloud', providerId: 'p_oai', model: 'gpt-4o-mini' },
coding: { kind: 'cloud', providerId: 'p_anth', model: 'claude-sonnet-4-6' },
memory: { kind: 'local', model: 'llama3.1:8b' },
embeddings: { kind: 'local', model: 'nomic-embed-text' },
heartbeat: { kind: 'local', model: 'llama3.2:3b' },
learning: { kind: 'primary' },
subconscious: { kind: 'local', model: 'llama3.2:3b' },
},
};
const installedModels = [
{ id: 'llama3.1:8b', sizeBytes: 4_700_000_000, family: 'llama' },
{ id: 'llama3.2:3b', sizeBytes: 2_000_000_000, family: 'llama' },
{ id: 'qwen2.5:14b', sizeBytes: 8_700_000_000, family: 'qwen' },
{ id: 'nomic-embed-text', sizeBytes: 274_000_000, family: 'embed' },
];
return {
providerMeta,
workloads,
installedModels,
saved: JSON.parse(JSON.stringify(initial)),
draft: JSON.parse(JSON.stringify(initial)),
get chatWorkloads() { return this.workloads.filter((w) => w.group === 'chat'); },
get bgWorkloads() { return this.workloads.filter((w) => w.group === 'background'); },
get primary() { return this.draft.cloudProviders.find((p) => p.id === this.draft.primaryCloudId); },
get isDirty() { return JSON.stringify(this.saved) !== JSON.stringify(this.draft); },
get diffSummary() {
const out = [];
for (const w of this.workloads) {
const a = this.saved.routing[w.id];
const b = this.draft.routing[w.id];
if (JSON.stringify(a) !== JSON.stringify(b)) {
const describe = (r) =>
r.kind === 'primary' ? 'primary' :
r.kind === 'cloud' ? `cloud:${r.model}` :
`local:${r.model}`;
out.push(`${w.label}${describe(b)}`);
}
}
if (this.saved.primaryCloudId !== this.draft.primaryCloudId) {
const p = this.draft.cloudProviders.find((cp) => cp.id === this.draft.primaryCloudId);
out.push(`primary → ${p ? providerMeta[p.type].label : '—'}`);
}
return out;
},
diffSummaryText() {
const d = this.diffSummary;
const head = d.slice(0, 2).join(' · ');
return d.length > 2 ? `${head} · +${d.length - 2}` : head;
},
tabClass(active) {
return (
'flex-1 px-2 py-1 text-[11px] font-medium transition-colors first:rounded-l last:rounded-r cursor-pointer ' +
(active
? 'bg-white text-stone-900 shadow-subtle ring-1 ring-stone-200'
: 'text-stone-500 hover:text-stone-800')
);
},
formatBytes(n) {
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`;
},
selectedCloudType(workloadId) {
const r = this.draft.routing[workloadId];
if (r.kind !== 'cloud') return null;
const p = this.draft.cloudProviders.find((cp) => cp.id === r.providerId);
return p?.type ?? null;
},
resolvedLabel(workloadId) {
const r = this.draft.routing[workloadId];
if (r.kind === 'primary') {
const p = this.primary;
if (!p) return 'no primary set';
if (p.type === 'openhuman') return 'openhuman';
return `${providerMeta[p.type].label.toLowerCase()} · ${p.defaultModel}`;
}
if (r.kind === 'cloud') {
const p = this.draft.cloudProviders.find((cp) => cp.id === r.providerId);
if (!p) return r.model;
if (p.type === 'openhuman') return 'openhuman';
return `${providerMeta[p.type].label.toLowerCase()} · ${r.model}`;
}
return `ollama · ${r.model}`;
},
primaryResolvedLabel() {
const p = this.primary;
if (!p) return '—';
if (p.type === 'openhuman') return 'openhuman';
return `${providerMeta[p.type].label.toLowerCase()} · ${p.defaultModel}`;
},
setRouting(id, next) {
this.draft.routing[id] = next;
this.recordDirty();
},
setCloud(id) {
const other = this.draft.cloudProviders.find((c) => c.id !== this.draft.primaryCloudId) ?? this.draft.cloudProviders[0];
if (!other) return;
this.setRouting(id, {
kind: 'cloud',
providerId: other.id,
model: other.type === 'openhuman' ? '' : other.defaultModel,
});
},
setLocal(id) {
const m = this.installedModels[0]?.id;
if (!m) return;
this.setRouting(id, { kind: 'local', model: m });
},
onCloudProviderChange(ev, id) {
const pid = ev.target.value;
const p = this.draft.cloudProviders.find((c) => c.id === pid);
if (!p) return;
this.draft.routing[id] = {
kind: 'cloud',
providerId: p.id,
model: p.type === 'openhuman' ? '' : p.defaultModel,
};
this.recordDirty();
},
applyPreset(kind) {
const m = this.installedModels[0]?.id;
for (const w of this.workloads) {
if (kind === 'cloud') this.draft.routing[w.id] = { kind: 'primary' };
else if (kind === 'local') this.draft.routing[w.id] = m ? { kind: 'local', model: m } : { kind: 'primary' };
else this.draft.routing[w.id] = w.group === 'chat' ? { kind: 'primary' } : (m ? { kind: 'local', model: m } : { kind: 'primary' });
}
this.recordDirty();
},
save() { this.saved = JSON.parse(JSON.stringify(this.draft)); },
discard() { this.draft = JSON.parse(JSON.stringify(this.saved)); },
recordDirty() { this.$nextTick(() => lucide.createIcons()); },
};
}
</script>
</body>
</html>
@@ -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<AgentDefinition> {
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]
+143 -17
View File
@@ -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<ToolSpec>) -> Vec<ToolSpec> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deduped: Vec<ToolSpec> = Vec::with_capacity(specs.len());
let mut dropped: Vec<String> = 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<ToolSpec> = if visible_names.is_empty() {
let visible_tool_specs_unfiltered: Vec<ToolSpec> = 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<ToolSpec> =
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<dyn Memory> = 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<dyn Provider> = 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<dyn Provider>, 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::<ToolSpec>::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"]})
);
}
}
@@ -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.
+11 -1
View File
@@ -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(),
+6 -2
View File
@@ -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<crate::openhuman::tools::ToolSpec> =
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
@@ -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<dyn Provider>,
parent_model: String,
) -> (std::sync::Arc<dyn Provider>, 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<SubagentRunOutcome, SubagentRunError> {
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,
@@ -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<dyn Provider>` 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<P: ?Sized>(a: &std::sync::Arc<P>, b: &std::sync::Arc<P>) -> bool {
std::sync::Arc::ptr_eq(a, b)
}
#[test]
fn resolve_subagent_provider_inherit_uses_parent_provider_and_model() {
let parent: Arc<dyn Provider> = 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<dyn Provider> = 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<dyn Provider> = 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<dyn Provider> = 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<dyn Provider> = 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
+53 -23
View File
@@ -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<String>,
/// Per-message `temperature` override (same channel as
/// `model_override`).
temperature: Option<f64>,
/// 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<String>,
}
struct SessionEntry {
agent: Agent,
model_override: Option<String>,
temperature: Option<f64>,
/// 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,
},
);
}
@@ -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<f64>,
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"
);
}
+2 -1
View File
@@ -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<dyn Memory> = 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,
+67
View File
@@ -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<Vec<crate::openhuman::config::ModelRouteConfig>>,
/// 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<Vec<crate::openhuman::config::schema::cloud_providers::CloudProviderCreds>>,
/// 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<String>,
pub reasoning_provider: Option<String>,
pub agentic_provider: Option<String>,
pub coding_provider: Option<String>,
pub memory_provider: Option<String>,
pub embeddings_provider: Option<String>,
pub heartbeat_provider: Option<String>,
pub learning_provider: Option<String>,
pub subconscious_provider: Option<String>,
}
#[derive(Debug, Clone, Default)]
@@ -236,6 +253,11 @@ pub struct MeetSettingsPatch {
#[derive(Debug, Clone, Default)]
pub struct LocalAiSettingsPatch {
pub runtime_enabled: Option<bool>,
/// 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<bool>,
pub provider: Option<String>,
pub base_url: Option<String>,
@@ -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<String> {
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(
+7
View File
@@ -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());
@@ -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_<type>_<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)
}
+11 -4
View File
@@ -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
}
+2
View File
@@ -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;
+109
View File
@@ -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:<model>" → look up cloud_providers entry of type=openai;
// build OpenAiCompatibleProvider with Bearer auth
// "anthropic:<model>" → type=anthropic; Bearer auth on the compat endpoint
// "openrouter:<model>" → type=openrouter; Bearer auth
// "custom:<model>" → type=custom; Bearer auth
// "ollama:<model>" → 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<crate::openhuman::config::schema::cloud_providers::CloudProviderCreds>,
/// 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<String>,
/// Provider string for the main reasoning / chat workload.
#[serde(default)]
pub reasoning_provider: Option<String>,
/// Provider string for sub-agent execution and tool-loop workloads.
#[serde(default)]
pub agentic_provider: Option<String>,
/// Provider string for code generation and refactor workloads.
#[serde(default)]
pub coding_provider: Option<String>,
/// Provider string for memory-tree extract + summarise workloads.
#[serde(default)]
pub memory_provider: Option<String>,
/// Provider string for embedding generation.
#[serde(default)]
pub embeddings_provider: Option<String>,
/// Provider string for the heartbeat background-reasoning loop.
#[serde(default)]
pub heartbeat_provider: Option<String>,
/// Provider string for learning / reflection passes.
#[serde(default)]
pub learning_provider: Option<String>,
/// Provider string for subconscious evaluation and drift checks.
#[serde(default)]
pub subconscious_provider: Option<String>,
/// 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:<model>"` (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<String> {
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(),
+123 -3
View File
@@ -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<String>,
/// "openhuman" | "openai" | "anthropic" | "openrouter" | "custom"
r#type: String,
endpoint: String,
default_model: Option<String>,
}
#[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<Vec<ModelRouteUpdate>>,
/// 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<Vec<CloudProviderUpdate>>,
primary_cloud: Option<String>,
reasoning_provider: Option<String>,
agentic_provider: Option<String>,
coding_provider: Option<String>,
memory_provider: Option<String>,
embeddings_provider: Option<String>,
heartbeat_provider: Option<String>,
learning_provider: Option<String>,
subconscious_provider: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -89,6 +112,10 @@ struct MeetSettingsUpdate {
#[derive(Debug, Deserialize)]
struct LocalAiSettingsUpdate {
runtime_enabled: Option<bool>,
/// 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<bool>,
provider: Option<String>,
base_url: Option<String>,
@@ -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<String, Value>) -> 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<serde_json::Value> = 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<String, Value>) -> 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<String, Value>) -> 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::<Result<Vec<_>, 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?)
})
+70 -7
View File
@@ -233,15 +233,60 @@ impl AuthProfilesStore {
fn load_locked(&self) -> Result<AuthProfilesData> {
let mut persisted = self.read_persisted_locked()?;
let mut migrated = false;
let mut dropped_ids: Vec<String> = 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)?;
}
@@ -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();
+43 -1
View File
@@ -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!(
+1 -1
View File
@@ -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 — \
+76
View File
@@ -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:<model>` 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:<m>`) 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<RpcOutcome<local_ai::LocalAiStatus>, 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<String>) -> 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,
+22
View File
@@ -138,6 +138,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_local_ai_shutdown_owned(_params: Map<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<LocalAiDownloadParams>(params)?;
@@ -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();
+41 -53
View File
@@ -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<Box<dyn Memory>> {
// 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);
+86 -69
View File
@@ -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:<m>"`):
/// 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<Arc<dyn ChatProvider>> {
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();
+12 -6
View File
@@ -149,12 +149,18 @@ pub async fn run_once(config: &Config) -> Result<bool> {
// 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
+17
View File
@@ -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.
@@ -80,16 +80,15 @@ pub fn build_embedder_from_config(config: &Config) -> Result<Box<dyn Embedder>>
)))
}
_ => {
// 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:<model>` 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");
+28 -28
View File
@@ -38,8 +38,8 @@ pub fn build_summary_extractor(config: &Config) -> Arc<dyn EntityExtractor> {
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<dyn EntityExtractor> {
/// 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:<m>"`): 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<String> {
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,
}
}
)
}
}
+2 -2
View File
@@ -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();
}
@@ -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<dyn Summariser> {
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<String> = 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:<m> without the legacy fields still get
// an LLM summariser instead of the inert fallback.
let model: Option<String> = 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());
};
+39 -1
View File
@@ -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)]
+7 -7
View File
@@ -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"
@@ -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<CloudProviderCreds>` 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<MigrationStats> {
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<String>, 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;
@@ -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"));
}
+716
View File
@@ -0,0 +1,716 @@
//! Unified chat-provider factory.
//!
//! Resolves workload names (e.g. `"reasoning"`, `"heartbeat"`) to a
//! `(Box<dyn Provider>, 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:<model>" → cloud_providers entry of type=openai + Bearer auth
//! "anthropic:<model>" → cloud_providers entry of type=anthropic + Bearer auth
//! "openrouter:<model>" → cloud_providers entry of type=openrouter + Bearer auth
//! "custom:<model>" → cloud_providers entry of type=custom + Bearer auth
//! "ollama:<model>" → 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:<model>"`.
pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:";
/// Prefix for OpenAI-compatible providers: `"openai:<model>"`.
pub const OPENAI_PROVIDER_PREFIX: &str = "openai:";
/// Prefix for Anthropic-compatible providers: `"anthropic:<model>"`.
pub const ANTHROPIC_PROVIDER_PREFIX: &str = "anthropic:";
/// Prefix for OpenRouter providers: `"openrouter:<model>"`.
pub const OPENROUTER_PROVIDER_PREFIX: &str = "openrouter:";
/// Prefix for custom OpenAI-compatible providers: `"custom:<model>"`.
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<dyn Provider>, 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<dyn Provider>, 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:<model-id>'",
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:<m>, openai:<m>, \
anthropic:<m>, openrouter:<m>, custom:<m>",
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<dyn Provider>, 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<dyn Provider>, 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:<tier>` 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 `<tier>-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<dyn Provider>, 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<dyn Provider>, 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 `"<type>: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<String> {
// 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<Box<dyn Provider>> {
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.
"<endpoint>".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<CloudProviderCreds>,
primary: Option<String>,
) -> 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:<model> 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:<model> 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:<model> 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:<model> 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<dyn Provider>, 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");
}
}
+2
View File
@@ -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::*;
+1 -1
View File
@@ -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
+23 -16
View File
@@ -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);
}
}
+12 -1
View File
@@ -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(