mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
2d54e46296
commit
e052aadfe9
@@ -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",
|
||||
|
||||
@@ -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 Anthropic’s OpenAI-compatibility endpoint with a key from console.anthropic.com. Defaults: Opus 4.7 reasoning, Sonnet 4.6 agentic/coding, Haiku 4.5 summarization.',
|
||||
tint: {
|
||||
idle: 'border-stone-200 hover:border-coral-400 hover:bg-coral-50/40',
|
||||
selected: 'border-coral-600 bg-coral-100 ring-2 ring-coral-300 text-coral-900',
|
||||
dot: 'bg-coral-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
label: 'OpenRouter',
|
||||
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
|
||||
suggestedModel: 'openai/gpt-4o',
|
||||
roleModels: {
|
||||
reasoning: 'openai/o1',
|
||||
agentic: 'anthropic/claude-sonnet-4.6',
|
||||
coding: 'anthropic/claude-sonnet-4.6',
|
||||
summarization: 'openai/gpt-4o-mini',
|
||||
},
|
||||
note: 'One key, dozens of providers (openrouter.ai). Mix and match per role — swap to meta-llama/llama-3.3-70b-instruct, google/gemini-2.0-flash, etc.',
|
||||
tint: {
|
||||
idle: 'border-stone-200 hover:border-amber-400 hover:bg-amber-50/40',
|
||||
selected: 'border-amber-600 bg-amber-100 ring-2 ring-amber-300 text-amber-900',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
label: 'Ollama (local)',
|
||||
apiUrl: 'http://localhost:11434/v1/chat/completions',
|
||||
suggestedModel: 'llama3.3',
|
||||
roleModels: {
|
||||
reasoning: 'llama3.3',
|
||||
agentic: 'llama3.3',
|
||||
coding: 'qwen2.5-coder',
|
||||
summarization: 'llama3.2',
|
||||
},
|
||||
note: 'Local Ollama runtime via its OpenAI-compatible endpoint. API key is ignored — leave blank.',
|
||||
tint: {
|
||||
idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50',
|
||||
selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900',
|
||||
dot: 'bg-stone-500',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: 'Custom',
|
||||
apiUrl: '',
|
||||
suggestedModel: '',
|
||||
roleModels: { ...EMPTY_ROLE_MODELS },
|
||||
note: 'Any other endpoint that speaks the OpenAI /chat/completions shape (vLLM, LiteLLM, LM Studio, self-hosted gateways).',
|
||||
tint: {
|
||||
idle: 'border-stone-200 hover:border-stone-400 hover:bg-stone-50',
|
||||
selected: 'border-stone-500 bg-stone-200 ring-2 ring-stone-300 text-stone-900',
|
||||
dot: 'bg-stone-400',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function detectPreset(apiUrl: string, routes: ModelRoute[]): ProviderPreset {
|
||||
const trimmed = apiUrl.trim();
|
||||
// Saved routes mean "non-OpenHuman provider"; an empty URL with routes is
|
||||
// a hand-edited Custom setup, not the OpenHuman default.
|
||||
const hasRoutes = routes.length > 0;
|
||||
if (!trimmed)
|
||||
return hasRoutes ? PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1] : PROVIDER_PRESETS[0];
|
||||
const match = PROVIDER_PRESETS.find(p => p.apiUrl && p.apiUrl === trimmed);
|
||||
if (match) return match;
|
||||
return PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; // custom
|
||||
}
|
||||
|
||||
function roleModelsFromRoutes(routes: ModelRoute[]): RoleModels {
|
||||
const out: RoleModels = { ...EMPTY_ROLE_MODELS };
|
||||
for (const r of routes) {
|
||||
if ((ROLE_HINTS as readonly string[]).includes(r.hint)) {
|
||||
out[r.hint as RoleHint] = r.model;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the LLM provider (#1342). Defaults to the hosted OpenHuman
|
||||
* backend, whose built-in router picks the best model per request. Selecting
|
||||
* any other preset reveals per-role model inputs (reasoning / agentic /
|
||||
* coding / summarization) that get persisted to `config.model_routes` so the
|
||||
* core router obeys them.
|
||||
*
|
||||
* The api_key is stored on the user's machine in `config.toml`. It is sent
|
||||
* over the local Tauri↔core RPC only at write time; subsequent reads return
|
||||
* only `api_key_set: bool` so the secret never leaves the core process once
|
||||
* persisted.
|
||||
*/
|
||||
const BackendProviderPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [client, setClient] = useState<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! You’re 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
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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 } : {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?)
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 — \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user