mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(ai-settings): reset orphaned local-runtime routing when the runtime is disabled (#3167)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
51a1d1b034
commit
12908e2ccd
@@ -53,6 +53,7 @@ import {
|
||||
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { routingWithProviderRemoved } from './aiRouting';
|
||||
import {
|
||||
authStyleForBuiltinCloudProvider,
|
||||
BUILTIN_CLOUD_PROVIDER_META,
|
||||
@@ -67,7 +68,7 @@ import { useReembedBackfillModal } from './useReembedBackfillModal';
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CloudProvider = {
|
||||
export type CloudProvider = {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
@@ -92,7 +93,7 @@ type WorkloadId =
|
||||
|
||||
type WorkloadGroup = 'chat' | 'background';
|
||||
|
||||
type ProviderRef =
|
||||
export type ProviderRef =
|
||||
| { kind: 'openhuman' }
|
||||
| { kind: 'default' }
|
||||
| { kind: 'cloud'; providerSlug: string; model: string; temperature?: number | null }
|
||||
@@ -100,7 +101,7 @@ type ProviderRef =
|
||||
|
||||
type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; description: string };
|
||||
|
||||
type RoutingMap = Record<WorkloadId, ProviderRef>;
|
||||
export type RoutingMap = Record<WorkloadId, ProviderRef>;
|
||||
type RoutingMode = 'managed' | 'own' | 'custom';
|
||||
const ROUTING_WORKLOAD_IDS: WorkloadId[] = [
|
||||
'chat',
|
||||
@@ -626,6 +627,15 @@ const ProviderKeyDialog = ({
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// A provider credential is being saved. This adds/updates a `cloudProviders`
|
||||
// entry only — it does NOT change the workload routing map, so routing is
|
||||
// unchanged afterwards (see inferRoutingMode). Logged for routing diagnostics.
|
||||
console.debug('[ai-settings][routing] saving provider credential', {
|
||||
slug,
|
||||
local_runtime: isLocalRuntime,
|
||||
kind: isLocalRuntime ? 'endpoint' : 'apiKey',
|
||||
});
|
||||
|
||||
setPhase('saving');
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
@@ -2724,7 +2734,26 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
|
||||
const chatRows = WORKLOADS.filter(w => w.group === 'chat');
|
||||
const bgRows = WORKLOADS.filter(w => w.group === 'background');
|
||||
const inferredRoutingMode = useMemo(() => inferRoutingMode(draft.routing), [draft.routing]);
|
||||
const inferredRoutingMode = useMemo(() => {
|
||||
const mode = inferRoutingMode(draft.routing);
|
||||
// Routing mode is derived purely from the workload routing map, not from the
|
||||
// set of configured providers: saving a provider key only adds a
|
||||
// `cloudProviders` entry, it does not rewrite `routing`. So "managed while a
|
||||
// provider key is configured" is an expected state — the user must pick a
|
||||
// route to actually use their provider. Surfaced for support diagnostics
|
||||
// (the recurring "my key is added but not used" question).
|
||||
const configuredWithKey = draft.cloudProviders.filter(p => p.maskedKey.startsWith('••••'));
|
||||
console.debug('[ai-settings][routing] inferred mode', {
|
||||
mode,
|
||||
routing: ROUTING_WORKLOAD_IDS.map(id => `${id}:${draft.routing[id]?.kind}`),
|
||||
configured_providers: draft.cloudProviders.map(p => p.slug),
|
||||
configured_with_key: configuredWithKey.map(p => p.slug),
|
||||
// A provider key is configured but routing is still managed → the provider
|
||||
// is not used until the user selects a custom route.
|
||||
configured_but_managed: mode === 'managed' && configuredWithKey.length > 0,
|
||||
});
|
||||
return mode;
|
||||
}, [draft.routing, draft.cloudProviders]);
|
||||
const effectiveRoutingMode: RoutingMode =
|
||||
routingEditorMode === 'own'
|
||||
? 'own'
|
||||
@@ -2800,14 +2829,11 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
// Toggle OFF: remove the provider + scrub any
|
||||
// routing entries that pin to it.
|
||||
const remaining = draft.cloudProviders.filter(cp => cp.id !== existing.id);
|
||||
const nextRouting = Object.fromEntries(
|
||||
Object.entries(draft.routing).map(([wid, ref]) => [
|
||||
wid,
|
||||
ref.kind === 'cloud' && ref.providerSlug === existing.slug
|
||||
? ({ kind: 'default' } as const)
|
||||
: ref,
|
||||
])
|
||||
) as typeof draft.routing;
|
||||
const nextRouting = routingWithProviderRemoved(
|
||||
draft.routing,
|
||||
{ slug: existing.slug, isLocalRuntime: false },
|
||||
remaining
|
||||
);
|
||||
await persist({
|
||||
...draft,
|
||||
cloudProviders: remaining,
|
||||
@@ -2834,14 +2860,11 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
busy={busyAction === `toggle-${existing.slug}`}
|
||||
onToggle={async () => {
|
||||
const remaining = draft.cloudProviders.filter(cp => cp.id !== existing.id);
|
||||
const nextRouting = Object.fromEntries(
|
||||
Object.entries(draft.routing).map(([wid, ref]) => [
|
||||
wid,
|
||||
ref.kind === 'cloud' && ref.providerSlug === existing.slug
|
||||
? ({ kind: 'default' } as const)
|
||||
: ref,
|
||||
])
|
||||
) as typeof draft.routing;
|
||||
const nextRouting = routingWithProviderRemoved(
|
||||
draft.routing,
|
||||
{ slug: existing.slug, isLocalRuntime: false },
|
||||
remaining
|
||||
);
|
||||
await persist({ ...draft, cloudProviders: remaining, routing: nextRouting });
|
||||
}}
|
||||
/>
|
||||
@@ -2872,14 +2895,11 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
const remaining = draft.cloudProviders.filter(
|
||||
cp => cp.id !== existing.id
|
||||
);
|
||||
const nextRouting = Object.fromEntries(
|
||||
Object.entries(draft.routing).map(([wid, ref]) => [
|
||||
wid,
|
||||
ref.kind === 'cloud' && ref.providerSlug === localKind
|
||||
? ({ kind: 'default' } as const)
|
||||
: ref,
|
||||
])
|
||||
) as typeof draft.routing;
|
||||
const nextRouting = routingWithProviderRemoved(
|
||||
draft.routing,
|
||||
{ slug: localKind, isLocalRuntime: true },
|
||||
remaining
|
||||
);
|
||||
await persist({
|
||||
...draft,
|
||||
cloudProviders: remaining,
|
||||
|
||||
@@ -583,6 +583,58 @@ describe('AIPanel', () => {
|
||||
expect(nextSettings.routing.coding).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
// ─── chip toggle: local runtime toggle OFF scrubs orphaned local routing ─────
|
||||
|
||||
it('toggling OFF a local runtime resets workloads routed to it back to default', async () => {
|
||||
const settingsWithOllama = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_ollama_1',
|
||||
slug: 'ollama',
|
||||
label: 'Ollama',
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: false,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
chat: { kind: 'local' as const, model: 'llama3' },
|
||||
reasoning: { kind: 'local' as const, model: 'llama3' },
|
||||
agentic: { kind: 'openhuman' as const },
|
||||
coding: { kind: 'openhuman' as const },
|
||||
memory: { kind: 'openhuman' as const },
|
||||
embeddings: { kind: 'openhuman' as const },
|
||||
heartbeat: { kind: 'openhuman' as const },
|
||||
learning: { kind: 'openhuman' as const },
|
||||
subconscious: { kind: 'openhuman' as const },
|
||||
},
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOllama);
|
||||
vi.mocked(saveAISettings).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Disconnect Ollama/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Toggle Ollama OFF — no other local runtime remains, so its routed
|
||||
// workloads are orphaned and must reset to the user default route.
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Disconnect Ollama/i }));
|
||||
|
||||
await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled());
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0];
|
||||
|
||||
expect(
|
||||
nextSettings.cloudProviders.find((p: { slug: string }) => p.slug === 'ollama')
|
||||
).toBeUndefined();
|
||||
// Local-routed workloads reset to default (the fix — previously left orphaned).
|
||||
expect(nextSettings.routing.chat).toEqual({ kind: 'default' });
|
||||
expect(nextSettings.routing.reasoning).toEqual({ kind: 'default' });
|
||||
// Already-managed entries unchanged.
|
||||
expect(nextSettings.routing.agentic).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
// ─── API-key dialog: failed setCloudProviderKey does not add provider ────────
|
||||
|
||||
it('when setCloudProviderKey throws, the provider is NOT added to the draft', async () => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CloudProvider, ProviderRef, RoutingMap } from '../AIPanel';
|
||||
import { routingWithProviderRemoved } from '../aiRouting';
|
||||
|
||||
const WORKLOADS = [
|
||||
'chat',
|
||||
'reasoning',
|
||||
'agentic',
|
||||
'coding',
|
||||
'memory',
|
||||
'heartbeat',
|
||||
'learning',
|
||||
'subconscious',
|
||||
] as const;
|
||||
|
||||
/** Build a full 8-workload routing map defaulting every slot to managed. */
|
||||
function routingOf(
|
||||
overrides: Partial<Record<(typeof WORKLOADS)[number], ProviderRef>>
|
||||
): RoutingMap {
|
||||
const base = Object.fromEntries(
|
||||
WORKLOADS.map(w => [w, { kind: 'default' } as ProviderRef])
|
||||
) as RoutingMap;
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
const cloudRef = (slug: string, model = 'm'): ProviderRef => ({
|
||||
kind: 'cloud',
|
||||
providerSlug: slug,
|
||||
model,
|
||||
});
|
||||
const localRef = (model = 'llama3'): ProviderRef => ({ kind: 'local', model });
|
||||
// The helper only reads `.slug`; the rest of CloudProvider is irrelevant here.
|
||||
const provider = (slug: string): CloudProvider =>
|
||||
({
|
||||
id: `id-${slug}`,
|
||||
slug,
|
||||
label: slug,
|
||||
endpoint: '',
|
||||
maskedKey: '',
|
||||
}) as unknown as CloudProvider;
|
||||
|
||||
describe('routingWithProviderRemoved', () => {
|
||||
it('resets workloads pinned to a removed cloud provider back to default', () => {
|
||||
const routing = routingOf({ chat: cloudRef('openrouter'), coding: cloudRef('openrouter') });
|
||||
const next = routingWithProviderRemoved(
|
||||
routing,
|
||||
{ slug: 'openrouter', isLocalRuntime: false },
|
||||
[]
|
||||
);
|
||||
expect(next.chat).toEqual({ kind: 'default' });
|
||||
expect(next.coding).toEqual({ kind: 'default' });
|
||||
});
|
||||
|
||||
it('leaves a different cloud provider untouched when one is removed', () => {
|
||||
const routing = routingOf({ chat: cloudRef('openrouter'), reasoning: cloudRef('openai') });
|
||||
const next = routingWithProviderRemoved(
|
||||
routing,
|
||||
{ slug: 'openrouter', isLocalRuntime: false },
|
||||
[provider('openai')]
|
||||
);
|
||||
expect(next.chat).toEqual({ kind: 'default' });
|
||||
expect(next.reasoning).toEqual(cloudRef('openai'));
|
||||
});
|
||||
|
||||
it('resets local-runtime refs when the last local runtime is disabled', () => {
|
||||
const routing = routingOf({ chat: localRef('llama3'), agentic: localRef('llama3') });
|
||||
// Disabling ollama with no local runtime remaining → local refs are orphaned.
|
||||
const next = routingWithProviderRemoved(routing, { slug: 'ollama', isLocalRuntime: true }, []);
|
||||
expect(next.chat).toEqual({ kind: 'default' });
|
||||
expect(next.agentic).toEqual({ kind: 'default' });
|
||||
});
|
||||
|
||||
it('keeps local refs when another local runtime is still enabled', () => {
|
||||
const routing = routingOf({ chat: localRef('llama3') });
|
||||
// Disabling lmstudio while ollama remains → the local ref may resolve to ollama.
|
||||
const next = routingWithProviderRemoved(routing, { slug: 'lmstudio', isLocalRuntime: true }, [
|
||||
provider('ollama'),
|
||||
]);
|
||||
expect(next.chat).toEqual(localRef('llama3'));
|
||||
});
|
||||
|
||||
it('does not scrub local refs when a cloud provider is removed (regression guard)', () => {
|
||||
const routing = routingOf({ chat: localRef('llama3'), reasoning: cloudRef('openrouter') });
|
||||
const next = routingWithProviderRemoved(
|
||||
routing,
|
||||
{ slug: 'openrouter', isLocalRuntime: false },
|
||||
[]
|
||||
);
|
||||
expect(next.chat).toEqual(localRef('llama3'));
|
||||
expect(next.reasoning).toEqual({ kind: 'default' });
|
||||
});
|
||||
|
||||
it('preserves all 8 workload slots', () => {
|
||||
const next = routingWithProviderRemoved(
|
||||
routingOf({}),
|
||||
{ slug: 'x', isLocalRuntime: false },
|
||||
[]
|
||||
);
|
||||
expect(Object.keys(next).sort()).toEqual([...WORKLOADS].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Pure routing-map helpers for the AI settings panel.
|
||||
*
|
||||
* Kept out of AIPanel.tsx so the logic is unit-testable without rendering the
|
||||
* whole panel (the types are imported type-only, so there is no runtime import
|
||||
* cycle — AIPanel imports these functions, this module imports only erased
|
||||
* types back from it).
|
||||
*/
|
||||
import type { CloudProvider, ProviderRef, RoutingMap } from './AIPanel';
|
||||
|
||||
const LOCAL_RUNTIME_SLUGS = ['ollama', 'lmstudio'] as const;
|
||||
|
||||
/**
|
||||
* Reset any workload routing ref pinned to a now-removed provider back to
|
||||
* `{ kind: 'default' }` (managed), so disabling a provider can never leave
|
||||
* orphaned routing that still points at it.
|
||||
*
|
||||
* Matching differs by provider kind because routing refs carry different
|
||||
* identity:
|
||||
* - **Cloud / custom** providers are matched precisely by `providerSlug`
|
||||
* (`{ kind: 'cloud', providerSlug, model }`).
|
||||
* - **Local runtimes** (Ollama / LM Studio) have NO slug on their routing refs
|
||||
* (`{ kind: 'local', model }`), so an individual local ref can't be tied back
|
||||
* to a specific runtime. A `local` ref is therefore only definitively
|
||||
* orphaned once NO local runtime remains enabled; while another local runtime
|
||||
* is still enabled we leave `local` refs alone since they may resolve to the
|
||||
* survivor.
|
||||
*
|
||||
* Before this helper the local case was silently a no-op: the toggle-off
|
||||
* handlers only matched `kind === 'cloud' && providerSlug === <runtime>`, which
|
||||
* a `kind: 'local'` ref can never satisfy — so disabling Ollama / LM Studio left
|
||||
* its routed workloads pinned to a now-removed runtime.
|
||||
*/
|
||||
export function routingWithProviderRemoved(
|
||||
routing: RoutingMap,
|
||||
removed: { slug: string; isLocalRuntime: boolean },
|
||||
remainingProviders: readonly CloudProvider[]
|
||||
): RoutingMap {
|
||||
const anyLocalRuntimeLeft = remainingProviders.some(p =>
|
||||
(LOCAL_RUNTIME_SLUGS as readonly string[]).includes(p.slug)
|
||||
);
|
||||
|
||||
const scrubbed = Object.entries(routing).map(([workloadId, ref]) => {
|
||||
const pinnedToRemovedCloud = ref.kind === 'cloud' && ref.providerSlug === removed.slug;
|
||||
const orphanedLocal = ref.kind === 'local' && removed.isLocalRuntime && !anyLocalRuntimeLeft;
|
||||
const nextRef: ProviderRef = pinnedToRemovedCloud || orphanedLocal ? { kind: 'default' } : ref;
|
||||
return [workloadId, nextRef] as const;
|
||||
});
|
||||
|
||||
return Object.fromEntries(scrubbed) as RoutingMap;
|
||||
}
|
||||
Reference in New Issue
Block a user