feat(providers): slug-keyed cloud providers + per-workload model routing (#1888)

This commit is contained in:
Steven Enamakel
2026-05-15 21:19:07 -07:00
committed by GitHub
parent 320fd6c74c
commit 86b57281dc
18 changed files with 2044 additions and 1121 deletions
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([]);
});
});
+92 -246
View File
@@ -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);
+1
View File
@@ -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;
+14 -7
View File
@@ -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;