mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +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;
|
||||
|
||||
@@ -139,6 +139,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
.extend(crate::openhuman::channels::controllers::all_channels_registered_controllers());
|
||||
// Persistent configuration management
|
||||
controllers.extend(crate::openhuman::config::all_config_registered_controllers());
|
||||
// Cloud provider model catalog queries
|
||||
controllers.extend(crate::openhuman::providers::all_providers_registered_controllers());
|
||||
// Local sidecar reachability + backend Socket.IO state diagnostics (#1527)
|
||||
controllers.extend(crate::openhuman::connectivity::all_connectivity_registered_controllers());
|
||||
// User credentials and session management
|
||||
@@ -264,6 +266,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
.extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas());
|
||||
schemas.extend(crate::openhuman::channels::controllers::all_channels_controller_schemas());
|
||||
schemas.extend(crate::openhuman::config::all_config_controller_schemas());
|
||||
schemas.extend(crate::openhuman::providers::all_providers_controller_schemas());
|
||||
schemas.extend(crate::openhuman::connectivity::all_connectivity_controller_schemas());
|
||||
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
|
||||
schemas.extend(crate::openhuman::service::all_service_controller_schemas());
|
||||
|
||||
@@ -1,18 +1,233 @@
|
||||
//! Cloud provider credential schema.
|
||||
//!
|
||||
//! Each entry in `Config::cloud_providers` represents one configured LLM
|
||||
//! backend (OpenHuman, OpenAI, Anthropic, OpenRouter, or a custom
|
||||
//! OpenAI-compatible endpoint). The factory in
|
||||
//! `crate::openhuman::providers::factory` resolves workload-to-provider
|
||||
//! strings against this list at runtime.
|
||||
//! backend. Providers are keyed by a user-chosen `slug` (e.g. `"openai"`,
|
||||
//! `"my-deepseek"`). The factory in `crate::openhuman::providers::factory`
|
||||
//! resolves workload-to-provider strings against this list at runtime using
|
||||
//! the grammar `"<slug>:<model>"`.
|
||||
//!
|
||||
//! Legacy configs that use `type`/`default_model` are migrated in-memory on
|
||||
//! load via `migrate_legacy_fields()`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Discriminator for a cloud provider entry.
|
||||
/// Authentication header style for a cloud provider.
|
||||
///
|
||||
/// Wire format is lowercase (e.g. `"openai"`). Dictates the default endpoint
|
||||
/// and the auth header style used by the chat factory.
|
||||
/// Wire format is lowercase (e.g. `"bearer"`). Determines which HTTP headers
|
||||
/// are attached when calling the provider's API.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthStyle {
|
||||
/// OpenAI-compatible: `Authorization: Bearer <key>`
|
||||
#[default]
|
||||
Bearer,
|
||||
/// Anthropic: `x-api-key: <key>` + `anthropic-version: 2023-06-01`
|
||||
Anthropic,
|
||||
/// OpenHuman session JWT (injected by the backend provider, not stored here).
|
||||
OpenhumanJwt,
|
||||
/// No auth header — e.g. local Ollama.
|
||||
None,
|
||||
}
|
||||
|
||||
impl AuthStyle {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Bearer => "bearer",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::OpenhumanJwt => "openhuman_jwt",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint config for one cloud LLM provider.
|
||||
///
|
||||
/// **Note on secrets**: API keys are NOT stored on this struct. They live in
|
||||
/// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`],
|
||||
/// keyed by `provider:<slug>` (falling back to bare `<slug>` for legacy
|
||||
/// entries). The factory looks up the token at call time via
|
||||
/// [`crate::openhuman::providers::factory::auth_key_for_slug`].
|
||||
///
|
||||
/// ## Back-compat
|
||||
///
|
||||
/// Old configs may have `type` and `default_model` fields. These are
|
||||
/// tolerated on read (via `legacy_type` / `default_model`) but never written.
|
||||
/// Call `migrate_legacy_fields()` after deserialising.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct CloudProviderCreds {
|
||||
/// Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in the UI.
|
||||
/// Generated once by [`generate_provider_id`] and never changes.
|
||||
pub id: String,
|
||||
/// Routing key chosen by the user or seeded from the legacy type.
|
||||
/// Lower-case alphanumeric + `-`. Must be unique per config and not in the
|
||||
/// reserved list (see [`is_slug_reserved`]). The factory resolves
|
||||
/// `"<slug>:<model>"` strings against this field.
|
||||
pub slug: String,
|
||||
/// Human-readable display label, supplied by the frontend. Not used in routing.
|
||||
pub label: String,
|
||||
/// OpenAI-compatible base URL (`/models`, `/chat/completions` etc. are appended).
|
||||
pub endpoint: String,
|
||||
/// Authentication header style.
|
||||
pub auth_style: AuthStyle,
|
||||
|
||||
// ── Back-compat: old `type` field ───────────────────────────────────────
|
||||
/// Legacy discriminator written by older builds. Read-only; never emitted.
|
||||
#[serde(rename = "type", default, skip_serializing)]
|
||||
pub legacy_type: Option<String>,
|
||||
|
||||
// ── Back-compat: old `default_model` field ──────────────────────────────
|
||||
/// Legacy default model written by older builds. Read-only; never emitted.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CloudProviderCreds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
slug: String::new(),
|
||||
label: String::new(),
|
||||
endpoint: String::new(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
legacy_type: None,
|
||||
default_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserved slugs that may not be used for user-configured providers.
|
||||
/// These are sentinels in the factory's routing grammar.
|
||||
pub fn is_slug_reserved(s: &str) -> bool {
|
||||
matches!(s.trim(), "" | "cloud" | "openhuman" | "ollama" | "pid")
|
||||
}
|
||||
|
||||
/// Apply legacy field migration in-place.
|
||||
///
|
||||
/// Idempotent: only fills in empty fields from the legacy `type`/`default_model`
|
||||
/// values. Safe to call on already-migrated entries.
|
||||
pub fn migrate_legacy_fields(entry: &mut CloudProviderCreds) {
|
||||
let legacy_type = entry.legacy_type.clone().unwrap_or_default();
|
||||
let lt = legacy_type.trim();
|
||||
|
||||
// Slug from legacy type when missing.
|
||||
if entry.slug.is_empty() && !lt.is_empty() {
|
||||
entry.slug = lt.to_string();
|
||||
log::debug!(
|
||||
"[config][cloud_providers] migrated slug from legacy type='{}' id={}",
|
||||
lt,
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
|
||||
// Label from static map when missing.
|
||||
if entry.label.is_empty() {
|
||||
entry.label = legacy_label_for(if entry.slug.is_empty() {
|
||||
lt
|
||||
} else {
|
||||
&entry.slug
|
||||
})
|
||||
.to_string();
|
||||
log::debug!(
|
||||
"[config][cloud_providers] migrated label='{}' for slug='{}' id={}",
|
||||
entry.label,
|
||||
entry.slug,
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
|
||||
// Endpoint from legacy defaults when missing.
|
||||
if entry.endpoint.is_empty() {
|
||||
let ep = legacy_default_endpoint(lt);
|
||||
if !ep.is_empty() {
|
||||
entry.endpoint = ep.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Auth style from legacy type when still at default Bearer.
|
||||
if entry.auth_style == AuthStyle::Bearer {
|
||||
match lt {
|
||||
"anthropic" => {
|
||||
entry.auth_style = AuthStyle::Anthropic;
|
||||
}
|
||||
"openhuman" => {
|
||||
entry.auth_style = AuthStyle::OpenhumanJwt;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a legacy type string (or slug) to a human-readable label.
|
||||
fn legacy_label_for(type_str: &str) -> &'static str {
|
||||
match type_str {
|
||||
"openhuman" => "OpenHuman",
|
||||
"openai" => "OpenAI",
|
||||
"anthropic" => "Anthropic",
|
||||
"openrouter" => "OpenRouter",
|
||||
"custom" => "Custom",
|
||||
_ => "Custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a legacy type string to its well-known default endpoint.
|
||||
fn legacy_default_endpoint(type_str: &str) -> &'static str {
|
||||
match type_str {
|
||||
"openhuman" => "https://api.openhuman.ai/v1",
|
||||
"openai" => "https://api.openai.com/v1",
|
||||
"anthropic" => "https://api.anthropic.com/v1",
|
||||
"openrouter" => "https://openrouter.ai/api/v1",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a short opaque id for a new provider entry.
|
||||
///
|
||||
/// Format: `"p_<slug>_<5 random alphanumerics>"`, e.g. `"p_openai_a8c3f"`.
|
||||
/// The random suffix is not cryptographically strong — it only needs to be
|
||||
/// unique within a single user's config file.
|
||||
pub fn generate_provider_id(slug: &str) -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
// Cheap pseudo-random from timestamp nanoseconds — adequate for local
|
||||
// config uniqueness without pulling in a PRNG crate.
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut suffix = String::with_capacity(5);
|
||||
let mut seed = nanos as usize;
|
||||
for _ in 0..5 {
|
||||
suffix.push(chars[seed % chars.len()] as char);
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
seed = (seed >> 33) ^ seed;
|
||||
}
|
||||
// Sanitise slug to only alphanumeric + '-' for the id prefix.
|
||||
let safe_slug: String = slug
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.take(20)
|
||||
.collect();
|
||||
format!("p_{}_{}", safe_slug, suffix)
|
||||
}
|
||||
|
||||
// ── Back-compat type alias ──────────────────────────────────────────────────
|
||||
// Kept so existing code that imports `CloudProviderType` compiles without
|
||||
// sweeping changes. New code should use `AuthStyle` directly.
|
||||
|
||||
/// Legacy discriminator enum. **Deprecated**: use `AuthStyle` on new entries.
|
||||
/// Retained only to satisfy callers that still pattern-match on
|
||||
/// `CloudProviderType` (e.g. the migration module). Will be removed once all
|
||||
/// call sites are updated to slug-keyed lookups.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CloudProviderType {
|
||||
@@ -56,66 +271,13 @@ impl CloudProviderType {
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint config for one cloud LLM provider.
|
||||
///
|
||||
/// **Note on secrets**: API keys are NOT stored on this struct. They live in
|
||||
/// `auth-profiles.json` via [`crate::openhuman::credentials::AuthService`],
|
||||
/// encrypted at rest under the workspace's `.secret_key`. The factory looks
|
||||
/// up the bearer token at call time by `provider_type.as_str()` (e.g.
|
||||
/// `"openai"`, `"anthropic"`), mirroring how the Composio integration stores
|
||||
/// its key under `"composio-direct:default"`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct CloudProviderCreds {
|
||||
/// Opaque stable id, e.g. `"p_openai_a8c3f"`. Never shown in the UI.
|
||||
/// Generated once by [`generate_provider_id`] and never changes.
|
||||
pub id: String,
|
||||
/// Provider type determines default endpoint, the auth-profile lookup
|
||||
/// key, and the human-readable label.
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: CloudProviderType,
|
||||
/// OpenAI-compatible base URL (`/v1/chat/completions` is appended).
|
||||
pub endpoint: String,
|
||||
/// Default model id sent to this provider when no per-workload override
|
||||
/// is configured.
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
impl Default for CloudProviderCreds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
r#type: CloudProviderType::Openhuman,
|
||||
endpoint: CloudProviderType::Openhuman.default_endpoint().to_string(),
|
||||
default_model: "reasoning-v1".to_string(),
|
||||
/// Corresponding `AuthStyle`.
|
||||
pub fn auth_style(&self) -> AuthStyle {
|
||||
match self {
|
||||
Self::Openhuman => AuthStyle::OpenhumanJwt,
|
||||
Self::Anthropic => AuthStyle::Anthropic,
|
||||
_ => AuthStyle::Bearer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a short opaque id for a new provider entry.
|
||||
///
|
||||
/// Format: `"p_<type>_<5 random alphanumerics>"`, e.g. `"p_openai_a8c3f"`.
|
||||
/// The random suffix is not cryptographically strong — it only needs to be
|
||||
/// unique within a single user's config file.
|
||||
pub fn generate_provider_id(t: &CloudProviderType) -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
// Cheap pseudo-random from timestamp nanoseconds — adequate for local
|
||||
// config uniqueness without pulling in a PRNG crate.
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut suffix = String::with_capacity(5);
|
||||
let mut seed = nanos as usize;
|
||||
for _ in 0..5 {
|
||||
suffix.push(chars[seed % chars.len()] as char);
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
seed = (seed >> 33) ^ seed;
|
||||
}
|
||||
format!("p_{}_{}", t.as_str(), suffix)
|
||||
}
|
||||
|
||||
@@ -586,6 +586,103 @@ pub(super) fn redact_url_for_log(raw: &str) -> String {
|
||||
"<unparseable url>".to_string()
|
||||
}
|
||||
|
||||
/// Migrate `cloud_providers` entries to the new slug-keyed shape and rewrite
|
||||
/// any per-workload routing strings that still use the old bare-prefix grammar.
|
||||
///
|
||||
/// This is idempotent: entries that already have a slug/label are left
|
||||
/// untouched. Routing fields that already contain a `:` are assumed to be
|
||||
/// in the new `<slug>:<model>` form.
|
||||
fn migrate_cloud_provider_slugs(config: &mut Config) {
|
||||
use super::cloud_providers::migrate_legacy_fields;
|
||||
|
||||
// Step 1: migrate every cloud_providers entry in-place.
|
||||
for entry in &mut config.cloud_providers {
|
||||
migrate_legacy_fields(entry);
|
||||
}
|
||||
|
||||
// Step 2: rewrite per-workload routing strings from legacy bare grammar.
|
||||
// Build a lookup: legacy type string → first entry with that slug.
|
||||
// After migration, `entry.slug` is populated from `legacy_type` when it
|
||||
// was empty, so we can look up by slug now.
|
||||
let slug_to_id: std::collections::HashMap<String, String> = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.map(|e| (e.slug.clone(), e.id.clone()))
|
||||
.collect();
|
||||
|
||||
// Helper: rewrite a single routing field.
|
||||
// Legacy bare strings are: "cloud", "openhuman", "openai", "anthropic",
|
||||
// "openrouter", "custom" (no ':'). New strings contain ':'.
|
||||
let rewrite = |field: &mut Option<String>| {
|
||||
let raw = match field.as_deref() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => return,
|
||||
};
|
||||
// Already in new grammar (contains ':') or is the openhuman sentinel.
|
||||
if raw.contains(':') || raw == "openhuman" {
|
||||
return;
|
||||
}
|
||||
match raw.as_str() {
|
||||
"cloud" => {
|
||||
// "cloud" sentinel: look for the primary or first non-openhuman entry.
|
||||
// If none found, leave as "openhuman".
|
||||
let primary_slug = config.primary_cloud.as_deref().and_then(|pid| {
|
||||
config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == pid)
|
||||
.map(|e| e.slug.clone())
|
||||
});
|
||||
let slug = primary_slug.or_else(|| {
|
||||
config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.slug != "openhuman")
|
||||
.map(|e| e.slug.clone())
|
||||
});
|
||||
if let Some(s) = slug {
|
||||
tracing::info!(
|
||||
"[config][migrate] rewriting routing 'cloud' → '{s}:' (empty model)"
|
||||
);
|
||||
*field = Some(format!("{s}:"));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[config][migrate] routing 'cloud' with no non-openhuman provider → 'openhuman'"
|
||||
);
|
||||
*field = Some("openhuman".to_string());
|
||||
}
|
||||
}
|
||||
other => {
|
||||
// Bare type string (e.g. "openai") — find entry by slug.
|
||||
if slug_to_id.contains_key(other) {
|
||||
tracing::info!(
|
||||
"[config][migrate] rewriting bare routing '{}' → '{}:'",
|
||||
other,
|
||||
other
|
||||
);
|
||||
*field = Some(format!("{other}:"));
|
||||
} else if other != "openhuman" {
|
||||
tracing::warn!(
|
||||
"[config][migrate] bare routing '{}' has no matching provider entry, \
|
||||
falling back to 'openhuman'",
|
||||
other
|
||||
);
|
||||
*field = Some("openhuman".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rewrite(&mut config.reasoning_provider);
|
||||
rewrite(&mut config.agentic_provider);
|
||||
rewrite(&mut config.coding_provider);
|
||||
rewrite(&mut config.memory_provider);
|
||||
rewrite(&mut config.embeddings_provider);
|
||||
rewrite(&mut config.heartbeat_provider);
|
||||
rewrite(&mut config.learning_provider);
|
||||
rewrite(&mut config.subconscious_provider);
|
||||
}
|
||||
|
||||
fn migrate_legacy_autocomplete_disabled_apps(config: &mut Config) {
|
||||
// Legacy defaults blocked both terminal and code, which prevented Codex/CLI usage.
|
||||
// Migrate only the exact legacy default so custom user preferences remain untouched.
|
||||
@@ -705,6 +802,7 @@ impl Config {
|
||||
config.workspace_dir = workspace_dir;
|
||||
migrate_legacy_autocomplete_disabled_apps(&mut config);
|
||||
migrate_legacy_inference_url(&mut config);
|
||||
migrate_cloud_provider_slugs(&mut config);
|
||||
config.apply_env_overrides();
|
||||
|
||||
if config_was_corrupted {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! Split into submodules; this module re-exports the main `Config` and all public types.
|
||||
|
||||
pub mod cloud_providers;
|
||||
pub use cloud_providers::{generate_provider_id, CloudProviderCreds, CloudProviderType};
|
||||
pub use cloud_providers::{
|
||||
generate_provider_id, is_slug_reserved, migrate_legacy_fields, AuthStyle, CloudProviderCreds,
|
||||
CloudProviderType,
|
||||
};
|
||||
mod accessibility;
|
||||
mod agent;
|
||||
mod autocomplete;
|
||||
|
||||
@@ -19,9 +19,20 @@ struct ModelRouteUpdate {
|
||||
struct CloudProviderUpdate {
|
||||
/// Opaque stable id. Empty / missing → server generates a new id.
|
||||
id: Option<String>,
|
||||
/// "openhuman" | "openai" | "anthropic" | "openrouter" | "custom"
|
||||
r#type: String,
|
||||
/// Routing slug, e.g. "openai", "my-deepseek". Must be unique per config.
|
||||
slug: String,
|
||||
/// Human-readable label.
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
endpoint: String,
|
||||
/// Auth style: "bearer" | "anthropic" | "openhuman_jwt" | "none".
|
||||
#[serde(default)]
|
||||
auth_style: Option<String>,
|
||||
/// Legacy field — tolerated on read for back-compat but not required.
|
||||
#[serde(rename = "type", default)]
|
||||
legacy_type: Option<String>,
|
||||
/// Legacy field — tolerated on read.
|
||||
#[serde(default)]
|
||||
default_model: Option<String>,
|
||||
}
|
||||
|
||||
@@ -407,7 +418,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
FieldSchema {
|
||||
name: "cloud_providers",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
|
||||
comment: "Optional list of cloud provider entries {id, type, endpoint, default_model}. API keys are stored separately via cloud_provider_set_key. Replaces config.cloud_providers wholesale.",
|
||||
comment: "Optional list of cloud provider entries {id, slug, label, endpoint, auth_style}. API keys are stored separately via cloud_provider_set_key. Replaces config.cloud_providers wholesale.",
|
||||
required: false,
|
||||
},
|
||||
optional_string("primary_cloud", "id of the cloud_providers entry used when a workload routes to 'cloud'. Empty string clears."),
|
||||
@@ -891,9 +902,10 @@ fn handle_get_client_config(_params: Map<String, Value>) -> ControllerFuture {
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"type": c.r#type.as_str(),
|
||||
"slug": c.slug,
|
||||
"label": c.label,
|
||||
"endpoint": c.endpoint,
|
||||
"default_model": c.default_model,
|
||||
"auth_style": c.auth_style.as_str(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -951,36 +963,62 @@ fn handle_update_model_settings(params: Map<String, Value>) -> ControllerFuture
|
||||
.cloud_providers
|
||||
.map(|entries| {
|
||||
use crate::openhuman::config::schema::cloud_providers::{
|
||||
generate_provider_id, CloudProviderCreds, CloudProviderType,
|
||||
generate_provider_id, is_slug_reserved, migrate_legacy_fields, AuthStyle,
|
||||
CloudProviderCreds,
|
||||
};
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let r#type = match e.r#type.to_ascii_lowercase().as_str() {
|
||||
"openhuman" => CloudProviderType::Openhuman,
|
||||
"openai" => CloudProviderType::Openai,
|
||||
"anthropic" => CloudProviderType::Anthropic,
|
||||
"openrouter" => CloudProviderType::Openrouter,
|
||||
"custom" => CloudProviderType::Custom,
|
||||
let slug = e.slug.trim().to_string();
|
||||
if slug.is_empty() {
|
||||
return Err(
|
||||
"cloud provider slug must not be empty".to_string()
|
||||
);
|
||||
}
|
||||
if is_slug_reserved(&slug) {
|
||||
return Err(format!(
|
||||
"slug '{}' is reserved and cannot be used for a custom provider",
|
||||
slug
|
||||
));
|
||||
}
|
||||
let auth_style = match e
|
||||
.auth_style
|
||||
.as_deref()
|
||||
.unwrap_or("bearer")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"bearer" => AuthStyle::Bearer,
|
||||
"anthropic" => AuthStyle::Anthropic,
|
||||
"openhuman_jwt" | "openhumanjwt" => AuthStyle::OpenhumanJwt,
|
||||
"none" => AuthStyle::None,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown cloud provider type '{}'; \
|
||||
valid values: openhuman, openai, anthropic, \
|
||||
openrouter, custom",
|
||||
"unknown auth_style '{}'; valid: bearer, anthropic, openhuman_jwt, none",
|
||||
other
|
||||
))
|
||||
}
|
||||
};
|
||||
let id =
|
||||
e.id.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| generate_provider_id(&r#type));
|
||||
let default_model = e.default_model.unwrap_or_default();
|
||||
Ok(CloudProviderCreds {
|
||||
let id = e
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| generate_provider_id(&slug));
|
||||
let label = e
|
||||
.label
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| slug.clone());
|
||||
let mut entry = CloudProviderCreds {
|
||||
id,
|
||||
r#type,
|
||||
slug,
|
||||
label,
|
||||
endpoint: e.endpoint,
|
||||
default_model,
|
||||
})
|
||||
auth_style,
|
||||
legacy_type: e.legacy_type,
|
||||
default_model: e.default_model,
|
||||
};
|
||||
// Apply any remaining legacy-field migration.
|
||||
migrate_legacy_fields(&mut entry);
|
||||
Ok(entry)
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
//! URL doesn't look like the OpenHuman backend.
|
||||
|
||||
use crate::openhuman::config::schema::cloud_providers::{
|
||||
generate_provider_id, CloudProviderCreds, CloudProviderType,
|
||||
generate_provider_id, AuthStyle, CloudProviderCreds, CloudProviderType,
|
||||
};
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
@@ -98,10 +98,13 @@ fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) {
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "reasoning-v1".to_string());
|
||||
config.cloud_providers.push(CloudProviderCreds {
|
||||
id: generate_provider_id(&CloudProviderType::Openhuman),
|
||||
r#type: CloudProviderType::Openhuman,
|
||||
id: generate_provider_id("openhuman"),
|
||||
slug: "openhuman".to_string(),
|
||||
label: "OpenHuman".to_string(),
|
||||
endpoint: oh_endpoint,
|
||||
default_model: oh_default_model,
|
||||
auth_style: AuthStyle::OpenhumanJwt,
|
||||
default_model: Some(oh_default_model),
|
||||
..Default::default()
|
||||
});
|
||||
stats.cloud_providers_seeded += 1;
|
||||
|
||||
@@ -121,12 +124,15 @@ fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) {
|
||||
.find(|r| r.hint.eq_ignore_ascii_case("reasoning"))
|
||||
.or_else(|| config.model_routes.first())
|
||||
.map(|r| r.model.clone())
|
||||
.unwrap_or_default();
|
||||
.filter(|m| !m.is_empty());
|
||||
config.cloud_providers.push(CloudProviderCreds {
|
||||
id: generate_provider_id(&CloudProviderType::Custom),
|
||||
r#type: CloudProviderType::Custom,
|
||||
id: generate_provider_id("custom"),
|
||||
slug: "custom".to_string(),
|
||||
label: "Custom".to_string(),
|
||||
endpoint: trimmed.to_string(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
default_model,
|
||||
..Default::default()
|
||||
});
|
||||
stats.cloud_providers_seeded += 1;
|
||||
log::info!(
|
||||
@@ -146,7 +152,7 @@ fn set_primary_cloud(config: &mut Config, stats: &mut MigrationStats) {
|
||||
let oh = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.r#type == CloudProviderType::Openhuman);
|
||||
.find(|e| e.slug == "openhuman" || e.legacy_type.as_deref() == Some("openhuman"));
|
||||
if let Some(entry) = oh {
|
||||
config.primary_cloud = Some(entry.id.clone());
|
||||
stats.primary_cloud_set = true;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Tests for the 1 → 2 AI-provider unification migration.
|
||||
|
||||
use super::*;
|
||||
use crate::openhuman::config::schema::cloud_providers::CloudProviderType;
|
||||
use crate::openhuman::config::schema::{LocalAiConfig, LocalAiUsage};
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
@@ -30,7 +29,7 @@ fn empty_config_seeds_openhuman_entry() {
|
||||
|
||||
assert_eq!(stats.cloud_providers_seeded, 1);
|
||||
assert_eq!(c.cloud_providers.len(), 1);
|
||||
assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman);
|
||||
assert_eq!(c.cloud_providers[0].slug, "openhuman");
|
||||
assert!(c.cloud_providers[0].id.starts_with("p_openhuman_"));
|
||||
}
|
||||
|
||||
@@ -59,10 +58,10 @@ fn legacy_inference_url_becomes_custom_entry() {
|
||||
let custom = c
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.r#type == CloudProviderType::Custom)
|
||||
.find(|e| e.slug == "custom")
|
||||
.expect("custom entry must be seeded");
|
||||
assert_eq!(custom.endpoint, "https://api.example.com/v1");
|
||||
assert_eq!(custom.default_model, "gpt-4o");
|
||||
assert_eq!(custom.default_model.as_deref(), Some("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,7 +71,7 @@ fn openhuman_inference_url_does_not_seed_custom() {
|
||||
let _ = run(&mut c).expect("migration must succeed");
|
||||
// Only the openhuman entry should be seeded — no Custom entry.
|
||||
assert_eq!(c.cloud_providers.len(), 1);
|
||||
assert_eq!(c.cloud_providers[0].r#type, CloudProviderType::Openhuman);
|
||||
assert_eq!(c.cloud_providers[0].slug, "openhuman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,7 +127,7 @@ fn chat_workload_providers_left_unset() {
|
||||
let mut c = make_legacy_config_local_on();
|
||||
let _ = run(&mut c).unwrap();
|
||||
// Reasoning/agentic/coding have no legacy equivalent — they stay None
|
||||
// and the factory defaults them to "cloud" at runtime.
|
||||
// and the factory defaults them to "openhuman" at runtime.
|
||||
assert_eq!(c.reasoning_provider, None);
|
||||
assert_eq!(c.agentic_provider, None);
|
||||
assert_eq!(c.coding_provider, None);
|
||||
|
||||
@@ -77,6 +77,8 @@ pub enum AuthStyle {
|
||||
Bearer,
|
||||
/// `x-api-key: <key>` (used by some Chinese providers)
|
||||
XApiKey,
|
||||
/// Anthropic-specific: `x-api-key: <key>` + `anthropic-version: 2023-06-01`
|
||||
Anthropic,
|
||||
/// Custom header name
|
||||
Custom(String),
|
||||
}
|
||||
@@ -362,6 +364,9 @@ impl OpenAiCompatibleProvider {
|
||||
req.header("Authorization", format!("Bearer {credential}"))
|
||||
}
|
||||
(AuthStyle::XApiKey, Some(credential)) => req.header("x-api-key", credential),
|
||||
(AuthStyle::Anthropic, Some(credential)) => req
|
||||
.header("x-api-key", credential)
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
(AuthStyle::Custom(header), Some(credential)) => req.header(header, credential),
|
||||
}
|
||||
}
|
||||
@@ -1692,6 +1697,9 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
(AuthStyle::XApiKey, Some(credential)) => {
|
||||
req_builder.header("x-api-key", credential)
|
||||
}
|
||||
(AuthStyle::Anthropic, Some(credential)) => req_builder
|
||||
.header("x-api-key", credential)
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
(AuthStyle::Custom(header), Some(credential)) => {
|
||||
req_builder.header(header, credential)
|
||||
}
|
||||
|
||||
+202
-293
@@ -7,45 +7,42 @@
|
||||
//! ## Provider-string grammar
|
||||
//!
|
||||
//! ```text
|
||||
//! "cloud" → resolves to primary_cloud entry; if that entry has
|
||||
//! type=openhuman, behaves as "openhuman"
|
||||
//! "openhuman" → OpenHumanBackendProvider; model = config.default_model
|
||||
//! "openai:<model>" → cloud_providers entry of type=openai + Bearer auth
|
||||
//! "anthropic:<model>" → cloud_providers entry of type=anthropic + Bearer auth
|
||||
//! "openrouter:<model>" → cloud_providers entry of type=openrouter + Bearer auth
|
||||
//! "custom:<model>" → cloud_providers entry of type=custom + Bearer auth
|
||||
//! "ollama:<model>" → local Ollama at config.local_ai.base_url
|
||||
//! "openhuman" → OpenHumanBackendProvider; model = config.default_model
|
||||
//! "ollama:<model>" → local Ollama at config.local_ai.base_url
|
||||
//! "<slug>:<model>" → cloud_providers entry keyed by slug;
|
||||
//! builds OpenAiCompatibleProvider (Bearer) or Anthropic
|
||||
//! flavour depending on auth_style.
|
||||
//! "" / missing → falls back to "openhuman"
|
||||
//! ```
|
||||
//!
|
||||
//! Unknown strings and missing-creds configurations produce actionable errors.
|
||||
//! Unknown slugs and missing-creds configurations produce actionable errors.
|
||||
|
||||
use crate::openhuman::config::schema::cloud_providers::CloudProviderType;
|
||||
use crate::openhuman::config::schema::cloud_providers::AuthStyle;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::credentials::AuthService;
|
||||
use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
|
||||
use crate::openhuman::providers::compatible::{
|
||||
AuthStyle as CompatAuthStyle, OpenAiCompatibleProvider,
|
||||
};
|
||||
use crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider;
|
||||
use crate::openhuman::providers::traits::Provider;
|
||||
use crate::openhuman::providers::ProviderRuntimeOptions;
|
||||
|
||||
/// Sentinel meaning "use whatever primary_cloud resolves to".
|
||||
pub const PROVIDER_CLOUD: &str = "cloud";
|
||||
/// Sentinel meaning "use the OpenHuman backend session JWT".
|
||||
pub const PROVIDER_OPENHUMAN: &str = "openhuman";
|
||||
/// Prefix for Ollama-local providers: `"ollama:<model>"`.
|
||||
pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:";
|
||||
/// Prefix for OpenAI-compatible providers: `"openai:<model>"`.
|
||||
pub const OPENAI_PROVIDER_PREFIX: &str = "openai:";
|
||||
/// Prefix for Anthropic-compatible providers: `"anthropic:<model>"`.
|
||||
pub const ANTHROPIC_PROVIDER_PREFIX: &str = "anthropic:";
|
||||
/// Prefix for OpenRouter providers: `"openrouter:<model>"`.
|
||||
pub const OPENROUTER_PROVIDER_PREFIX: &str = "openrouter:";
|
||||
/// Prefix for custom OpenAI-compatible providers: `"custom:<model>"`.
|
||||
pub const CUSTOM_PROVIDER_PREFIX: &str = "custom:";
|
||||
|
||||
/// Auth-profile storage key for a slug-keyed provider.
|
||||
///
|
||||
/// New writes use `"provider:<slug>"`. Lookups also try the bare `<slug>`
|
||||
/// as a legacy fallback (old configs stored keys as e.g. `"openai:default"`).
|
||||
pub fn auth_key_for_slug(slug: &str) -> String {
|
||||
format!("provider:{slug}")
|
||||
}
|
||||
|
||||
/// Return the configured provider string for a named workload role.
|
||||
///
|
||||
/// Returns `"cloud"` when the workload has no explicit override, which causes
|
||||
/// the factory to resolve via `primary_cloud`.
|
||||
/// Returns `"openhuman"` when the workload has no explicit override.
|
||||
pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
let opt = match role {
|
||||
"reasoning" => config.reasoning_provider.as_deref(),
|
||||
@@ -64,20 +61,14 @@ pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
_ => None,
|
||||
};
|
||||
let s = opt.unwrap_or("").trim();
|
||||
if s.is_empty() {
|
||||
PROVIDER_CLOUD.to_string()
|
||||
if s.is_empty() || s == "cloud" {
|
||||
PROVIDER_OPENHUMAN.to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `(Provider, model)` for the given workload role.
|
||||
///
|
||||
/// Equivalent to:
|
||||
/// ```rust,ignore
|
||||
/// let s = provider_for_role(role, config);
|
||||
/// create_chat_provider_from_string(role, &s, config)
|
||||
/// ```
|
||||
pub fn create_chat_provider(
|
||||
role: &str,
|
||||
config: &Config,
|
||||
@@ -106,8 +97,9 @@ pub fn create_chat_provider_from_string(
|
||||
p
|
||||
);
|
||||
|
||||
if p == PROVIDER_CLOUD || p.is_empty() {
|
||||
return resolve_cloud_primary(role, config);
|
||||
// Empty / legacy "cloud" sentinel → OpenHuman backend.
|
||||
if p.is_empty() || p == "cloud" {
|
||||
return make_openhuman_backend(config);
|
||||
}
|
||||
|
||||
if p == PROVIDER_OPENHUMAN {
|
||||
@@ -126,106 +118,42 @@ pub fn create_chat_provider_from_string(
|
||||
return make_ollama_provider(model.trim(), config);
|
||||
}
|
||||
|
||||
// Third-party cloud providers — look up the matching entry by type.
|
||||
let (type_str, model) = parse_typed_prefix(p).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[chat-factory] unrecognised provider string '{}' for role '{}'. \
|
||||
Valid forms: cloud, openhuman, ollama:<m>, openai:<m>, \
|
||||
anthropic:<m>, openrouter:<m>, custom:<m>",
|
||||
p,
|
||||
role
|
||||
)
|
||||
})?;
|
||||
// New grammar: "<slug>:<model>"
|
||||
if let Some(colon_pos) = p.find(':') {
|
||||
let slug = p[..colon_pos].trim();
|
||||
let model = p[colon_pos + 1..].trim();
|
||||
|
||||
if model.trim().is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] provider string '{}' for role '{}' has an empty model",
|
||||
p,
|
||||
role
|
||||
);
|
||||
if slug.is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] provider string '{}' for role '{}' has an empty slug",
|
||||
p,
|
||||
role
|
||||
);
|
||||
}
|
||||
|
||||
return make_cloud_provider_by_slug(role, slug, model, config);
|
||||
}
|
||||
|
||||
let provider_type = match type_str {
|
||||
"openai" => CloudProviderType::Openai,
|
||||
"anthropic" => CloudProviderType::Anthropic,
|
||||
"openrouter" => CloudProviderType::Openrouter,
|
||||
"custom" => CloudProviderType::Custom,
|
||||
other => anyhow::bail!(
|
||||
"[chat-factory] unknown provider type '{}' in provider string '{}' for role '{}'",
|
||||
other,
|
||||
p,
|
||||
role
|
||||
),
|
||||
};
|
||||
|
||||
make_cloud_provider_by_type(role, &provider_type, model.trim(), config)
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the `"cloud"` sentinel by consulting `primary_cloud`.
|
||||
fn resolve_cloud_primary(
|
||||
role: &str,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<(Box<dyn Provider>, String)> {
|
||||
// Find the primary entry (or fall back to an OpenHuman entry / first entry).
|
||||
let entry = if let Some(ref primary_id) = config.primary_cloud {
|
||||
config.cloud_providers.iter().find(|e| &e.id == primary_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let entry = entry.or_else(|| {
|
||||
// Implicit fallback: first openhuman entry, then any entry.
|
||||
// No colon: might be a bare legacy type string (e.g. "openai"). Try as
|
||||
// slug lookup with empty model — gives a clear "no entry" error rather
|
||||
// than an opaque parse failure.
|
||||
anyhow::bail!(
|
||||
"[chat-factory] unrecognised provider string '{}' for role '{}'. \
|
||||
Valid forms: openhuman, ollama:<model>, <slug>:<model>. \
|
||||
Configured slugs: [{}]",
|
||||
p,
|
||||
role,
|
||||
config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.r#type == CloudProviderType::Openhuman)
|
||||
.or_else(|| config.cloud_providers.first())
|
||||
});
|
||||
|
||||
match entry {
|
||||
None => {
|
||||
// No cloud_providers configured at all — route to the OpenHuman backend.
|
||||
log::debug!(
|
||||
"[providers][chat-factory] no cloud_providers entries, \
|
||||
falling back to openhuman backend for role={}",
|
||||
role
|
||||
);
|
||||
make_openhuman_backend(config)
|
||||
}
|
||||
Some(e) if e.r#type == CloudProviderType::Openhuman => {
|
||||
log::debug!(
|
||||
"[providers][chat-factory] primary resolves to openhuman backend for role={}",
|
||||
role
|
||||
);
|
||||
make_openhuman_backend(config)
|
||||
}
|
||||
Some(e) => {
|
||||
let model = e.default_model.clone();
|
||||
if model.trim().is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] primary cloud provider '{}' (type={}) has an empty \
|
||||
default_model — set a model for role '{}'",
|
||||
e.id,
|
||||
e.r#type.label(),
|
||||
role
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"[providers][chat-factory] role={} resolved cloud→{}:{} endpoint_host={}",
|
||||
role,
|
||||
e.r#type.label(),
|
||||
model,
|
||||
redact_endpoint(&e.endpoint)
|
||||
);
|
||||
let key = lookup_provider_key(&e.r#type, config)?;
|
||||
let p = make_openai_compatible_provider(&e.endpoint, &key)?;
|
||||
Ok((p, model))
|
||||
}
|
||||
}
|
||||
.map(|e| e.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the OpenHuman backend provider (session-JWT auth).
|
||||
fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>, String)> {
|
||||
let model = config
|
||||
@@ -253,19 +181,7 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
|
||||
options.secrets_encrypt
|
||||
);
|
||||
// Translate `hint:<tier>` model strings into the OpenHuman backend's
|
||||
// canonical tier names. The legacy `create_intelligent_routing_provider`
|
||||
// path did this inside `IntelligentRoutingProvider::resolve_remote_model`;
|
||||
// #1710's factory bypassed that wrapper, which broke the web-chat
|
||||
// `model_override` contract (json_rpc_e2e `routing_cases`):
|
||||
// `hint:reasoning` was reaching the backend verbatim instead of
|
||||
// `reasoning-v1`. We apply ONLY the model-name mapping here — not the
|
||||
// full routing wrapper, which also injects local-AI health probing and
|
||||
// a streaming shim that the web-chat SSE path doesn't tolerate (it
|
||||
// hangs `chat_done`). Mapping mirrors `resolve_remote_model`'s
|
||||
// heavy-tier arm exactly: known heavy tiers map to `<tier>-v1`;
|
||||
// lightweight hints (`hint:reaction`, …) and already-exact tier names
|
||||
// pass through untouched. Third-party cloud providers never see
|
||||
// `hint:` strings, so this stays scoped to the OpenHuman backend.
|
||||
// canonical tier names.
|
||||
let model = match model.strip_prefix("hint:") {
|
||||
Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(),
|
||||
Some("chat") => crate::openhuman::config::MODEL_REASONING_QUICK_V1.to_string(),
|
||||
@@ -297,85 +213,124 @@ fn make_ollama_provider(
|
||||
model,
|
||||
redact_endpoint(&endpoint)
|
||||
);
|
||||
let p = make_openai_compatible_provider(&endpoint, "")?;
|
||||
let p = make_openai_compatible_provider(&endpoint, "", CompatAuthStyle::Bearer)?;
|
||||
Ok((p, model.to_string()))
|
||||
}
|
||||
|
||||
/// Look up a cloud_providers entry by type and build the provider.
|
||||
fn make_cloud_provider_by_type(
|
||||
/// Look up a `cloud_providers` entry by slug and build the provider.
|
||||
fn make_cloud_provider_by_slug(
|
||||
role: &str,
|
||||
provider_type: &CloudProviderType,
|
||||
slug: &str,
|
||||
model: &str,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<(Box<dyn Provider>, String)> {
|
||||
let entry = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| &e.r#type == provider_type);
|
||||
let entry = config.cloud_providers.iter().find(|e| e.slug == slug);
|
||||
|
||||
let entry = entry.ok_or_else(|| {
|
||||
let known: Vec<&str> = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.map(|e| e.slug.as_str())
|
||||
.collect();
|
||||
anyhow::anyhow!(
|
||||
"[chat-factory] no cloud provider configured for type '{}' (role '{}') — \
|
||||
add a {} entry to cloud_providers in config.toml",
|
||||
provider_type.as_str(),
|
||||
"[chat-factory] no cloud provider configured for slug '{}' (role '{}') — \
|
||||
add an entry with that slug to cloud_providers in config.toml. \
|
||||
Configured slugs: [{}]",
|
||||
slug,
|
||||
role,
|
||||
provider_type.label()
|
||||
known.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Resolve effective model: use provided model if non-empty, else fall back
|
||||
// to the entry's legacy default_model (if any), else empty → error.
|
||||
let effective_model = if model.trim().is_empty() {
|
||||
entry.default_model.clone().unwrap_or_default()
|
||||
} else {
|
||||
model.to_string()
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[providers][chat-factory] role={} type={} model={} endpoint_host={}",
|
||||
"[providers][chat-factory] role={} slug={} model={} endpoint_host={}",
|
||||
role,
|
||||
provider_type.label(),
|
||||
model,
|
||||
slug,
|
||||
effective_model,
|
||||
redact_endpoint(&entry.endpoint)
|
||||
);
|
||||
let key = lookup_provider_key(provider_type, config)?;
|
||||
let p = make_openai_compatible_provider(&entry.endpoint, &key)?;
|
||||
Ok((p, model.to_string()))
|
||||
|
||||
let key = lookup_key_for_slug(slug, config)?;
|
||||
|
||||
match entry.auth_style {
|
||||
AuthStyle::Anthropic => {
|
||||
let p =
|
||||
make_openai_compatible_provider(&entry.endpoint, &key, CompatAuthStyle::Anthropic)?;
|
||||
Ok((p, effective_model))
|
||||
}
|
||||
AuthStyle::OpenhumanJwt => {
|
||||
// Route to the OpenHuman backend — ignore the entry's endpoint
|
||||
// and model; use the backend provider with the configured default.
|
||||
log::debug!(
|
||||
"[providers][chat-factory] slug='{}' has auth_style=OpenhumanJwt → routing to openhuman backend",
|
||||
slug
|
||||
);
|
||||
make_openhuman_backend(config)
|
||||
}
|
||||
AuthStyle::None => {
|
||||
let p = make_openai_compatible_provider(&entry.endpoint, "", CompatAuthStyle::Bearer)?;
|
||||
Ok((p, effective_model))
|
||||
}
|
||||
AuthStyle::Bearer => {
|
||||
let p =
|
||||
make_openai_compatible_provider(&entry.endpoint, &key, CompatAuthStyle::Bearer)?;
|
||||
Ok((p, effective_model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the encrypted bearer token for a cloud provider type from the
|
||||
/// workspace `auth-profiles.json` via the shared [`AuthService`].
|
||||
/// Fetch the bearer token for a slug from the workspace `auth-profiles.json`.
|
||||
///
|
||||
/// Each provider type maps to a default profile named `"<type>:default"`
|
||||
/// (e.g. `"openai:default"`), mirroring how the Composio integration stores
|
||||
/// `"composio-direct:default"`. Missing or empty keys return `Ok(String::new())`
|
||||
/// — callers (and `make_openai_compatible_provider`) treat that as "no auth",
|
||||
/// which surfaces an authentication error at first call rather than at factory
|
||||
/// build time. This keeps the factory testable without forcing every test to
|
||||
/// seed an auth profile.
|
||||
fn lookup_provider_key(
|
||||
provider_type: &CloudProviderType,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<String> {
|
||||
// OpenHuman uses the session JWT path; no separate key here.
|
||||
if matches!(provider_type, CloudProviderType::Openhuman) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
/// Tries `provider:<slug>` first (new key format), then the bare `<slug>`
|
||||
/// (legacy format where keys were stored as `"openai"`, `"anthropic"`, etc.).
|
||||
/// Missing or empty keys return `Ok(String::new())` — callers treat that as
|
||||
/// "no auth", which surfaces an authentication error at first call rather than
|
||||
/// at factory build time.
|
||||
pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result<String> {
|
||||
let auth = AuthService::from_config(config);
|
||||
// Try new-style key first.
|
||||
let new_key = auth_key_for_slug(slug);
|
||||
if let Ok(Some(k)) = auth.get_provider_bearer_token(&new_key, None) {
|
||||
if !k.is_empty() {
|
||||
log::debug!(
|
||||
"[providers][chat-factory] auth lookup slug={} key_present=true (new-style)",
|
||||
slug
|
||||
);
|
||||
return Ok(k);
|
||||
}
|
||||
}
|
||||
// Fall back to legacy bare slug.
|
||||
let key = auth
|
||||
.get_provider_bearer_token(provider_type.as_str(), None)
|
||||
.get_provider_bearer_token(slug, None)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"[chat-factory] failed to read API key for provider '{}': {}",
|
||||
provider_type.as_str(),
|
||||
"[chat-factory] failed to read API key for slug '{}': {}",
|
||||
slug,
|
||||
e
|
||||
)
|
||||
})?
|
||||
.unwrap_or_default();
|
||||
log::debug!(
|
||||
"[providers][chat-factory] auth lookup type={} key_present={}",
|
||||
provider_type.as_str(),
|
||||
"[providers][chat-factory] auth lookup slug={} key_present={}",
|
||||
slug,
|
||||
!key.is_empty()
|
||||
);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Build an `OpenAiCompatibleProvider` with Bearer auth.
|
||||
/// Build an `OpenAiCompatibleProvider` with the given auth style.
|
||||
fn make_openai_compatible_provider(
|
||||
endpoint: &str,
|
||||
api_key: &str,
|
||||
auth_style: CompatAuthStyle,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
let key = if api_key.trim().is_empty() {
|
||||
None
|
||||
@@ -383,95 +338,69 @@ fn make_openai_compatible_provider(
|
||||
Some(api_key)
|
||||
};
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"cloud",
|
||||
endpoint,
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
"cloud", endpoint, key, auth_style,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Return a safe-to-log representation of a URL endpoint: `scheme://host` only.
|
||||
///
|
||||
/// User-configured endpoints can embed API keys or tokens in the query string
|
||||
/// or even in the authority (e.g. `https://key@host/`). Logging the raw URL
|
||||
/// violates the "never log secrets" rule. This helper strips everything except
|
||||
/// the scheme and host so logs are still useful for debugging routing issues
|
||||
/// without leaking credentials.
|
||||
fn redact_endpoint(url: &str) -> String {
|
||||
let trimmed = url.trim();
|
||||
// Try to extract scheme://host by splitting on "://" and then on "/", "@", "?".
|
||||
if let Some(rest) = trimmed.split_once("://") {
|
||||
let scheme = rest.0;
|
||||
// Strip any userinfo (user:pass@host) and take only up to the first path/query.
|
||||
let authority = rest.1.split('/').next().unwrap_or("");
|
||||
let host = authority.split('@').last().unwrap_or(authority);
|
||||
let host_no_query = host.split('?').next().unwrap_or(host);
|
||||
return format!("{}://{}", scheme, host_no_query);
|
||||
}
|
||||
// No "://" — probably a bare host or unrecognised; log a placeholder.
|
||||
"<endpoint>".to_string()
|
||||
}
|
||||
|
||||
/// Split `"openai:gpt-4o"` → `("openai", "gpt-4o")`, or `None` if unrecognised.
|
||||
fn parse_typed_prefix(s: &str) -> Option<(&str, &str)> {
|
||||
for prefix in &[
|
||||
OPENAI_PROVIDER_PREFIX,
|
||||
ANTHROPIC_PROVIDER_PREFIX,
|
||||
OPENROUTER_PROVIDER_PREFIX,
|
||||
CUSTOM_PROVIDER_PREFIX,
|
||||
] {
|
||||
if let Some(model) = s.strip_prefix(prefix) {
|
||||
let type_str = prefix.trim_end_matches(':');
|
||||
return Some((type_str, model));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::config::schema::cloud_providers::{
|
||||
CloudProviderCreds, CloudProviderType,
|
||||
};
|
||||
use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds};
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
fn config_with_providers(
|
||||
providers: Vec<CloudProviderCreds>,
|
||||
primary: Option<String>,
|
||||
) -> Config {
|
||||
fn config_with_providers(providers: Vec<CloudProviderCreds>) -> Config {
|
||||
let mut c = Config::default();
|
||||
c.cloud_providers = providers;
|
||||
c.primary_cloud = primary;
|
||||
c
|
||||
}
|
||||
|
||||
fn oh_entry(id: &str) -> CloudProviderCreds {
|
||||
CloudProviderCreds {
|
||||
id: id.to_string(),
|
||||
r#type: CloudProviderType::Openhuman,
|
||||
slug: "openhuman".to_string(),
|
||||
label: "OpenHuman".to_string(),
|
||||
endpoint: "https://api.openhuman.ai/v1".to_string(),
|
||||
default_model: "reasoning-v1".to_string(),
|
||||
auth_style: AuthStyle::OpenhumanJwt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_entry(id: &str, model: &str) -> CloudProviderCreds {
|
||||
fn openai_entry(id: &str, slug: &str) -> CloudProviderCreds {
|
||||
CloudProviderCreds {
|
||||
id: id.to_string(),
|
||||
r#type: CloudProviderType::Openai,
|
||||
slug: slug.to_string(),
|
||||
label: "OpenAI".to_string(),
|
||||
endpoint: "https://api.openai.com/v1".to_string(),
|
||||
default_model: model.to_string(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
default_model: Some("gpt-4o".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_entry(id: &str, model: &str) -> CloudProviderCreds {
|
||||
fn anthropic_entry(id: &str, slug: &str) -> CloudProviderCreds {
|
||||
CloudProviderCreds {
|
||||
id: id.to_string(),
|
||||
r#type: CloudProviderType::Anthropic,
|
||||
slug: slug.to_string(),
|
||||
label: "Anthropic".to_string(),
|
||||
endpoint: "https://api.anthropic.com/v1".to_string(),
|
||||
default_model: model.to_string(),
|
||||
auth_style: AuthStyle::Anthropic,
|
||||
default_model: Some("claude-sonnet-4-6".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +417,7 @@ mod tests {
|
||||
#[test]
|
||||
fn cloud_no_providers_falls_back_to_openhuman() {
|
||||
let config = Config::default();
|
||||
// "cloud" sentinel still works — routes to openhuman.
|
||||
let result = create_chat_provider_from_string("reasoning", "cloud", &config);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
@@ -497,36 +427,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_with_openhuman_primary() {
|
||||
let config = config_with_providers(vec![oh_entry("p_oh")], Some("p_oh".to_string()));
|
||||
let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config)
|
||||
.expect("cloud→openhuman primary must build");
|
||||
fn openhuman_slug_routes_to_backend() {
|
||||
let config = config_with_providers(vec![oh_entry("p_oh")]);
|
||||
let (_, model) = create_chat_provider_from_string("reasoning", "openhuman:", &config)
|
||||
.expect("openhuman: must build");
|
||||
assert!(!model.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_with_openai_primary() {
|
||||
let config = config_with_providers(
|
||||
vec![oh_entry("p_oh"), openai_entry("p_oai", "gpt-4o")],
|
||||
Some("p_oai".to_string()),
|
||||
);
|
||||
let (_, model) = create_chat_provider_from_string("reasoning", "cloud", &config)
|
||||
.expect("cloud→openai primary must build");
|
||||
assert_eq!(model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_prefix() {
|
||||
let config = config_with_providers(vec![openai_entry("p_oai", "gpt-4o")], None);
|
||||
fn openai_slug_model() {
|
||||
let config = config_with_providers(vec![openai_entry("p_oai", "openai")]);
|
||||
let (_, model) = create_chat_provider_from_string("agentic", "openai:gpt-4o-mini", &config)
|
||||
.expect("openai:<model> must build");
|
||||
assert_eq!(model, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_prefix() {
|
||||
let config =
|
||||
config_with_providers(vec![anthropic_entry("p_ant", "claude-sonnet-4-6")], None);
|
||||
fn anthropic_slug_model() {
|
||||
let config = config_with_providers(vec![anthropic_entry("p_ant", "anthropic")]);
|
||||
let (_, model) =
|
||||
create_chat_provider_from_string("coding", "anthropic:claude-sonnet-4-6", &config)
|
||||
.expect("anthropic:<model> must build");
|
||||
@@ -534,13 +452,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openrouter_prefix() {
|
||||
fn openrouter_slug_model() {
|
||||
let mut config = Config::default();
|
||||
config.cloud_providers.push(CloudProviderCreds {
|
||||
id: "p_or".to_string(),
|
||||
r#type: CloudProviderType::Openrouter,
|
||||
slug: "openrouter".to_string(),
|
||||
label: "OpenRouter".to_string(),
|
||||
endpoint: "https://openrouter.ai/api/v1".to_string(),
|
||||
default_model: "openai/gpt-4o".to_string(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
default_model: Some("openai/gpt-4o".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let (_, model) = create_chat_provider_from_string(
|
||||
"agentic",
|
||||
@@ -563,7 +484,7 @@ mod tests {
|
||||
// ── Workload routing ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn all_workloads_default_to_cloud() {
|
||||
fn all_workloads_default_to_openhuman() {
|
||||
let config = Config::default();
|
||||
for role in &[
|
||||
"reasoning",
|
||||
@@ -577,8 +498,8 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(
|
||||
provider_for_role(role, &config),
|
||||
"cloud",
|
||||
"role={role} must default to cloud"
|
||||
"openhuman",
|
||||
"role={role} must default to openhuman"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -591,14 +512,13 @@ mod tests {
|
||||
provider_for_role("heartbeat", &config),
|
||||
"ollama:llama3.2:3b"
|
||||
);
|
||||
assert_eq!(provider_for_role("reasoning", &config), "cloud");
|
||||
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_chat_provider_uses_role() {
|
||||
let mut config = Config::default();
|
||||
config.cloud_providers.push(openai_entry("p_oai", "gpt-4o"));
|
||||
config.primary_cloud = Some("p_oai".to_string());
|
||||
config.cloud_providers.push(openai_entry("p_oai", "openai"));
|
||||
config.reasoning_provider = Some("openai:gpt-4o-mini".to_string());
|
||||
let (_, model) =
|
||||
create_chat_provider("reasoning", &config).expect("create_chat_provider must succeed");
|
||||
@@ -608,14 +528,24 @@ mod tests {
|
||||
// ── Error cases ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unknown_provider_string_rejected() {
|
||||
// `Result<(Box<dyn Provider>, String), _>` can't use `.expect_err`
|
||||
// because `dyn Provider` doesn't implement `Debug` — drop the
|
||||
// Ok via `.err()` and pattern on the Option instead.
|
||||
fn unknown_slug_rejected() {
|
||||
let config = Config::default();
|
||||
let err = create_chat_provider_from_string("reasoning", "groq:llama3", &config)
|
||||
.err()
|
||||
.expect("unknown provider string must fail");
|
||||
.expect("unknown slug must fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("no cloud provider configured for slug"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_string_without_colon_rejected() {
|
||||
let config = Config::default();
|
||||
let err = create_chat_provider_from_string("reasoning", "openai", &config)
|
||||
.err()
|
||||
.expect("bare string must fail");
|
||||
assert!(
|
||||
err.to_string().contains("unrecognised provider string"),
|
||||
"{err}"
|
||||
@@ -632,23 +562,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_creds_for_openai_gives_clear_error() {
|
||||
fn missing_slug_for_openai_gives_clear_error() {
|
||||
// No openai entry in cloud_providers.
|
||||
let config = Config::default();
|
||||
let err = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
|
||||
.err()
|
||||
.expect("missing creds must fail");
|
||||
.expect("missing slug must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("no cloud provider configured for type 'openai'"),
|
||||
msg.contains("no cloud provider configured for slug 'openai'"),
|
||||
"{msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_cloud_defaults_to_openhuman_when_none() {
|
||||
// primary_cloud=None → factory must still succeed by falling back
|
||||
// to either an openhuman entry or the openhuman backend.
|
||||
fn primary_cloud_defaults_to_openhuman_when_no_providers() {
|
||||
let config = Config::default();
|
||||
assert!(create_chat_provider("reasoning", &config).is_ok());
|
||||
}
|
||||
@@ -657,10 +585,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn summarization_aliases_memory_provider() {
|
||||
// The summarizer sub-agent declares `[model] hint = "summarization"`
|
||||
// but there's no `summarization_provider` config field — the workload
|
||||
// is a synonym for `memory` since both are "condense input" tasks.
|
||||
// `provider_for_role("summarization", ...)` must read `memory_provider`.
|
||||
let mut config = Config::default();
|
||||
config.memory_provider = Some("ollama:llama3.1:8b".to_string());
|
||||
assert_eq!(provider_for_role("memory", &config), "ollama:llama3.1:8b");
|
||||
@@ -672,45 +596,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarization_defaults_to_cloud_like_memory() {
|
||||
// No memory_provider → both `memory` and `summarization` fall through
|
||||
// to "cloud", consistent with every other workload.
|
||||
fn summarization_defaults_to_openhuman_like_memory() {
|
||||
let config = Config::default();
|
||||
assert_eq!(provider_for_role("memory", &config), "cloud");
|
||||
assert_eq!(provider_for_role("summarization", &config), "cloud");
|
||||
assert_eq!(provider_for_role("memory", &config), "openhuman");
|
||||
assert_eq!(provider_for_role("summarization", &config), "openhuman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_workload_falls_back_to_cloud() {
|
||||
// The wildcard arm in provider_for_role's match must keep
|
||||
// unrecognised workloads on the primary so a typo in an agent
|
||||
// TOML doesn't surface as a NoneProvider crash.
|
||||
fn unknown_workload_falls_back_to_openhuman() {
|
||||
let config = Config::default();
|
||||
assert_eq!(provider_for_role("nope-not-a-workload", &config), "cloud");
|
||||
assert_eq!(provider_for_role("", &config), "cloud");
|
||||
assert_eq!(
|
||||
provider_for_role("nope-not-a-workload", &config),
|
||||
"openhuman"
|
||||
);
|
||||
assert_eq!(provider_for_role("", &config), "openhuman");
|
||||
}
|
||||
|
||||
// ── OpenHuman backend state_dir wiring ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn openhuman_backend_uses_config_path_parent_as_state_dir() {
|
||||
// Regression test for #1710: when the user's workspace lives outside
|
||||
// ~/.openhuman (e.g. OPENHUMAN_WORKSPACE override, or a test
|
||||
// worktree), the factory must propagate config.config_path.parent()
|
||||
// into ProviderRuntimeOptions.openhuman_dir so the backend's
|
||||
// AuthService reads `auth-profiles.json` from the same workspace
|
||||
// login wrote to. Without this, login succeeds but every chat
|
||||
// call bails with "No backend session".
|
||||
let mut config = Config::default();
|
||||
config.config_path = std::path::PathBuf::from("/tmp/oh-test-workspace/config.toml");
|
||||
// Build via the chat-factory entrypoint — make_openhuman_backend
|
||||
// is private and called transitively when there are no
|
||||
// cloud_providers entries.
|
||||
let (_provider, model) = create_chat_provider("reasoning", &config)
|
||||
.expect("openhuman backend must build with no cloud_providers");
|
||||
// Sanity: the model resolution path also goes through the
|
||||
// backend ctor, so a successful build implies state_dir was
|
||||
// wired without panic.
|
||||
assert!(!model.is_empty(), "model must be set");
|
||||
assert!(!model.is_empty(), "model must be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod openhuman_backend;
|
||||
pub mod ops;
|
||||
pub mod reliable;
|
||||
pub mod router;
|
||||
pub mod schemas;
|
||||
pub mod thread_context;
|
||||
pub mod traits;
|
||||
|
||||
@@ -17,3 +18,7 @@ pub use traits::{
|
||||
pub use billing_error::is_budget_exhausted_message;
|
||||
pub use factory::{create_chat_provider, provider_for_role};
|
||||
pub use ops::*;
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_providers_controller_schemas,
|
||||
all_registered_controllers as all_providers_registered_controllers,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! RPC controller schemas for the providers domain.
|
||||
//!
|
||||
//! Exposes `openhuman.providers_list_models` — fetches the `/models` endpoint
|
||||
//! of a configured cloud provider and returns the list.
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::rpc::RpcOutcome;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn to_json<T: Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
fn deserialize_params<T: for<'de> Deserialize<'de>>(
|
||||
params: Map<String, Value>,
|
||||
) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── Schema catalog ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![list_models_schema()]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![RegisteredController {
|
||||
schema: list_models_schema(),
|
||||
handler: handle_list_models,
|
||||
}]
|
||||
}
|
||||
|
||||
fn list_models_schema() -> ControllerSchema {
|
||||
ControllerSchema {
|
||||
namespace: "providers",
|
||||
function: "list_models",
|
||||
description: "Fetch the available model list from a configured cloud provider's /models API.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "provider_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Opaque id of the cloud_providers entry to query.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "models",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of { id, owned_by?, context_window? } model descriptors returned by the provider.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListModelsRequest {
|
||||
provider_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelInfo {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
owned_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context_window: Option<u64>,
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list_models(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let req: ListModelsRequest = deserialize_params(params)?;
|
||||
let provider_id = req.provider_id.trim().to_string();
|
||||
|
||||
if provider_id.is_empty() {
|
||||
return Err("provider_id must not be empty".to_string());
|
||||
}
|
||||
|
||||
log::debug!("[providers][list_models] provider_id={}", provider_id);
|
||||
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let entry = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == provider_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("no cloud provider with id '{}' found", provider_id))?;
|
||||
|
||||
// Build the /models URL from the provider's endpoint.
|
||||
let base = entry.endpoint.trim_end_matches('/');
|
||||
let models_url = format!("{}/models", base);
|
||||
|
||||
log::debug!(
|
||||
"[providers][list_models] fetching url={} slug={}",
|
||||
models_url,
|
||||
entry.slug
|
||||
);
|
||||
|
||||
// Fetch the API key for this provider.
|
||||
let api_key =
|
||||
crate::openhuman::providers::factory::lookup_key_for_slug(&entry.slug, &config)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build the HTTP client (reuse the runtime proxy config). Explicit
|
||||
// timeouts mirror the other external integrations (composio,
|
||||
// multimodal) so a slow/unresponsive provider can't hang the panel.
|
||||
let client = crate::openhuman::config::build_runtime_proxy_client_with_timeouts(
|
||||
"providers.list_models",
|
||||
30,
|
||||
10,
|
||||
);
|
||||
|
||||
let mut request = client.get(&models_url);
|
||||
|
||||
// Attach auth header per auth_style.
|
||||
use crate::openhuman::config::schema::cloud_providers::AuthStyle;
|
||||
request = match entry.auth_style {
|
||||
AuthStyle::Bearer => {
|
||||
if !api_key.is_empty() {
|
||||
request.header("Authorization", format!("Bearer {}", api_key))
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
AuthStyle::Anthropic => {
|
||||
let mut r = request.header("anthropic-version", "2023-06-01");
|
||||
if !api_key.is_empty() {
|
||||
r = r.header("x-api-key", &api_key);
|
||||
}
|
||||
r
|
||||
}
|
||||
AuthStyle::OpenhumanJwt | AuthStyle::None => request,
|
||||
};
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("[providers][list_models] HTTP request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let truncated = crate::openhuman::util::truncate_with_ellipsis(&body, 300);
|
||||
return Err(format!(
|
||||
"provider returned {}: {}",
|
||||
status.as_u16(),
|
||||
truncated
|
||||
));
|
||||
}
|
||||
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("[providers][list_models] failed to parse JSON: {}", e))?;
|
||||
|
||||
// Parse OpenAI-compatible `{ data: [{ id, owned_by? }] }` or
|
||||
// Anthropic `{ data: [{ id, display_name }] }`.
|
||||
let data = body
|
||||
.get("data")
|
||||
.and_then(|d| d.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let models: Vec<ModelInfo> = data
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let id = item.get("id")?.as_str()?.to_string();
|
||||
let owned_by = item
|
||||
.get("owned_by")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let context_window = item
|
||||
.get("context_length")
|
||||
.or_else(|| item.get("context_window"))
|
||||
.and_then(|v| v.as_u64());
|
||||
Some(ModelInfo {
|
||||
id,
|
||||
owned_by,
|
||||
context_window,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
log::info!(
|
||||
"[providers][list_models] slug={} fetched {} models",
|
||||
entry.slug,
|
||||
models.len()
|
||||
);
|
||||
|
||||
to_json(RpcOutcome::new(
|
||||
serde_json::json!({ "models": models }),
|
||||
vec![format!("fetched {} models", models.len())],
|
||||
))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user