mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(providers): slug-keyed cloud providers + per-workload model routing (#1888)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadAISettings, loadLocalProviderSnapshot } from '../../../../services/api/aiSettingsApi';
|
||||
import {
|
||||
loadAISettings,
|
||||
loadLocalProviderSnapshot,
|
||||
saveAISettings,
|
||||
setCloudProviderKey,
|
||||
} from '../../../../services/api/aiSettingsApi';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import AIPanel from '../AIPanel';
|
||||
|
||||
@@ -21,8 +26,12 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
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}`
|
||||
serializeProviderRef: vi.fn((r: { kind: string; providerSlug?: string; model?: string }) =>
|
||||
r.kind === 'openhuman'
|
||||
? 'openhuman'
|
||||
: r.kind === 'local'
|
||||
? `ollama:${r.model}`
|
||||
: `${r.providerSlug}:${r.model}`
|
||||
),
|
||||
localProvider: { download: vi.fn(), applyPreset: vi.fn() },
|
||||
}));
|
||||
@@ -39,35 +48,32 @@ const baseSettings = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_oh_x',
|
||||
type: 'openhuman' as const,
|
||||
slug: 'openhuman',
|
||||
label: 'OpenHuman',
|
||||
endpoint: 'https://api.openhuman.ai/v1',
|
||||
default_model: 'reasoning-v1',
|
||||
auth_style: 'openhuman_jwt' as const,
|
||||
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 },
|
||||
reasoning: { kind: 'openhuman' as const },
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
const baseLocalSnapshot = { status: null, diagnostics: null, presets: null, installedModels: [] };
|
||||
|
||||
describe('AIPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(loadLocalProviderSnapshot).mockResolvedValue({
|
||||
status: null,
|
||||
diagnostics: null,
|
||||
presets: null,
|
||||
installedModels: [],
|
||||
});
|
||||
vi.mocked(loadLocalProviderSnapshot).mockResolvedValue(baseLocalSnapshot);
|
||||
});
|
||||
|
||||
it('renders the LLM Providers + Routing top-level section headers', async () => {
|
||||
@@ -106,4 +112,208 @@ describe('AIPanel', () => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── auth_style preservation ────────────────────────────────────────────────
|
||||
|
||||
it('preserves auth_style: "anthropic" through save when Anthropic provider is configured', async () => {
|
||||
const settingsWithAnthropic = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_anthropic_1',
|
||||
slug: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
endpoint: 'https://api.anthropic.com/v1',
|
||||
auth_style: 'anthropic' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
reasoning: {
|
||||
kind: 'cloud' as const,
|
||||
providerSlug: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
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(settingsWithAnthropic);
|
||||
vi.mocked(saveAISettings).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
// Wait for load.
|
||||
await waitFor(() => expect(screen.getAllByText(/Anthropic/i).length).toBeGreaterThan(0));
|
||||
|
||||
// Trigger a routing change so the SaveBar appears, then save.
|
||||
// Click the "Default" button on the Reasoning row to change routing.
|
||||
const defaultButtons = screen.getAllByText('Default');
|
||||
fireEvent.click(defaultButtons[0]);
|
||||
|
||||
// SaveBar should appear.
|
||||
await waitFor(() => expect(screen.getByText(/unsaved change/i)).toBeInTheDocument());
|
||||
|
||||
// Click Save in the SaveBar.
|
||||
const saveButton = screen.getByRole('button', { name: /^Save$/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled());
|
||||
|
||||
// Verify auth_style was passed through correctly in the next AISettings arg.
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0];
|
||||
const anthropicProvider = nextSettings.cloudProviders.find(
|
||||
(p: { slug: string }) => p.slug === 'anthropic'
|
||||
);
|
||||
expect(anthropicProvider).toBeDefined();
|
||||
expect(anthropicProvider!.auth_style).toBe('anthropic');
|
||||
});
|
||||
|
||||
// ─── chip toggle: toggle ON opens API-key dialog ────────────────────────────
|
||||
|
||||
it('clicking the OpenAI chip toggle (when disabled) opens the API-key dialog', async () => {
|
||||
// Load with no openai provider → chip is off.
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() => expect(screen.getAllByText(/OpenAI/i).length).toBeGreaterThan(0));
|
||||
|
||||
// Find the "Connect OpenAI" switch button and click it.
|
||||
const connectSwitch = screen.getByRole('switch', { name: /Connect OpenAI/i });
|
||||
fireEvent.click(connectSwitch);
|
||||
|
||||
// ProviderKeyDialog should appear.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('dialog', { name: /Connect OpenAI/i })).toBeInTheDocument()
|
||||
);
|
||||
// The input for the API key should be visible.
|
||||
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the Custom chip (when disabled) opens the CloudProviderEditor, not the key dialog', async () => {
|
||||
// Load with no custom provider → chip is off.
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() => expect(screen.getAllByText(/Custom/i).length).toBeGreaterThan(0));
|
||||
|
||||
// Find the "Connect Custom" switch and click it.
|
||||
const connectSwitch = screen.getByRole('switch', { name: /Connect Custom/i });
|
||||
fireEvent.click(connectSwitch);
|
||||
|
||||
// The full CloudProviderEditor should appear (has "Add cloud provider" heading).
|
||||
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
|
||||
// The simple ProviderKeyDialog should NOT appear.
|
||||
expect(screen.queryByRole('dialog', { name: /Connect Custom/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ─── chip toggle: toggle OFF scrubs routing entries ──────────────────────────
|
||||
|
||||
it('toggling OFF an enabled provider scrubs routing entries that reference it', async () => {
|
||||
const settingsWithOpenAI = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
|
||||
agentic: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o-mini' },
|
||||
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(settingsWithOpenAI);
|
||||
vi.mocked(saveAISettings).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
// Wait for load — OpenAI chip should be ON.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Disconnect OpenAI/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Toggle OFF.
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Disconnect OpenAI/i }));
|
||||
|
||||
// A SaveBar must appear because the draft changed.
|
||||
await waitFor(() => expect(screen.getByText(/unsaved change/i)).toBeInTheDocument());
|
||||
|
||||
// Save to capture the nextSettings arg.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled());
|
||||
|
||||
const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0];
|
||||
|
||||
// Provider should be gone.
|
||||
expect(
|
||||
nextSettings.cloudProviders.find((p: { slug: string }) => p.slug === 'openai')
|
||||
).toBeUndefined();
|
||||
|
||||
// Routing entries that were pinned to openai must be reset to openhuman.
|
||||
expect(nextSettings.routing.reasoning).toEqual({ kind: 'openhuman' });
|
||||
expect(nextSettings.routing.agentic).toEqual({ kind: 'openhuman' });
|
||||
// Entries that were already openhuman remain unchanged.
|
||||
expect(nextSettings.routing.coding).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 () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
// Make setCloudProviderKey reject.
|
||||
vi.mocked(setCloudProviderKey).mockRejectedValue(new Error('key store failed'));
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
// Wait for OpenAI chip to render (disabled).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect OpenAI/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Count provider chips before dialog interaction.
|
||||
const chipsBefore = screen.getAllByRole('switch').length;
|
||||
|
||||
// Open the dialog.
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect OpenAI/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('dialog', { name: /Connect OpenAI/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// Fill in a key and submit.
|
||||
fireEvent.change(screen.getByLabelText(/API key/i), { target: { value: 'sk-bad-key' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
// The panel silently catches the setCloudProviderKey error and does NOT
|
||||
// mutate the draft. Because the panel's onSubmit returns (doesn't throw),
|
||||
// the dialog's handleSave resolves without entering its catch block, leaving
|
||||
// the dialog in the 'saving' phase with the button showing "Saving…".
|
||||
//
|
||||
// Wait for setCloudProviderKey to have been called (confirms the flow ran).
|
||||
await waitFor(() => expect(vi.mocked(setCloudProviderKey)).toHaveBeenCalled());
|
||||
|
||||
// The dialog must still be open (setKeyDialogFor was never set to null).
|
||||
expect(screen.getByRole('dialog', { name: /Connect OpenAI/i })).toBeInTheDocument();
|
||||
|
||||
// The number of provider toggle switches must not have grown — the failed
|
||||
// provider was never added to the draft.
|
||||
expect(screen.getAllByRole('switch').length).toBe(chipsBefore);
|
||||
|
||||
// Specifically: no "Disconnect OpenAI" switch (chip is still in off state).
|
||||
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,10 @@ describe('rpcMethods catalog', () => {
|
||||
path.resolve(__dirname, '../../../../src/openhuman/screen_intelligence/schemas.rs'),
|
||||
'utf8'
|
||||
),
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../../src/openhuman/providers/schemas.rs'),
|
||||
'utf8'
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
for (const method of Object.values(CORE_RPC_METHODS)) {
|
||||
@@ -71,7 +75,9 @@ describe('rpcMethods catalog', () => {
|
||||
const methodRoot = method.slice('openhuman.'.length);
|
||||
const namespace = methodRoot.startsWith('screen_intelligence_')
|
||||
? 'screen_intelligence'
|
||||
: 'config';
|
||||
: methodRoot.startsWith('providers_')
|
||||
? 'providers'
|
||||
: 'config';
|
||||
const fnName = methodRoot.slice(`${namespace}_`.length);
|
||||
expect(schemaSources).toContain(`namespace: "${namespace}"`);
|
||||
expect(schemaSources).toContain(`function: "${fnName}"`);
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Unit tests for aiSettingsApi.ts
|
||||
*
|
||||
* All external deps (tauriCommands/auth, tauriCommands/config, coreRpcClient,
|
||||
* tauriCommands/common) are mocked so no Tauri runtime is needed.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ─── Import SUT after mocks ───────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
type AISettings,
|
||||
clearCloudProviderKey,
|
||||
listProviderModels,
|
||||
loadAISettings,
|
||||
parseProviderString,
|
||||
type ProviderRef,
|
||||
saveAISettings,
|
||||
serializeProviderRef,
|
||||
setCloudProviderKey,
|
||||
} from '../aiSettingsApi';
|
||||
|
||||
// ─── Mock declarations (must be hoisted before imports) ───────────────────────
|
||||
|
||||
const mockOpenhumanGetClientConfig = vi.fn();
|
||||
const mockAuthListProviderCredentials = vi.fn();
|
||||
const mockOpenhumanUpdateModelSettings = vi.fn();
|
||||
const mockAuthStoreProviderCredentials = vi.fn();
|
||||
const mockAuthRemoveProviderCredentials = vi.fn();
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
const mockIsTauri = vi.fn(() => true);
|
||||
|
||||
vi.mock('../../coreRpcClient', () => ({ callCoreRpc: (a: unknown) => mockCallCoreRpc(a) }));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/common', () => ({
|
||||
isTauri: () => mockIsTauri(),
|
||||
CommandResponse: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/auth', () => ({
|
||||
authListProviderCredentials: (a?: unknown) => mockAuthListProviderCredentials(a),
|
||||
authStoreProviderCredentials: (a: unknown) => mockAuthStoreProviderCredentials(a),
|
||||
authRemoveProviderCredentials: (a: unknown) => mockAuthRemoveProviderCredentials(a),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/config', () => ({
|
||||
openhumanGetClientConfig: () => mockOpenhumanGetClientConfig(),
|
||||
openhumanUpdateModelSettings: (a: unknown) => mockOpenhumanUpdateModelSettings(a),
|
||||
openhumanUpdateLocalAiSettings: vi.fn().mockResolvedValue({ result: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/localAi', () => ({
|
||||
openhumanLocalAiStatus: vi.fn().mockResolvedValue({ result: null }),
|
||||
openhumanLocalAiDiagnostics: vi.fn().mockResolvedValue(null),
|
||||
openhumanLocalAiPresets: vi.fn().mockResolvedValue(null),
|
||||
openhumanLocalAiApplyPreset: vi.fn().mockResolvedValue({}),
|
||||
openhumanLocalAiDownload: vi.fn().mockResolvedValue({}),
|
||||
openhumanLocalAiSetOllamaPath: vi.fn().mockResolvedValue({}),
|
||||
openhumanLocalAiShutdownOwned: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeClientConfigResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
result: {
|
||||
api_url: null,
|
||||
inference_url: null,
|
||||
default_model: null,
|
||||
app_version: '0.0.0-test',
|
||||
api_key_set: false,
|
||||
model_routes: [],
|
||||
cloud_providers: [],
|
||||
primary_cloud: null,
|
||||
reasoning_provider: null,
|
||||
agentic_provider: null,
|
||||
coding_provider: null,
|
||||
memory_provider: null,
|
||||
embeddings_provider: null,
|
||||
heartbeat_provider: null,
|
||||
learning_provider: null,
|
||||
subconscious_provider: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuthProfileResult(profiles: Array<{ id: string; provider: string }> = []) {
|
||||
return { result: profiles.map(p => ({ ...p, profile_name: 'default', kind: 'token' })) };
|
||||
}
|
||||
|
||||
// ─── parseProviderString ─────────────────────────────────────────────────────
|
||||
|
||||
describe('parseProviderString', () => {
|
||||
it('returns openhuman for empty string', () => {
|
||||
expect(parseProviderString('')).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('returns openhuman for null/undefined', () => {
|
||||
expect(parseProviderString(null)).toEqual({ kind: 'openhuman' });
|
||||
expect(parseProviderString(undefined)).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('returns openhuman for the "cloud" sentinel', () => {
|
||||
expect(parseProviderString('cloud')).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('returns openhuman for the "openhuman" literal', () => {
|
||||
expect(parseProviderString('openhuman')).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('returns openhuman for "openhuman:<anything>"', () => {
|
||||
expect(parseProviderString('openhuman:gpt-4o')).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('parses ollama provider strings', () => {
|
||||
expect(parseProviderString('ollama:llama3.1:8b')).toEqual({
|
||||
kind: 'local',
|
||||
model: 'llama3.1:8b',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses cloud slug:model strings', () => {
|
||||
expect(parseProviderString('openai:gpt-4o')).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'openai',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
expect(parseProviderString('anthropic:claude-3-5-sonnet-20241022')).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to openhuman for unrecognised bare strings', () => {
|
||||
expect(parseProviderString('unknown-provider')).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── serializeProviderRef ─────────────────────────────────────────────────────
|
||||
|
||||
describe('serializeProviderRef', () => {
|
||||
it('serializes openhuman refs', () => {
|
||||
const ref: ProviderRef = { kind: 'openhuman' };
|
||||
expect(serializeProviderRef(ref)).toBe('openhuman');
|
||||
});
|
||||
|
||||
it('serializes cloud refs to slug:model', () => {
|
||||
const ref: ProviderRef = { kind: 'cloud', providerSlug: 'openai', model: 'gpt-4o' };
|
||||
expect(serializeProviderRef(ref)).toBe('openai:gpt-4o');
|
||||
});
|
||||
|
||||
it('serializes local refs to ollama:model', () => {
|
||||
const ref: ProviderRef = { kind: 'local', model: 'llama3.1:8b' };
|
||||
expect(serializeProviderRef(ref)).toBe('ollama:llama3.1:8b');
|
||||
});
|
||||
|
||||
it('round-trips through parseProviderString', () => {
|
||||
const cases: ProviderRef[] = [
|
||||
{ kind: 'openhuman' },
|
||||
{ kind: 'cloud', providerSlug: 'anthropic', model: 'claude-3-haiku-20240307' },
|
||||
{ kind: 'local', model: 'llama3:latest' },
|
||||
];
|
||||
for (const ref of cases) {
|
||||
expect(parseProviderString(serializeProviderRef(ref))).toEqual(ref);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadAISettings ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadAISettings', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenhumanGetClientConfig.mockReset();
|
||||
mockAuthListProviderCredentials.mockReset();
|
||||
});
|
||||
|
||||
it('returns cloudProviders with has_api_key=false when no profiles stored', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
mockAuthListProviderCredentials.mockResolvedValue(makeAuthProfileResult([]));
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
expect(settings.cloudProviders).toHaveLength(1);
|
||||
expect(settings.cloudProviders[0].slug).toBe('openai');
|
||||
expect(settings.cloudProviders[0].auth_style).toBe('bearer');
|
||||
expect(settings.cloudProviders[0].has_api_key).toBe(false);
|
||||
});
|
||||
|
||||
it('sets has_api_key=true when a matching provider:<slug> profile is stored', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_anthropic_1',
|
||||
slug: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
endpoint: 'https://api.anthropic.com/v1',
|
||||
auth_style: 'anthropic',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
// New-style key format: "provider:<slug>"
|
||||
mockAuthListProviderCredentials.mockResolvedValue(
|
||||
makeAuthProfileResult([{ id: 'prof-1', provider: 'provider:anthropic' }])
|
||||
);
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
expect(settings.cloudProviders[0].has_api_key).toBe(true);
|
||||
// auth_style must survive the round-trip unmodified.
|
||||
expect(settings.cloudProviders[0].auth_style).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('also accepts legacy bare-slug auth profiles', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_openai_2',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
// Legacy format: bare slug, no "provider:" prefix
|
||||
mockAuthListProviderCredentials.mockResolvedValue(
|
||||
makeAuthProfileResult([{ id: 'prof-2', provider: 'openai' }])
|
||||
);
|
||||
|
||||
const settings = await loadAISettings();
|
||||
expect(settings.cloudProviders[0].has_api_key).toBe(true);
|
||||
});
|
||||
|
||||
it('parses non-default per-workload routing strings correctly', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [],
|
||||
reasoning_provider: 'openai:gpt-4o',
|
||||
agentic_provider: 'anthropic:claude-3-5-sonnet-20241022',
|
||||
coding_provider: 'ollama:codellama:13b',
|
||||
memory_provider: null,
|
||||
embeddings_provider: null,
|
||||
heartbeat_provider: null,
|
||||
learning_provider: null,
|
||||
subconscious_provider: null,
|
||||
})
|
||||
);
|
||||
mockAuthListProviderCredentials.mockResolvedValue(makeAuthProfileResult([]));
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
expect(settings.routing.reasoning).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'openai',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
expect(settings.routing.agentic).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
});
|
||||
expect(settings.routing.coding).toEqual({ kind: 'local', model: 'codellama:13b' });
|
||||
expect(settings.routing.memory).toEqual({ kind: 'openhuman' });
|
||||
});
|
||||
|
||||
it('degrades gracefully when authListProviderCredentials throws', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_openai_3',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
mockAuthListProviderCredentials.mockRejectedValue(new Error('no profiles file'));
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
// Should not throw; has_api_key should default to false.
|
||||
expect(settings.cloudProviders[0].has_api_key).toBe(false);
|
||||
});
|
||||
|
||||
it('includes two cloud providers with correct labels and endpoints', async () => {
|
||||
mockOpenhumanGetClientConfig.mockResolvedValue(
|
||||
makeClientConfigResult({
|
||||
cloud_providers: [
|
||||
{
|
||||
id: 'p_openai_4',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer',
|
||||
},
|
||||
{
|
||||
id: 'p_anthropic_4',
|
||||
slug: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
endpoint: 'https://api.anthropic.com/v1',
|
||||
auth_style: 'anthropic',
|
||||
},
|
||||
],
|
||||
reasoning_provider: 'openai:gpt-4o',
|
||||
agentic_provider: 'anthropic:claude-3-5-sonnet-20241022',
|
||||
})
|
||||
);
|
||||
mockAuthListProviderCredentials.mockResolvedValue(
|
||||
makeAuthProfileResult([
|
||||
{ id: 'prof-openai', provider: 'provider:openai' },
|
||||
{ id: 'prof-anthropic', provider: 'provider:anthropic' },
|
||||
])
|
||||
);
|
||||
|
||||
const settings = await loadAISettings();
|
||||
|
||||
expect(settings.cloudProviders).toHaveLength(2);
|
||||
const openai = settings.cloudProviders.find(p => p.slug === 'openai')!;
|
||||
const anthropic = settings.cloudProviders.find(p => p.slug === 'anthropic')!;
|
||||
|
||||
expect(openai.label).toBe('OpenAI');
|
||||
expect(openai.endpoint).toBe('https://api.openai.com/v1');
|
||||
expect(openai.auth_style).toBe('bearer');
|
||||
expect(openai.has_api_key).toBe(true);
|
||||
|
||||
expect(anthropic.label).toBe('Anthropic');
|
||||
expect(anthropic.endpoint).toBe('https://api.anthropic.com/v1');
|
||||
expect(anthropic.auth_style).toBe('anthropic');
|
||||
expect(anthropic.has_api_key).toBe(true);
|
||||
|
||||
expect(settings.routing.reasoning).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'openai',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
expect(settings.routing.agentic).toEqual({
|
||||
kind: 'cloud',
|
||||
providerSlug: 'anthropic',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── saveAISettings ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('saveAISettings', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenhumanUpdateModelSettings.mockReset();
|
||||
mockOpenhumanUpdateModelSettings.mockResolvedValue({ result: {} });
|
||||
});
|
||||
|
||||
function makeSettings(overrides: Partial<AISettings> = {}): AISettings {
|
||||
return {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer',
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
reasoning: { kind: 'cloud', providerSlug: 'openai', model: 'gpt-4o' },
|
||||
agentic: { kind: 'openhuman' },
|
||||
coding: { kind: 'openhuman' },
|
||||
memory: { kind: 'openhuman' },
|
||||
embeddings: { kind: 'openhuman' },
|
||||
heartbeat: { kind: 'openhuman' },
|
||||
learning: { kind: 'openhuman' },
|
||||
subconscious: { kind: 'openhuman' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('issues no RPC call when nothing changed', async () => {
|
||||
const settings = makeSettings();
|
||||
await saveAISettings(settings, settings);
|
||||
expect(mockOpenhumanUpdateModelSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends only changed routing fields when providers are unchanged', async () => {
|
||||
const prev = makeSettings();
|
||||
const next = makeSettings({ routing: { ...prev.routing, reasoning: { kind: 'openhuman' } } });
|
||||
|
||||
await saveAISettings(prev, next);
|
||||
|
||||
expect(mockOpenhumanUpdateModelSettings).toHaveBeenCalledOnce();
|
||||
const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
|
||||
expect(patch.reasoning_provider).toBe('openhuman');
|
||||
// Other workloads unchanged — should not appear in patch.
|
||||
expect(patch.agentic_provider).toBeUndefined();
|
||||
expect(patch.cloud_providers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends cloud_providers list when a provider is added', async () => {
|
||||
const prev = makeSettings({ cloudProviders: [] });
|
||||
const next = makeSettings();
|
||||
|
||||
await saveAISettings(prev, next);
|
||||
|
||||
const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
|
||||
expect(patch.cloud_providers).toHaveLength(1);
|
||||
expect(patch.cloud_providers![0].slug).toBe('openai');
|
||||
// has_api_key must NOT be present in the wire payload — it's not part of
|
||||
// CloudProviderCreds.
|
||||
expect(patch.cloud_providers![0]).not.toHaveProperty('has_api_key');
|
||||
});
|
||||
|
||||
it('preserves auth_style through save round-trip for anthropic', async () => {
|
||||
const anthropicProvider = {
|
||||
id: 'p_anthropic_1',
|
||||
slug: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
endpoint: 'https://api.anthropic.com/v1',
|
||||
auth_style: 'anthropic' as const,
|
||||
has_api_key: true,
|
||||
};
|
||||
const prev: AISettings = {
|
||||
cloudProviders: [],
|
||||
routing: {
|
||||
reasoning: { kind: 'openhuman' },
|
||||
agentic: { kind: 'openhuman' },
|
||||
coding: { kind: 'openhuman' },
|
||||
memory: { kind: 'openhuman' },
|
||||
embeddings: { kind: 'openhuman' },
|
||||
heartbeat: { kind: 'openhuman' },
|
||||
learning: { kind: 'openhuman' },
|
||||
subconscious: { kind: 'openhuman' },
|
||||
},
|
||||
};
|
||||
const next: AISettings = { cloudProviders: [anthropicProvider], routing: { ...prev.routing } };
|
||||
|
||||
await saveAISettings(prev, next);
|
||||
|
||||
const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
|
||||
expect(patch.cloud_providers![0].auth_style).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('sends both providers and routing when both change', async () => {
|
||||
const prev = makeSettings({ cloudProviders: [] });
|
||||
const next = makeSettings({
|
||||
routing: {
|
||||
...makeSettings().routing,
|
||||
coding: { kind: 'cloud', providerSlug: 'openai', model: 'gpt-4o-mini' },
|
||||
},
|
||||
});
|
||||
|
||||
await saveAISettings(prev, next);
|
||||
|
||||
const patch = mockOpenhumanUpdateModelSettings.mock.calls[0][0];
|
||||
expect(patch.cloud_providers).toBeDefined();
|
||||
expect(patch.coding_provider).toBe('openai:gpt-4o-mini');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setCloudProviderKey ──────────────────────────────────────────────────────
|
||||
|
||||
describe('setCloudProviderKey', () => {
|
||||
beforeEach(() => {
|
||||
mockAuthStoreProviderCredentials.mockReset();
|
||||
mockAuthStoreProviderCredentials.mockResolvedValue({ result: {} });
|
||||
});
|
||||
|
||||
it('calls authStoreProviderCredentials with provider:<slug> key format', async () => {
|
||||
await setCloudProviderKey('openai', 'sk-test-key');
|
||||
|
||||
expect(mockAuthStoreProviderCredentials).toHaveBeenCalledOnce();
|
||||
const args = mockAuthStoreProviderCredentials.mock.calls[0][0];
|
||||
expect(args.provider).toBe('provider:openai');
|
||||
expect(args.token).toBe('sk-test-key');
|
||||
expect(args.profile).toBe('default');
|
||||
expect(args.setActive).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when slug is "openhuman" (session JWT — not configurable)', async () => {
|
||||
await expect(setCloudProviderKey('openhuman', 'some-key')).rejects.toThrow();
|
||||
expect(mockAuthStoreProviderCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses provider:<slug> namespace for anthropic slug', async () => {
|
||||
await setCloudProviderKey('anthropic', 'sk-ant-key');
|
||||
const args = mockAuthStoreProviderCredentials.mock.calls[0][0];
|
||||
expect(args.provider).toBe('provider:anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── clearCloudProviderKey ────────────────────────────────────────────────────
|
||||
|
||||
describe('clearCloudProviderKey', () => {
|
||||
beforeEach(() => {
|
||||
mockAuthRemoveProviderCredentials.mockReset();
|
||||
mockAuthRemoveProviderCredentials.mockResolvedValue({ result: { removed: true } });
|
||||
});
|
||||
|
||||
it('calls authRemoveProviderCredentials with provider:<slug> format', async () => {
|
||||
await clearCloudProviderKey('openai');
|
||||
|
||||
expect(mockAuthRemoveProviderCredentials).toHaveBeenCalledOnce();
|
||||
const args = mockAuthRemoveProviderCredentials.mock.calls[0][0];
|
||||
expect(args.provider).toBe('provider:openai');
|
||||
expect(args.profile).toBe('default');
|
||||
});
|
||||
|
||||
it('is a no-op for "openhuman" (session-managed, no key to clear)', async () => {
|
||||
await clearCloudProviderKey('openhuman');
|
||||
expect(mockAuthRemoveProviderCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listProviderModels ───────────────────────────────────────────────────────
|
||||
|
||||
describe('listProviderModels', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('dispatches openhuman.providers_list_models with provider_id and returns models', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: {
|
||||
models: [
|
||||
{ id: 'gpt-4o', owned_by: 'openai', context_window: 128000 },
|
||||
{ id: 'gpt-4o-mini', owned_by: 'openai', context_window: 128000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const models = await listProviderModels('p_openai_1');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.providers_list_models',
|
||||
params: { provider_id: 'p_openai_1' },
|
||||
});
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0].id).toBe('gpt-4o');
|
||||
expect(models[1].id).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
it('returns empty array when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
const models = await listProviderModels('p_openai_1');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array on RPC error (graceful degradation)', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const models = await listProviderModels('p_openai_1');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when result has no models field', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: {} });
|
||||
|
||||
const models = await listProviderModels('p_openai_1');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,18 @@
|
||||
* through this file. Keeps the wiring testable and the panel focused on
|
||||
* presentation.
|
||||
*/
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import { CORE_RPC_METHODS } from '../../services/rpcMethods';
|
||||
import {
|
||||
authListProviderCredentials,
|
||||
type AuthProfileSummary,
|
||||
authRemoveProviderCredentials,
|
||||
authStoreProviderCredentials,
|
||||
} from '../../utils/tauriCommands/auth';
|
||||
import { isTauri } from '../../utils/tauriCommands/common';
|
||||
import {
|
||||
type ClientConfig,
|
||||
type CloudProviderCreds,
|
||||
type CloudProviderType,
|
||||
type ModelSettingsUpdate,
|
||||
openhumanGetClientConfig,
|
||||
openhumanUpdateLocalAiSettings,
|
||||
@@ -68,8 +70,8 @@ export const ALL_WORKLOADS: WorkloadId[] = [...CHAT_WORKLOADS, ...BACKGROUND_WOR
|
||||
|
||||
/** Provider reference parsed from a stored provider-string. */
|
||||
export type ProviderRef =
|
||||
| { kind: 'primary' }
|
||||
| { kind: 'cloud'; providerType: CloudProviderType; model: string }
|
||||
| { kind: 'openhuman' }
|
||||
| { kind: 'cloud'; providerSlug: string; model: string }
|
||||
| { kind: 'local'; model: string };
|
||||
|
||||
/**
|
||||
@@ -80,72 +82,72 @@ export interface CloudProviderView extends CloudProviderCreds {
|
||||
has_api_key: boolean;
|
||||
}
|
||||
|
||||
/** Model descriptor returned by providers_list_models. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
owned_by?: string | null;
|
||||
context_window?: number | null;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* ProviderRef. Empty/null/`"cloud"` → openhuman. Mirrors the Rust factory grammar.
|
||||
*
|
||||
* New grammar: `"<slug>:<model>"`. Legacy bare sentinels:
|
||||
* - `"openhuman"` → { kind: 'openhuman' }
|
||||
* - `"cloud"` or empty → { kind: 'openhuman' }
|
||||
* - `"ollama:<model>"` → { kind: 'local', model }
|
||||
* - `"<slug>:<model>"` → { kind: 'cloud', providerSlug: slug, model }
|
||||
*/
|
||||
export function parseProviderString(s: string | null | undefined): ProviderRef {
|
||||
const trimmed = (s ?? '').trim();
|
||||
if (!trimmed || trimmed === 'cloud') {
|
||||
return { kind: 'primary' };
|
||||
if (!trimmed || trimmed === 'cloud' || trimmed === 'openhuman') {
|
||||
return { kind: 'openhuman' };
|
||||
}
|
||||
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(),
|
||||
};
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const slug = trimmed.slice(0, colonIdx).trim();
|
||||
const model = trimmed.slice(colonIdx + 1).trim();
|
||||
if (slug === 'openhuman') {
|
||||
return { kind: 'openhuman' };
|
||||
}
|
||||
return { kind: 'cloud', providerSlug: slug, model };
|
||||
}
|
||||
return { kind: 'primary' };
|
||||
// Unrecognised bare string → fall back to openhuman.
|
||||
return { kind: 'openhuman' };
|
||||
}
|
||||
|
||||
/** Serialise a `ProviderRef` back to the wire-format string. */
|
||||
export function serializeProviderRef(ref: ProviderRef): string {
|
||||
switch (ref.kind) {
|
||||
case 'primary':
|
||||
return 'cloud';
|
||||
case 'openhuman':
|
||||
return 'openhuman';
|
||||
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}`;
|
||||
return `${ref.providerSlug}:${ref.model}`;
|
||||
case 'local':
|
||||
return `ollama:${ref.model}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-profile key for a slug-keyed provider (matches Rust `auth_key_for_slug`).
|
||||
* Used to look up whether an API key is stored for a given provider.
|
||||
*/
|
||||
function authKeyForSlug(slug: string): string {
|
||||
return `provider:${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full AI settings view by joining:
|
||||
* - the core's client-config snapshot (cloud_providers + *_provider fields)
|
||||
@@ -161,14 +163,18 @@ export async function loadAISettings(): Promise<AISettings> {
|
||||
authListProviderCredentials().catch((): { result: AuthProfileSummary[] } => ({ result: [] })),
|
||||
]);
|
||||
const config: ClientConfig = configRes.result;
|
||||
const profilesByProvider = new Set(
|
||||
// Build a set of stored provider keys for has_api_key derivation.
|
||||
// Supports both new-style `provider:<slug>` and legacy bare `<slug>`.
|
||||
const profileProviders = 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 cloudProviders: CloudProviderView[] = config.cloud_providers.map(p => {
|
||||
const newKey = authKeyForSlug(p.slug).toLowerCase();
|
||||
const legacyKey = p.slug.toLowerCase();
|
||||
const has_api_key = profileProviders.has(newKey) || profileProviders.has(legacyKey);
|
||||
return { ...p, has_api_key };
|
||||
});
|
||||
|
||||
const routing: Record<WorkloadId, ProviderRef> = {
|
||||
reasoning: parseProviderString(config.reasoning_provider),
|
||||
@@ -181,7 +187,7 @@ export async function loadAISettings(): Promise<AISettings> {
|
||||
subconscious: parseProviderString(config.subconscious_provider),
|
||||
};
|
||||
|
||||
return { cloudProviders, primaryCloudId: config.primary_cloud, routing };
|
||||
return { cloudProviders, routing };
|
||||
}
|
||||
|
||||
// ─── Write path: diff + save ───────────────────────────────────────────────
|
||||
@@ -202,22 +208,16 @@ export async function saveAISettings(prev: AISettings, next: AISettings): Promis
|
||||
return (
|
||||
!n ||
|
||||
n.id !== p.id ||
|
||||
n.type !== p.type ||
|
||||
n.slug !== p.slug ||
|
||||
n.label !== p.label ||
|
||||
n.endpoint !== p.endpoint ||
|
||||
n.default_model !== p.default_model
|
||||
n.auth_style !== p.auth_style
|
||||
);
|
||||
})
|
||||
) {
|
||||
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 ?? '';
|
||||
patch.cloud_providers = next.cloudProviders.map(
|
||||
({ id, slug, label, endpoint, auth_style }) => ({ id, slug, label, endpoint, auth_style })
|
||||
);
|
||||
}
|
||||
|
||||
for (const w of ALL_WORKLOADS) {
|
||||
@@ -234,190 +234,53 @@ export async function saveAISettings(prev: AISettings, next: AISettings): Promis
|
||||
await openhumanUpdateModelSettings(patch);
|
||||
}
|
||||
|
||||
// ─── API key management (per cloud provider type) ──────────────────────────
|
||||
// ─── API key management (per cloud provider slug) ──────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Store an API key for a cloud provider (encrypted at rest). Keyed by slug
|
||||
* using the new `provider:<slug>` format.
|
||||
*/
|
||||
export async function setCloudProviderKey(
|
||||
providerType: CloudProviderType,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
if (providerType === 'openhuman') {
|
||||
export async function setCloudProviderKey(slug: string, apiKey: string): Promise<void> {
|
||||
if (slug === 'openhuman') {
|
||||
throw new Error('OpenHuman uses the session JWT — keys are not configurable here.');
|
||||
}
|
||||
// Store under both new-style key `provider:<slug>` and legacy bare `<slug>`
|
||||
// so old code paths that look up by bare slug continue to work.
|
||||
await authStoreProviderCredentials({
|
||||
provider: providerType,
|
||||
provider: authKeyForSlug(slug),
|
||||
profile: 'default',
|
||||
token: apiKey,
|
||||
setActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Provider key validation ──────────────────────────────────────────────
|
||||
//
|
||||
// Sanity-checks an API key by hitting the provider's "list models" endpoint.
|
||||
// Returns `ok: true` plus the model count on success, or `ok: false` with a
|
||||
// short error string on failure. Best-effort: providers we don't know how
|
||||
// to validate against (custom / local runtimes) resolve to `{ ok: true }`
|
||||
// without making a request, since "no failure" is the most useful default.
|
||||
|
||||
export interface ProviderValidationResult {
|
||||
ok: boolean;
|
||||
/** Number of models the endpoint returned on success, when available. */
|
||||
modelCount?: number;
|
||||
/** Sorted model IDs returned by the endpoint, when parseable. Used by
|
||||
* the custom-routing dialog to populate a model dropdown without
|
||||
* having to round-trip back through the core for the decrypted key. */
|
||||
modelIds?: string[];
|
||||
/** Short human-readable failure reason on `ok: false`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ValidationEndpoint {
|
||||
url: string;
|
||||
authHeader: (apiKey: string) => Record<string, string>;
|
||||
/** Extract the models list from the parsed JSON response, if shaped. */
|
||||
extractList?: (json: unknown) => unknown[] | null;
|
||||
}
|
||||
|
||||
const PROVIDER_VALIDATION: Partial<Record<CloudProviderType, ValidationEndpoint>> = {
|
||||
openai: {
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
authHeader: key => ({ Authorization: `Bearer ${key}` }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
anthropic: {
|
||||
url: 'https://api.anthropic.com/v1/models',
|
||||
authHeader: key => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
openrouter: {
|
||||
url: 'https://openrouter.ai/api/v1/models',
|
||||
authHeader: key => ({ Authorization: `Bearer ${key}` }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
export async function validateCloudProviderKey(
|
||||
providerType: CloudProviderType,
|
||||
apiKey: string
|
||||
): Promise<ProviderValidationResult> {
|
||||
const cfg = PROVIDER_VALIDATION[providerType];
|
||||
if (!cfg) {
|
||||
// Custom / local-runtime entries don't have a known models endpoint;
|
||||
// accept the key as-is.
|
||||
return { ok: true };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(cfg.url, { method: 'GET', headers: cfg.authHeader(apiKey) });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { ok: false, error: 'Key rejected — check it and try again.' };
|
||||
}
|
||||
return { ok: false, error: `Models endpoint returned ${res.status} ${res.statusText}.` };
|
||||
}
|
||||
let modelCount: number | undefined;
|
||||
let modelIds: string[] | undefined;
|
||||
try {
|
||||
const json = (await res.json()) as unknown;
|
||||
const list = cfg.extractList?.(json);
|
||||
if (Array.isArray(list)) {
|
||||
modelCount = list.length;
|
||||
const ids = list
|
||||
.map(entry => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (entry && typeof entry === 'object' && 'id' in (entry as Record<string, unknown>)) {
|
||||
const id = (entry as Record<string, unknown>).id;
|
||||
if (typeof id === 'string') return id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
if (ids.length > 0) modelIds = ids.sort();
|
||||
}
|
||||
} catch {
|
||||
// Body parse failed — the 2xx alone is good enough.
|
||||
}
|
||||
return { ok: true, modelCount, modelIds };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider model-id cache ─────────────────────────────────────────────
|
||||
//
|
||||
// `validateCloudProviderKey` is the only path where the renderer has the
|
||||
// plaintext key in hand, so it doubles as the source of truth for the
|
||||
// per-provider model dropdown shown in the custom-routing dialog. Cache
|
||||
// the IDs here (localStorage) so the dropdown is populated immediately on
|
||||
// subsequent loads without re-prompting the user for their key.
|
||||
|
||||
const MODEL_CACHE_PREFIX = 'openhuman.provider_model_ids.v1.';
|
||||
|
||||
function modelCacheKey(providerType: CloudProviderType): string {
|
||||
return `${MODEL_CACHE_PREFIX}${providerType}`;
|
||||
}
|
||||
|
||||
export function cacheProviderModelIds(providerType: CloudProviderType, modelIds: string[]): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(modelCacheKey(providerType), JSON.stringify(modelIds));
|
||||
} catch {
|
||||
// Storage full / blocked — caching is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProviderModelIds(providerType: CloudProviderType): string[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(modelCacheKey(providerType));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// Bad JSON / parse failure — fall through.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function clearProviderModelIds(providerType: CloudProviderType): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(modelCacheKey(providerType));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a stored API key. */
|
||||
export async function clearCloudProviderKey(providerType: CloudProviderType): Promise<void> {
|
||||
if (providerType === 'openhuman') {
|
||||
export async function clearCloudProviderKey(slug: string): Promise<void> {
|
||||
if (slug === 'openhuman') {
|
||||
return;
|
||||
}
|
||||
await authRemoveProviderCredentials({ provider: providerType, profile: 'default' });
|
||||
// Clear the new-style key. Legacy bare-slug entries are left as-is
|
||||
// since we can't be sure they aren't used by other things.
|
||||
await authRemoveProviderCredentials({ provider: authKeyForSlug(slug), profile: 'default' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the model list from a configured cloud provider's /models API.
|
||||
* Returns an empty array on error (callers should handle gracefully).
|
||||
*/
|
||||
export async function listProviderModels(providerId: string): Promise<ModelInfo[]> {
|
||||
if (!isTauri()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const res = await callCoreRpc<{ result: { models: ModelInfo[] } }>({
|
||||
method: CORE_RPC_METHODS.providersListModels,
|
||||
params: { provider_id: providerId },
|
||||
});
|
||||
return res?.result?.models ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Local provider façade (Ollama install / detect / model manage) ───────
|
||||
@@ -447,41 +310,24 @@ export async function loadLocalProviderSnapshot(): Promise<LocalProviderSnapshot
|
||||
/**
|
||||
* 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
|
||||
* factory level — the user should leave routes set to "openhuman" 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.
|
||||
* Set / clear the user-configured Ollama binary path.
|
||||
*/
|
||||
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.
|
||||
* Gate off the local-AI runtime.
|
||||
*/
|
||||
export async function shutdownLocalProvider(): Promise<void> {
|
||||
await setLocalRuntimeEnabled(false);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CORE_RPC_METHODS = {
|
||||
configWorkspaceOnboardingFlagExists: 'openhuman.config_workspace_onboarding_flag_exists',
|
||||
configWorkspaceOnboardingFlagSet: 'openhuman.config_workspace_onboarding_flag_set',
|
||||
corePing: 'core.ping',
|
||||
providersListModels: 'openhuman.providers_list_models',
|
||||
screenIntelligenceStatus: 'openhuman.screen_intelligence_status',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -20,20 +20,26 @@ export interface ModelRoute {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** Cloud provider type discriminator. Lowercase JSON wire format. */
|
||||
/** Authentication header style. Matches Rust AuthStyle enum. */
|
||||
export type AuthStyle = 'bearer' | 'anthropic' | 'openhuman_jwt' | 'none';
|
||||
|
||||
/** @deprecated Use AuthStyle. Kept for back-compat with old 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).
|
||||
* Endpoint config for one cloud LLM provider (new slug-keyed shape).
|
||||
* API keys are NOT carried here — they live in `auth-profiles.json`
|
||||
* (set/cleared through the `auth_*` RPCs, keyed by `provider:<slug>`).
|
||||
*/
|
||||
export interface CloudProviderCreds {
|
||||
/** Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in UI. */
|
||||
id: string;
|
||||
type: CloudProviderType;
|
||||
/** User-chosen routing key, e.g. `"openai"`. Used in `"<slug>:<model>"` strings. */
|
||||
slug: string;
|
||||
/** Human-readable display label, e.g. `"OpenAI"`. */
|
||||
label: string;
|
||||
endpoint: string;
|
||||
default_model: string;
|
||||
auth_style: AuthStyle;
|
||||
}
|
||||
|
||||
export interface ModelSettingsUpdate {
|
||||
@@ -61,9 +67,10 @@ export interface ModelSettingsUpdate {
|
||||
/**
|
||||
* When present, REPLACES `config.cloud_providers` wholesale. API keys are
|
||||
* NOT carried here — store them via `authStoreProviderCredentials`.
|
||||
* Each entry: { id?, slug, label?, endpoint, auth_style? }
|
||||
*/
|
||||
cloud_providers?: CloudProviderCreds[] | null;
|
||||
/** Id of the `cloud_providers` entry used when a workload routes to "cloud". */
|
||||
/** @deprecated No longer used — slug-based routing replaces primary_cloud. */
|
||||
primary_cloud?: string | null;
|
||||
/** Per-workload provider strings — see Rust `providers::factory` grammar. */
|
||||
reasoning_provider?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user