mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Omlx local provider (#3862)
Co-authored-by: OpenHuman <package@openhuman.org> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
OpenHuman
Steven Enamakel
parent
f6f65df71f
commit
6762efd7f3
@@ -138,6 +138,7 @@ const BUILTIN_RESERVED_SLUGS = [
|
||||
'custom',
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'omlx',
|
||||
// Claude Code is a CLI-backed peer provider surfaced via a dedicated
|
||||
// connect button (not a chip), so reserve its slug so it never renders in
|
||||
// the generic custom-provider chip list.
|
||||
@@ -315,6 +316,7 @@ function slugifyCustomProviderName(name: string): string {
|
||||
function authStyleForSlug(slug: string): AuthStyle {
|
||||
if (slug === 'openhuman') return 'openhuman_jwt';
|
||||
if (slug === 'lmstudio' || slug === 'ollama') return 'none';
|
||||
if (slug === 'omlx') return 'bearer';
|
||||
// Claude Code authenticates via the local CLI, never an HTTP key.
|
||||
if (slug === 'claude-code') return 'none';
|
||||
return authStyleForBuiltinCloudProvider(slug) ?? 'bearer';
|
||||
@@ -564,15 +566,20 @@ function useInstalledModels(snapshot: LocalProviderSnapshot | null): OllamaModel
|
||||
|
||||
// Local-runtime chip slugs (Ollama / LM Studio) that aren't actual slugs in
|
||||
// the cloud_providers list but need the same chip affordance.
|
||||
type LocalChipSlug = 'lmstudio' | 'ollama';
|
||||
type LocalChipSlug = 'lmstudio' | 'ollama' | 'omlx';
|
||||
|
||||
// Tints per local-runtime chip slug.
|
||||
const LOCAL_CHIP_TONE: Record<LocalChipSlug, string> = {
|
||||
lmstudio: 'bg-cyan-50 dark:bg-cyan-500/10 ring-cyan-200 text-cyan-900 dark:text-cyan-100',
|
||||
ollama: 'bg-violet-50 dark:bg-violet-500/10 ring-violet-200 text-violet-900 dark:text-violet-100',
|
||||
omlx: 'bg-amber-50 dark:bg-amber-500/10 ring-amber-200 text-amber-900 dark:text-amber-100',
|
||||
};
|
||||
|
||||
const LOCAL_CHIP_LABEL: Record<LocalChipSlug, string> = { lmstudio: 'LM Studio', ollama: 'Ollama' };
|
||||
const LOCAL_CHIP_LABEL: Record<LocalChipSlug, string> = {
|
||||
lmstudio: 'LM Studio',
|
||||
ollama: 'Ollama',
|
||||
omlx: 'OMLX',
|
||||
};
|
||||
|
||||
function providerToggleAriaLabel(
|
||||
t: (key: string, fallback?: string) => string,
|
||||
@@ -645,7 +652,9 @@ const ProviderKeyDialog = ({
|
||||
slug,
|
||||
label,
|
||||
isLocalRuntime,
|
||||
endpointKeyMode = false,
|
||||
initialValue,
|
||||
initialKeyValue,
|
||||
oauthAction,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
@@ -654,18 +663,30 @@ const ProviderKeyDialog = ({
|
||||
label: string;
|
||||
/** When true, render an "Endpoint URL" field instead of API key. */
|
||||
isLocalRuntime: boolean;
|
||||
/**
|
||||
* When true (OMLX), render BOTH an "Endpoint URL" field AND an "API key"
|
||||
* field. `onSubmit` then receives the API key as `value` and the endpoint
|
||||
* via the `endpoint` argument.
|
||||
*/
|
||||
endpointKeyMode?: boolean;
|
||||
/** Pre-populate the field when editing an existing provider's endpoint. */
|
||||
initialValue?: string;
|
||||
/** Pre-populate the API key field in `endpointKeyMode`. */
|
||||
initialKeyValue?: string;
|
||||
oauthAction?: { label: string; description?: string; onClick: () => Promise<void> | void } | null;
|
||||
onCancel: () => void;
|
||||
/** Returns the entered value. For local runtimes this is the endpoint URL;
|
||||
* for cloud providers it's the API key. */
|
||||
onSubmit: (value: string) => Promise<void> | void;
|
||||
/** Returns the entered value(s). For plain local runtimes this is the
|
||||
* endpoint URL; for cloud providers it's the API key. In `endpointKeyMode`
|
||||
* the API key is `value` and the endpoint URL is `endpoint`. */
|
||||
onSubmit: (value: string, endpoint?: string) => Promise<void> | void;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
// In `endpointKeyMode`, `value` holds the endpoint URL and `keyValue` holds
|
||||
// the API key. Otherwise `value` is either the endpoint (local) or key (cloud).
|
||||
const [value, setValue] = useState<string>(
|
||||
initialValue ?? (isLocalRuntime ? defaultEndpointFor(slug) : '')
|
||||
);
|
||||
const [keyValue, setKeyValue] = useState<string>(initialKeyValue ?? '');
|
||||
const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const busy = phase !== 'idle';
|
||||
@@ -673,6 +694,7 @@ const ProviderKeyDialog = ({
|
||||
const placeholder = isLocalRuntime
|
||||
? defaultEndpointFor(slug) || t('settings.ai.defaultLocalEndpoint')
|
||||
: (builtinCloudProvider(slug)?.keyPlaceholder ?? 'your-api-key');
|
||||
const keyPlaceholder = builtinCloudProvider(slug)?.keyPlaceholder ?? 'your-api-key';
|
||||
|
||||
const fieldLabel = isLocalRuntime
|
||||
? t('settings.ai.endpointUrlLabel')
|
||||
@@ -684,6 +706,7 @@ const ProviderKeyDialog = ({
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = value.trim();
|
||||
const trimmedKey = keyValue.trim();
|
||||
if (!trimmed) {
|
||||
setError(
|
||||
isLocalRuntime ? t('settings.ai.endpointUrlRequired') : t('settings.ai.apiKeyRequired')
|
||||
@@ -694,6 +717,10 @@ const ProviderKeyDialog = ({
|
||||
setError(t('settings.ai.endpointProtocolRequired'));
|
||||
return;
|
||||
}
|
||||
if (endpointKeyMode && !trimmedKey) {
|
||||
setError(t('settings.ai.apiKeyRequired'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// A provider credential is being saved. This adds/updates a `cloudProviders`
|
||||
@@ -702,12 +729,18 @@ const ProviderKeyDialog = ({
|
||||
console.debug('[ai-settings][routing] saving provider credential', {
|
||||
slug,
|
||||
local_runtime: isLocalRuntime,
|
||||
kind: isLocalRuntime ? 'endpoint' : 'apiKey',
|
||||
kind: endpointKeyMode ? 'endpointKey' : isLocalRuntime ? 'endpoint' : 'apiKey',
|
||||
});
|
||||
|
||||
setPhase('saving');
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
// In endpointKeyMode the API key is the primary value, endpoint is the
|
||||
// second arg; otherwise the single field is the primary value.
|
||||
if (endpointKeyMode) {
|
||||
await onSubmit(trimmedKey, trimmed);
|
||||
} else {
|
||||
await onSubmit(trimmed);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[ai-settings] provider setup failed', {
|
||||
@@ -793,6 +826,36 @@ const ProviderKeyDialog = ({
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
{/* OMLX (endpointKeyMode): render the API key field in addition to
|
||||
the endpoint field above — the runtime is OpenAI-compatible but
|
||||
gated behind a Bearer key. */}
|
||||
{endpointKeyMode ? (
|
||||
<>
|
||||
<label
|
||||
htmlFor="provider-key-input-key"
|
||||
className="mt-3 text-xs font-medium text-neutral-700 dark:text-neutral-200">
|
||||
{t('settings.ai.apiKeyFieldLabel')}
|
||||
</label>
|
||||
<SettingsTextField
|
||||
id="provider-key-input-key"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={busy}
|
||||
onChange={e => {
|
||||
setKeyValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <ProviderSetupErrorNotice error={error} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -2855,14 +2918,30 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
slug,
|
||||
localLabel = null,
|
||||
value,
|
||||
endpoint: endpointOverride,
|
||||
credentialMode,
|
||||
}: {
|
||||
slug: string;
|
||||
localLabel?: string | null;
|
||||
value: string;
|
||||
credentialMode: 'api_key' | 'oauth' | 'codex_oauth' | 'endpoint' | 'cli_login';
|
||||
/**
|
||||
* For `endpoint_key` runtimes (OMLX): the endpoint URL. `value` carries
|
||||
* the API key in that mode, so the endpoint comes in separately. Ignored
|
||||
* for `endpoint` mode, where `value` IS the endpoint.
|
||||
*/
|
||||
endpoint?: string | null;
|
||||
credentialMode:
|
||||
| 'api_key'
|
||||
| 'oauth'
|
||||
| 'codex_oauth'
|
||||
| 'endpoint'
|
||||
| 'endpoint_key'
|
||||
| 'cli_login';
|
||||
}) => {
|
||||
const isLocalRuntime = credentialMode === 'endpoint';
|
||||
const isLocalRuntime = credentialMode === 'endpoint' || credentialMode === 'endpoint_key';
|
||||
// `endpoint_key` (OMLX) carries the API key in `value` and the endpoint
|
||||
// separately; `endpoint` mode carries the endpoint URL in `value`.
|
||||
const isEndpointKey = credentialMode === 'endpoint_key';
|
||||
const isCodexOAuth = credentialMode === 'codex_oauth';
|
||||
// CLI-backed login (Claude Code): no API key is written and no HTTP
|
||||
// /models probe is made — auth + execution both go through the local
|
||||
@@ -2872,9 +2951,13 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
|
||||
try {
|
||||
const trimmed = value.trim();
|
||||
// For `endpoint_key` (OMLX), the endpoint URL arrives via `endpointOverride`
|
||||
// (the dialog's endpoint field) and `trimmed` is the API key. For plain
|
||||
// `endpoint` runtimes, `trimmed` itself is the endpoint URL.
|
||||
const rawEndpoint = isEndpointKey ? (endpointOverride ?? '').trim() : trimmed;
|
||||
const endpoint = isLocalRuntime
|
||||
? (() => {
|
||||
const url = new URL(trimmed);
|
||||
const url = new URL(rawEndpoint);
|
||||
if (!/^https?:$/.test(url.protocol)) {
|
||||
throw new Error('Endpoint must start with http:// or https://');
|
||||
}
|
||||
@@ -2921,6 +3004,17 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
} else if (isLocalRuntime && slug === 'omlx') {
|
||||
// OMLX: OpenAI-compatible local runtime that also requires a Bearer
|
||||
// key. Persist both the endpoint and the key into local_ai (the Rust
|
||||
// factory's omlx branch reads `local_ai.api_key` as the Bearer token).
|
||||
await openhumanUpdateLocalAiSettings({
|
||||
base_url: endpoint,
|
||||
api_key: trimmed,
|
||||
provider: 'omlx',
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (slug !== 'openhuman') {
|
||||
@@ -3133,7 +3227,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
|
||||
{/* LM Studio + Ollama — local runtimes stored with a slug of
|
||||
"lmstudio" / "ollama" so they're distinct from generic custom. */}
|
||||
{(['lmstudio', 'ollama'] as const).map(localKind => {
|
||||
{(['lmstudio', 'ollama', 'omlx'] as const).map(localKind => {
|
||||
const label = LOCAL_CHIP_LABEL[localKind];
|
||||
const tone = LOCAL_CHIP_TONE[localKind];
|
||||
const existing = draft.cloudProviders.find(cp => cp.slug === localKind);
|
||||
@@ -3567,9 +3661,13 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
slug={keyDialogFor}
|
||||
label={pendingLocalLabel ?? BUILTIN_PROVIDER_META[keyDialogFor]?.label ?? keyDialogFor}
|
||||
isLocalRuntime={Boolean(pendingLocalLabel)}
|
||||
// OMLX is the only endpoint+key local runtime: render both an endpoint
|
||||
// field (prefilled with the localhost default) and an API key field.
|
||||
endpointKeyMode={keyDialogFor === 'omlx'}
|
||||
initialValue={
|
||||
pendingLocalLabel
|
||||
? (draft.cloudProviders.find(cp => cp.slug === keyDialogFor)?.endpoint ?? undefined)
|
||||
? (draft.cloudProviders.find(cp => cp.slug === keyDialogFor)?.endpoint ??
|
||||
(keyDialogFor === 'omlx' ? defaultEndpointFor('omlx') : undefined))
|
||||
: undefined
|
||||
}
|
||||
oauthAction={
|
||||
@@ -3601,12 +3699,20 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
setKeyDialogFor(null);
|
||||
setPendingLocalLabel(null);
|
||||
}}
|
||||
onSubmit={async value =>
|
||||
onSubmit={async (value, endpoint) =>
|
||||
await connectProvider({
|
||||
slug: keyDialogFor,
|
||||
localLabel: pendingLocalLabel,
|
||||
// In endpoint_key (OMLX) mode the dialog hands back the API key as
|
||||
// `value` and the endpoint URL as `endpoint`.
|
||||
value,
|
||||
credentialMode: pendingLocalLabel ? 'endpoint' : 'api_key',
|
||||
endpoint,
|
||||
credentialMode:
|
||||
keyDialogFor === 'omlx'
|
||||
? 'endpoint_key'
|
||||
: pendingLocalLabel
|
||||
? 'endpoint'
|
||||
: 'api_key',
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -3801,6 +3907,8 @@ function defaultEndpointFor(slug: string): string {
|
||||
return 'http://localhost:11434/v1';
|
||||
case 'lmstudio':
|
||||
return 'http://localhost:1234/v1';
|
||||
case 'omlx':
|
||||
return 'http://localhost:8000/v1';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listConnections as listComposioConnections } from '../../../../lib/composio/composioApi';
|
||||
import {
|
||||
clearCloudProviderKey,
|
||||
completeOpenAiCodexOAuth,
|
||||
importOpenAiCodexCliAuth,
|
||||
listProviderModels,
|
||||
loadAISettings,
|
||||
loadLocalProviderSnapshot,
|
||||
setCloudProviderKey,
|
||||
startOpenAiCodexOAuth,
|
||||
testProviderModel,
|
||||
} from '../../../../services/api/aiSettingsApi';
|
||||
import { creditsApi } from '../../../../services/api/creditsApi';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import { connectOpenRouterViaOAuth } from '../../../../utils/openrouterOAuth';
|
||||
import { openUrl } from '../../../../utils/openUrl';
|
||||
// Lazy import so the typed mock is available to individual tests.
|
||||
import { openhumanUpdateLocalAiSettings as openhumanUpdateLocalAiSettingsMock } from '../../../../utils/tauriCommands/config';
|
||||
import {
|
||||
openhumanHeartbeatSettingsGet,
|
||||
openhumanHeartbeatSettingsSet,
|
||||
openhumanHeartbeatTickNow,
|
||||
} from '../../../../utils/tauriCommands/heartbeat';
|
||||
import AIPanel from '../AIPanel';
|
||||
|
||||
vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
ALL_WORKLOADS: [
|
||||
'chat',
|
||||
'reasoning',
|
||||
'agentic',
|
||||
'coding',
|
||||
'memory',
|
||||
'embeddings',
|
||||
'heartbeat',
|
||||
'learning',
|
||||
'subconscious',
|
||||
],
|
||||
loadAISettings: vi.fn(),
|
||||
saveAISettings: vi.fn(),
|
||||
loadLocalProviderSnapshot: vi.fn(),
|
||||
testProviderModel: vi.fn(),
|
||||
modelRegistryVision: vi.fn(() => false),
|
||||
upsertModelRegistryVision: vi.fn((registry: unknown[]) => registry),
|
||||
setCloudProviderKey: vi.fn().mockResolvedValue(undefined),
|
||||
clearCloudProviderKey: vi.fn().mockResolvedValue(undefined),
|
||||
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() },
|
||||
flushCloudProviders: vi.fn().mockResolvedValue(undefined),
|
||||
importOpenAiCodexCliAuth: vi.fn().mockResolvedValue(undefined),
|
||||
listProviderModels: vi.fn().mockResolvedValue([]),
|
||||
OPENAI_CODEX_OAUTH_MISSING_AUTH_URL: 'OPENAI_CODEX_OAUTH_MISSING_AUTH_URL',
|
||||
OPENAI_CODEX_OAUTH_MISSING_CALLBACK_URL: 'OPENAI_CODEX_OAUTH_MISSING_CALLBACK_URL',
|
||||
startOpenAiCodexOAuth: vi.fn(),
|
||||
completeOpenAiCodexOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/tauriCommands/heartbeat', () => ({
|
||||
openhumanHeartbeatSettingsGet: vi.fn(),
|
||||
openhumanHeartbeatSettingsSet: vi.fn(),
|
||||
openhumanHeartbeatTickNow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../services/api/creditsApi', () => ({
|
||||
creditsApi: { getTeamUsage: vi.fn(), getTransactions: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../../lib/composio/composioApi', () => ({ listConnections: vi.fn() }));
|
||||
|
||||
// OMLX persists `local_ai.{base_url,api_key,provider}` via this command. Mock
|
||||
// it so the test can assert the call shape without crossing into Tauri IPC.
|
||||
vi.mock('../../../../utils/tauriCommands/config', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../utils/tauriCommands/config')>(
|
||||
'../../../../utils/tauriCommands/config'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
openhumanUpdateLocalAiSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ result: { config: {}, workspace_dir: '', config_path: '' }, logs: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../utils/openrouterOAuth', () => ({ connectOpenRouterViaOAuth: vi.fn() }));
|
||||
vi.mock('../../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
const baseSettings = {
|
||||
cloudProviders: [],
|
||||
routing: {
|
||||
chat: { kind: 'openhuman' as const },
|
||||
reasoning: { kind: 'openhuman' as const },
|
||||
agentic: { kind: 'openhuman' as const },
|
||||
coding: { kind: 'openhuman' as const },
|
||||
vision: { 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 },
|
||||
},
|
||||
modelRegistry: [],
|
||||
};
|
||||
|
||||
const baseLocalSnapshot = { status: null, diagnostics: null, presets: null, installedModels: [] };
|
||||
|
||||
const baseHeartbeatSettings = {
|
||||
enabled: true,
|
||||
interval_minutes: 15,
|
||||
inference_enabled: true,
|
||||
notify_meetings: true,
|
||||
notify_reminders: true,
|
||||
notify_relevant_events: false,
|
||||
external_delivery_enabled: false,
|
||||
triggers_enabled: false,
|
||||
max_promotions_per_hour: 30,
|
||||
meeting_lookahead_minutes: 60,
|
||||
max_calendar_connections_per_tick: 2,
|
||||
reminder_lookahead_minutes: 30,
|
||||
subconscious_mode: 'off' as 'off' | 'simple' | 'aggressive',
|
||||
};
|
||||
|
||||
const baseUsage = {
|
||||
remainingUsd: 1.5,
|
||||
cycleBudgetUsd: 10,
|
||||
cycleSpentUsd: 8.5,
|
||||
cycleStartDate: '2026-05-14T00:00:00.000Z',
|
||||
cycleEndsAt: '2026-05-21T00:00:00.000Z',
|
||||
plan: {
|
||||
plan: 'BASIC',
|
||||
name: 'Basic',
|
||||
marginPercent: 25,
|
||||
payAsYouGoMarginPercent: 50,
|
||||
discountVsPayAsYouGoPercent: 50,
|
||||
},
|
||||
insights: {
|
||||
period: { startDate: '2026-05-14T00:00:00.000Z', endDate: '2026-05-21T00:00:00.000Z' },
|
||||
totals: {
|
||||
inferenceUsd: 6,
|
||||
integrationsUsd: 2.5,
|
||||
totalUsd: 8.5,
|
||||
inferenceCalls: 120,
|
||||
integrationCalls: 6,
|
||||
},
|
||||
dailySeries: [],
|
||||
topModels: [],
|
||||
topIntegrations: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('AIPanel OMLX connect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(loadLocalProviderSnapshot).mockResolvedValue(baseLocalSnapshot);
|
||||
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
|
||||
vi.mocked(clearCloudProviderKey).mockResolvedValue(undefined);
|
||||
vi.mocked(importOpenAiCodexCliAuth).mockResolvedValue(undefined);
|
||||
vi.mocked(testProviderModel).mockResolvedValue({ reply: 'Hello from the selected model.' });
|
||||
vi.mocked(listProviderModels).mockResolvedValue([]);
|
||||
vi.mocked(startOpenAiCodexOAuth).mockResolvedValue({
|
||||
authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test',
|
||||
});
|
||||
vi.mocked(completeOpenAiCodexOAuth).mockResolvedValue(undefined);
|
||||
vi.mocked(openUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-oauth');
|
||||
vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({
|
||||
result: { settings: baseHeartbeatSettings },
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(openhumanHeartbeatSettingsSet).mockResolvedValue({
|
||||
result: { settings: baseHeartbeatSettings },
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(openhumanHeartbeatTickNow).mockResolvedValue({
|
||||
result: {
|
||||
summary: {
|
||||
source_events: 3,
|
||||
deliveries_attempted: 2,
|
||||
deliveries_sent: 1,
|
||||
deliveries_skipped_dedup: 1,
|
||||
},
|
||||
},
|
||||
logs: [],
|
||||
});
|
||||
vi.mocked(creditsApi.getTeamUsage).mockResolvedValue(baseUsage);
|
||||
vi.mocked(creditsApi.getTransactions).mockResolvedValue({ transactions: [], total: 0 });
|
||||
vi.mocked(listComposioConnections).mockResolvedValue({ connections: [] });
|
||||
});
|
||||
|
||||
it('renders an OMLX local-runtime chip', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect OMLX/i })).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('toggling OMLX ON shows BOTH an endpoint field (localhost:8000) and an API key field', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect OMLX/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect OMLX/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect OMLX/i });
|
||||
const urlInput = within(dialog).getByLabelText(/Endpoint URL/i) as HTMLInputElement;
|
||||
expect(urlInput).toBeInTheDocument();
|
||||
expect(urlInput.value).toBe('http://localhost:8000/v1');
|
||||
expect(within(dialog).getByLabelText(/API key/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists provider=omlx with base_url + api_key on confirm', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect OMLX/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect OMLX/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect OMLX/i });
|
||||
fireEvent.change(within(dialog).getByLabelText(/Endpoint URL/i), {
|
||||
target: { value: 'http://localhost:8000/v1' },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText(/API key/i), {
|
||||
target: { value: 'sk-omlx-test' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(openhumanUpdateLocalAiSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'omlx',
|
||||
base_url: 'http://localhost:8000/v1',
|
||||
api_key: expect.any(String),
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
const [arg] = vi.mocked(openhumanUpdateLocalAiSettingsMock).mock.calls[0];
|
||||
expect(arg).toMatchObject({ api_key: 'sk-omlx-test' });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import type { CloudProvider, ProviderRef, RoutingMap } from './AIPanel';
|
||||
|
||||
const LOCAL_RUNTIME_SLUGS = ['ollama', 'lmstudio'] as const;
|
||||
const LOCAL_RUNTIME_SLUGS = ['ollama', 'lmstudio', 'omlx'] as const;
|
||||
|
||||
/**
|
||||
* Reset any workload routing ref pinned to a now-removed provider back to
|
||||
|
||||
@@ -168,6 +168,12 @@ export interface LocalAiSettingsUpdate {
|
||||
opt_in_confirmed?: boolean | null;
|
||||
provider?: string | null;
|
||||
base_url?: string | null;
|
||||
/**
|
||||
* Bearer credential for OpenAI-compatible local runtimes that require a key
|
||||
* (e.g. OMLX). Stored in `config.local_ai.api_key` and sent as a Bearer token
|
||||
* on inference. Keyless runtimes (Ollama / LM Studio) omit this.
|
||||
*/
|
||||
api_key?: string | null;
|
||||
model_id?: string | null;
|
||||
chat_model_id?: string | null;
|
||||
usage_embeddings?: boolean | null;
|
||||
|
||||
@@ -5,7 +5,7 @@ not catch.
|
||||
|
||||
## What runs
|
||||
|
||||
Workflow: [`.github/workflows/weekly-code-review.yml`](../.github/workflows/weekly-code-review.yml).
|
||||
Workflow: `.github/workflows/weekly-code-review.yml`.
|
||||
Script: [`scripts/weekly-code-review.sh`](../scripts/weekly-code-review.sh).
|
||||
|
||||
The aggregator currently collects:
|
||||
|
||||
@@ -83,6 +83,7 @@ pub struct LocalAiSettingsPatch {
|
||||
pub usage_heartbeat: Option<bool>,
|
||||
pub usage_learning_reflection: Option<bool>,
|
||||
pub usage_subconscious: Option<bool>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -372,9 +373,15 @@ pub async fn apply_local_ai_settings(
|
||||
config.local_ai.base_url = match base_url {
|
||||
None => None,
|
||||
Some(base_url) if base_url.trim().is_empty() => None,
|
||||
// OMLX is an OpenAI-v1 endpoint: the `/v1` suffix is significant, so it
|
||||
// must NOT go through `validate_ollama_url` (which strips the path).
|
||||
// `provider_from_config` maps omlx → Ollama, so guard on the slug here.
|
||||
Some(base_url)
|
||||
if crate::openhuman::inference::local::provider::provider_from_config(config)
|
||||
== crate::openhuman::inference::local::provider::LocalAiProvider::Ollama =>
|
||||
if crate::openhuman::inference::local::provider::normalize_provider(
|
||||
&config.local_ai.provider,
|
||||
) != "omlx"
|
||||
&& crate::openhuman::inference::local::provider::provider_from_config(config)
|
||||
== crate::openhuman::inference::local::provider::LocalAiProvider::Ollama =>
|
||||
{
|
||||
Some(crate::openhuman::inference::local::validate_ollama_url(
|
||||
&base_url,
|
||||
@@ -401,6 +408,22 @@ pub async fn apply_local_ai_settings(
|
||||
if let Some(v) = update.usage_subconscious {
|
||||
config.local_ai.usage.subconscious = v;
|
||||
}
|
||||
if let Some(api_key) = update.api_key {
|
||||
let trimmed = api_key.trim();
|
||||
config.local_ai.api_key = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
log::debug!(
|
||||
"[config][local_ai] api_key {}",
|
||||
if config.local_ai.api_key.is_some() {
|
||||
"set"
|
||||
} else {
|
||||
"cleared"
|
||||
}
|
||||
);
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
|
||||
@@ -938,6 +938,7 @@ async fn apply_local_ai_settings_updates_lm_studio_provider_fields() {
|
||||
usage_heartbeat: Some(true),
|
||||
usage_learning_reflection: Some(false),
|
||||
usage_subconscious: Some(true),
|
||||
api_key: None,
|
||||
};
|
||||
|
||||
let outcome = apply_local_ai_settings(&mut cfg, patch)
|
||||
@@ -1010,6 +1011,66 @@ async fn apply_local_ai_settings_normalizes_ollama_unspecified_host_and_allows_n
|
||||
assert!(cfg.local_ai.base_url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_ai_settings_persists_api_key() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
cfg.local_ai.api_key = None;
|
||||
|
||||
// Non-empty key is stored.
|
||||
let patch = LocalAiSettingsPatch {
|
||||
runtime_enabled: Some(true),
|
||||
opt_in_confirmed: Some(true),
|
||||
provider: Some("omlx".into()),
|
||||
base_url: Some(Some("http://localhost:8080/v1".into())),
|
||||
api_key: Some("sk-omlx-1".into()),
|
||||
..LocalAiSettingsPatch::default()
|
||||
};
|
||||
apply_local_ai_settings(&mut cfg, patch)
|
||||
.await
|
||||
.expect("apply omlx api key");
|
||||
assert_eq!(cfg.local_ai.api_key.as_deref(), Some("sk-omlx-1"));
|
||||
|
||||
// Whitespace-only key clears to None.
|
||||
let patch_clear = LocalAiSettingsPatch {
|
||||
api_key: Some(" ".into()),
|
||||
..LocalAiSettingsPatch::default()
|
||||
};
|
||||
apply_local_ai_settings(&mut cfg, patch_clear)
|
||||
.await
|
||||
.expect("clear api key");
|
||||
assert!(cfg.local_ai.api_key.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_ai_settings_omlx_keeps_provider_and_v1_suffix() {
|
||||
// Regression: omlx must NOT collapse to ollama (normalize_provider) and its
|
||||
// `/v1` suffix must survive (no validate_ollama_url path-strip).
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
|
||||
apply_local_ai_settings(
|
||||
&mut cfg,
|
||||
LocalAiSettingsPatch {
|
||||
runtime_enabled: Some(true),
|
||||
opt_in_confirmed: Some(true),
|
||||
provider: Some("omlx".into()),
|
||||
base_url: Some(Some("http://localhost:8000/v1".into())),
|
||||
api_key: Some("sk-omlx-1".into()),
|
||||
..LocalAiSettingsPatch::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("apply omlx");
|
||||
|
||||
assert_eq!(cfg.local_ai.provider, "omlx");
|
||||
assert_eq!(
|
||||
cfg.local_ai.base_url.as_deref(),
|
||||
Some("http://localhost:8000/v1")
|
||||
);
|
||||
assert_eq!(cfg.local_ai.api_key.as_deref(), Some("sk-omlx-1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_analytics_settings_updates_enabled() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -47,8 +47,8 @@ pub struct LocalAiConfig {
|
||||
/// serde and will be silently ignored on load (intentional forced reset).
|
||||
#[serde(default = "default_runtime_enabled")]
|
||||
pub runtime_enabled: bool,
|
||||
/// Local provider identifier. Supported values are `ollama` and
|
||||
/// `lm_studio`; unknown values normalize to `ollama` at runtime.
|
||||
/// Local provider identifier. Supported values are `ollama`, `lm_studio`,
|
||||
/// and `omlx`; unknown values normalize to `ollama` at runtime.
|
||||
#[serde(default = "default_provider")]
|
||||
pub provider: String,
|
||||
/// Optional provider base URL. For LM Studio this defaults to
|
||||
|
||||
@@ -522,6 +522,7 @@ fn handle_update_local_ai_settings(params: Map<String, Value>) -> ControllerFutu
|
||||
usage_heartbeat: update.usage_heartbeat,
|
||||
usage_learning_reflection: update.usage_learning_reflection,
|
||||
usage_subconscious: update.usage_subconscious,
|
||||
api_key: update.api_key,
|
||||
};
|
||||
to_json(config_rpc::load_and_apply_local_ai_settings(patch).await?)
|
||||
})
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) struct LocalAiSettingsUpdate {
|
||||
pub(super) usage_heartbeat: Option<bool>,
|
||||
pub(super) usage_learning_reflection: Option<bool>,
|
||||
pub(super) usage_subconscious: Option<bool>,
|
||||
pub(super) api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -274,12 +274,16 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
),
|
||||
optional_string(
|
||||
"provider",
|
||||
"Local provider identifier. Supported values: ollama, lm_studio.",
|
||||
"Local provider identifier. Supported values: ollama, lm_studio, omlx.",
|
||||
),
|
||||
optional_json(
|
||||
"base_url",
|
||||
"Provider base URL string, or null to clear. For LM Studio this defaults to http://localhost:1234/v1.",
|
||||
),
|
||||
optional_string(
|
||||
"api_key",
|
||||
"Bearer credential for keyed local runtimes such as OMLX. Pass an empty string to clear.",
|
||||
),
|
||||
optional_string("model_id", "Default local chat model identifier."),
|
||||
optional_string("chat_model_id", "Local chat model identifier."),
|
||||
optional_bool(
|
||||
|
||||
@@ -266,6 +266,20 @@ fn update_local_ai_settings_schema_allows_json_base_url() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_local_ai_settings_schema_accepts_api_key() {
|
||||
let schema = schemas("update_local_ai_settings");
|
||||
let field = schema
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|field| field.name == "api_key")
|
||||
.expect("api_key field must be declared so validate_params accepts it");
|
||||
match &field.ty {
|
||||
TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::String)),
|
||||
other => panic!("expected Option<String>, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_parses_workspace_onboarding_flag_params() {
|
||||
let out: WorkspaceOnboardingFlagParams = deserialize_params(Map::new()).unwrap();
|
||||
|
||||
@@ -20,6 +20,8 @@ pub enum LocalProviderKind {
|
||||
LmStudio,
|
||||
/// MLX-compatible local server (e.g. `mlx_lm.server`).
|
||||
Mlx,
|
||||
/// OMLX — OpenAI v1-compatible MLX server that requires an API key.
|
||||
Omlx,
|
||||
/// Generic local OpenAI-compatible endpoint (llama.cpp, vLLM, etc.).
|
||||
LocalOpenai,
|
||||
}
|
||||
@@ -30,6 +32,7 @@ impl LocalProviderKind {
|
||||
Self::Ollama => "ollama",
|
||||
Self::LmStudio => "lmstudio",
|
||||
Self::Mlx => "mlx",
|
||||
Self::Omlx => "omlx",
|
||||
Self::LocalOpenai => "local-openai",
|
||||
}
|
||||
}
|
||||
@@ -39,6 +42,7 @@ impl LocalProviderKind {
|
||||
Self::Ollama => "Ollama",
|
||||
Self::LmStudio => "LM Studio",
|
||||
Self::Mlx => "MLX",
|
||||
Self::Omlx => "OMLX",
|
||||
Self::LocalOpenai => "Local OpenAI",
|
||||
}
|
||||
}
|
||||
@@ -49,6 +53,7 @@ impl LocalProviderKind {
|
||||
"ollama" => Some(Self::Ollama),
|
||||
"lmstudio" | "lm-studio" | "lm_studio" => Some(Self::LmStudio),
|
||||
"mlx" | "mlx-server" | "mlx_lm" => Some(Self::Mlx),
|
||||
"omlx" | "omlx-server" => Some(Self::Omlx),
|
||||
"local-openai" | "local_openai" | "llamacpp" | "llama.cpp" | "vllm" => {
|
||||
Some(Self::LocalOpenai)
|
||||
}
|
||||
@@ -160,6 +165,23 @@ pub const MLX_PROFILE: LocalProviderProfile = LocalProviderProfile {
|
||||
base_url_env: "MLX_SERVER_URL",
|
||||
};
|
||||
|
||||
/// OMLX profile: OpenAI v1-compatible MLX server, default port 8000, key required.
|
||||
pub const OMLX_PROFILE: LocalProviderProfile = LocalProviderProfile {
|
||||
kind: LocalProviderKind::Omlx,
|
||||
tool_support: ToolSupport::PromptGuided,
|
||||
default_context_window: Some(4_096),
|
||||
supports_responses_api: false,
|
||||
supports_streaming: true,
|
||||
default_quirks: RequestQuirks {
|
||||
num_ctx: None,
|
||||
suppress_thinking: false,
|
||||
omit_temperature: false,
|
||||
merge_system_into_user: false,
|
||||
},
|
||||
default_base_url: "http://127.0.0.1:8000/v1",
|
||||
base_url_env: "OMLX_SERVER_URL",
|
||||
};
|
||||
|
||||
/// Generic local OpenAI-compatible server (llama.cpp, vLLM, etc.).
|
||||
pub const LOCAL_OPENAI_PROFILE: LocalProviderProfile = LocalProviderProfile {
|
||||
kind: LocalProviderKind::LocalOpenai,
|
||||
@@ -183,6 +205,7 @@ pub fn profile_for_kind(kind: LocalProviderKind) -> &'static LocalProviderProfil
|
||||
LocalProviderKind::Ollama => &OLLAMA_PROFILE,
|
||||
LocalProviderKind::LmStudio => &LM_STUDIO_PROFILE,
|
||||
LocalProviderKind::Mlx => &MLX_PROFILE,
|
||||
LocalProviderKind::Omlx => &OMLX_PROFILE,
|
||||
LocalProviderKind::LocalOpenai => &LOCAL_OPENAI_PROFILE,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +224,8 @@ pub fn kind_from_provider_string(provider: &str) -> Option<LocalProviderKind> {
|
||||
Some(LocalProviderKind::LmStudio)
|
||||
} else if p.starts_with("mlx:") {
|
||||
Some(LocalProviderKind::Mlx)
|
||||
} else if p.starts_with("omlx:") {
|
||||
Some(LocalProviderKind::Omlx)
|
||||
} else if p.starts_with("local-openai:") || p.starts_with("local_openai:") {
|
||||
Some(LocalProviderKind::LocalOpenai)
|
||||
} else {
|
||||
@@ -296,4 +321,27 @@ mod tests {
|
||||
let profile = profile_for_kind(LocalProviderKind::Ollama);
|
||||
assert_eq!(profile.tool_support, ToolSupport::PromptGuided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_kind_and_profile() {
|
||||
assert_eq!(
|
||||
LocalProviderKind::from_str_loose("omlx"),
|
||||
Some(LocalProviderKind::Omlx)
|
||||
);
|
||||
assert_eq!(
|
||||
LocalProviderKind::from_str_loose("omlx-server"),
|
||||
Some(LocalProviderKind::Omlx)
|
||||
);
|
||||
assert_eq!(LocalProviderKind::Omlx.as_str(), "omlx");
|
||||
assert_eq!(LocalProviderKind::Omlx.display_name(), "OMLX");
|
||||
assert_eq!(
|
||||
profile_for_kind(LocalProviderKind::Omlx).default_base_url,
|
||||
"http://127.0.0.1:8000/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
kind_from_provider_string("omlx:my-model"),
|
||||
Some(LocalProviderKind::Omlx)
|
||||
);
|
||||
assert!(is_local_provider_string("omlx:my-model"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ impl LocalAiProvider {
|
||||
pub(crate) fn normalize_provider(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"lmstudio" | "lm-studio" | "lm_studio" => LocalAiProvider::LmStudio.as_str().to_string(),
|
||||
// OMLX is a keyed OpenAI-v1 local runtime handled by the provider factory
|
||||
// (`omlx:<model>`), not by `LocalAiProvider`. Preserve the slug so the
|
||||
// saved config keeps `provider = "omlx"` instead of collapsing to ollama.
|
||||
"omlx" | "omlx-server" => {
|
||||
log::trace!(
|
||||
"[local-provider] normalized provider '{}' -> omlx (factory-resolved local runtime)",
|
||||
value.trim()
|
||||
);
|
||||
"omlx".to_string()
|
||||
}
|
||||
_ => LocalAiProvider::Ollama.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
@@ -54,4 +64,11 @@ mod tests {
|
||||
assert_eq!(normalize_provider(""), "ollama");
|
||||
assert_eq!(normalize_provider("unknown"), "ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_provider_keeps_omlx() {
|
||||
assert_eq!(normalize_provider("omlx"), "omlx");
|
||||
assert_eq!(normalize_provider("omlx-server"), "omlx");
|
||||
assert_eq!(normalize_provider("OMLX"), "omlx");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ pub const OLLAMA_PROVIDER_PREFIX: &str = "ollama:";
|
||||
pub const LM_STUDIO_PROVIDER_PREFIX: &str = "lmstudio:";
|
||||
/// Prefix for MLX-compatible local providers: `"mlx:<model>"`.
|
||||
pub const MLX_PROVIDER_PREFIX: &str = "mlx:";
|
||||
/// Prefix for OMLX local providers: `"omlx:<model>"`.
|
||||
pub const OMLX_PROVIDER_PREFIX: &str = "omlx:";
|
||||
/// Prefix for generic local OpenAI-compatible providers: `"local-openai:<model>"`.
|
||||
pub const LOCAL_OPENAI_PROVIDER_PREFIX: &str = "local-openai:";
|
||||
/// Prefix for the Claude Agent SDK subprocess provider: `"claude_agent_sdk:<model>"`.
|
||||
@@ -361,8 +363,8 @@ fn route_has_usable_credentials(resolved: &str, config: &Config) -> bool {
|
||||
}
|
||||
|
||||
/// Find the first BYOK cloud provider string configured across all workload
|
||||
/// routes, skipping local providers (ollama, lmstudio) and managed-backend
|
||||
/// sentinels ("openhuman", "cloud", empty).
|
||||
/// routes, skipping local providers and managed-backend sentinels
|
||||
/// ("openhuman", "cloud", empty).
|
||||
///
|
||||
/// Returns `None` when no BYOK cloud provider is configured, in which case
|
||||
/// the caller should fall through to `resolve_primary_cloud_provider_string`.
|
||||
@@ -386,6 +388,7 @@ pub(crate) fn resolve_byok_fallback_provider_string(config: &Config) -> Option<S
|
||||
if s.starts_with(OLLAMA_PROVIDER_PREFIX)
|
||||
|| s.starts_with(LM_STUDIO_PROVIDER_PREFIX)
|
||||
|| s.starts_with(MLX_PROVIDER_PREFIX)
|
||||
|| s.starts_with(OMLX_PROVIDER_PREFIX)
|
||||
|| s.starts_with(LOCAL_OPENAI_PROVIDER_PREFIX)
|
||||
{
|
||||
continue;
|
||||
@@ -633,6 +636,19 @@ pub fn create_chat_provider_from_string(
|
||||
return make_mlx_provider(&model, temperature_override, config);
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) = p.strip_prefix(OMLX_PROVIDER_PREFIX) {
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if model.is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] provider string '{}' for role '{}' has an empty model — \
|
||||
use 'omlx:<model-id>'",
|
||||
p,
|
||||
role
|
||||
);
|
||||
}
|
||||
return make_omlx_provider(&model, temperature_override, config);
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) = p.strip_prefix(LOCAL_OPENAI_PROVIDER_PREFIX) {
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if model.is_empty() {
|
||||
@@ -681,7 +697,7 @@ pub fn create_chat_provider_from_string(
|
||||
// than an opaque parse failure.
|
||||
anyhow::bail!(
|
||||
"[chat-factory] unrecognised provider string '{}' for role '{}'. \
|
||||
Valid forms: openhuman, ollama:<model>, lmstudio:<model>, mlx:<model>, \
|
||||
Valid forms: openhuman, ollama:<model>, lmstudio:<model>, mlx:<model>, omlx:<model>, \
|
||||
local-openai:<model>, claude_agent_sdk, claude_agent_sdk:<model>, <slug>:<model>. \
|
||||
Configured slugs: [{}]",
|
||||
p,
|
||||
@@ -753,6 +769,17 @@ pub(crate) fn create_local_chat_provider_from_string(
|
||||
return make_mlx_provider(&model, temperature_override, config);
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) = p.strip_prefix(OMLX_PROVIDER_PREFIX) {
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if model.is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] provider string '{}' has an empty model — use 'omlx:<model-id>'",
|
||||
p
|
||||
);
|
||||
}
|
||||
return make_omlx_provider(&model, temperature_override, config);
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) = p.strip_prefix(LOCAL_OPENAI_PROVIDER_PREFIX) {
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if model.is_empty() {
|
||||
@@ -766,7 +793,7 @@ pub(crate) fn create_local_chat_provider_from_string(
|
||||
|
||||
anyhow::bail!(
|
||||
"[chat-factory] '{}' is not a supported local provider string. Valid local forms: \
|
||||
ollama:<model>, lmstudio:<model>, mlx:<model>, local-openai:<model>",
|
||||
ollama:<model>, lmstudio:<model>, mlx:<model>, omlx:<model>, local-openai:<model>",
|
||||
p
|
||||
);
|
||||
}
|
||||
@@ -1236,6 +1263,59 @@ fn make_mlx_provider(
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
}
|
||||
|
||||
/// Build an OMLX local provider.
|
||||
///
|
||||
/// OMLX servers expose an OpenAI v1-compatible endpoint and require a Bearer API key.
|
||||
/// Default URL: `http://127.0.0.1:8000/v1` (override via `OMLX_SERVER_URL` env
|
||||
/// or `local_ai.base_url` when provider is set to "omlx").
|
||||
fn make_omlx_provider(
|
||||
model: &str,
|
||||
temperature_override: Option<f64>,
|
||||
config: &Config,
|
||||
) -> anyhow::Result<(Box<dyn Provider>, String)> {
|
||||
use crate::openhuman::inference::local::profile::{LocalProviderKind, OMLX_PROFILE};
|
||||
|
||||
let endpoint = std::env::var("OMLX_SERVER_URL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| config.local_ai.base_url.clone())
|
||||
.unwrap_or_else(|| OMLX_PROFILE.default_base_url.to_string());
|
||||
let api_key = config.local_ai.api_key.as_deref().unwrap_or("");
|
||||
if api_key.trim().is_empty() {
|
||||
log::warn!(
|
||||
"[providers][chat-factory] omlx: no api_key configured — OMLX requires a Bearer key; \
|
||||
requests will likely 401"
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"[providers][chat-factory] building omlx provider model={} endpoint_host={} temp_override={:?}",
|
||||
model,
|
||||
redact_endpoint(&endpoint),
|
||||
temperature_override
|
||||
);
|
||||
let auth = if api_key.trim().is_empty() {
|
||||
CompatAuthStyle::None
|
||||
} else {
|
||||
CompatAuthStyle::Bearer
|
||||
};
|
||||
let provider = OpenAiCompatibleProvider::new_no_responses_fallback(
|
||||
"omlx",
|
||||
&endpoint,
|
||||
if api_key.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key)
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.with_temperature_unsupported_models(config.temperature_unsupported_models.clone())
|
||||
.with_temperature_override(temperature_override)
|
||||
.with_native_tool_calling(false)
|
||||
.with_vision(false)
|
||||
.with_local_provider_kind(LocalProviderKind::Omlx);
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
}
|
||||
|
||||
/// Build a generic local OpenAI-compatible provider.
|
||||
///
|
||||
/// Points at any local server that speaks the OpenAI chat-completions API
|
||||
|
||||
@@ -1842,12 +1842,29 @@ fn byok_fallback_skips_mlx_and_local_openai() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byok_fallback_skips_omlx() {
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("omlx:llama3".to_string());
|
||||
|
||||
assert!(
|
||||
resolve_byok_fallback_provider_string(&config).is_none(),
|
||||
"OMLX is a local provider and must not be treated as a BYOK cloud fallback"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_for_role("coding", &config),
|
||||
"openhuman",
|
||||
"unset coding must not inherit chat OMLX as a BYOK fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_string_detection() {
|
||||
use crate::openhuman::inference::local::profile::is_local_provider_string;
|
||||
assert!(is_local_provider_string("ollama:phi3"));
|
||||
assert!(is_local_provider_string("lmstudio:model"));
|
||||
assert!(is_local_provider_string("mlx:llama"));
|
||||
assert!(is_local_provider_string("omlx:llama"));
|
||||
assert!(is_local_provider_string("local-openai:qwen2"));
|
||||
assert!(!is_local_provider_string("openai:gpt-4o"));
|
||||
assert!(!is_local_provider_string("openhuman"));
|
||||
@@ -1928,6 +1945,70 @@ fn resolve_model_for_hint_handles_unknown_hint_passthrough() {
|
||||
assert_eq!(result, "hint:unknown_tier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_provider_builds_with_bearer_key() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.local_ai.api_key = Some("sk-omlx-test".to_string());
|
||||
config.local_ai.base_url = Some("http://127.0.0.1:8000/v1".to_string());
|
||||
let (_provider, model) =
|
||||
super::make_omlx_provider("my-model", None, &config).expect("omlx provider builds");
|
||||
assert_eq!(model, "my-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_dispatch_empty_model_errors() {
|
||||
// Covers the empty-model bail! arms in create_chat_provider_from_string
|
||||
// and create_local_chat_provider_from_string for the "omlx:" prefix.
|
||||
let config = crate::openhuman::config::Config::default();
|
||||
|
||||
let err = create_chat_provider_from_string("chat", "omlx:", &config)
|
||||
.err()
|
||||
.expect("omlx: with empty model must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("empty model") || msg.contains("omlx:<model"),
|
||||
"expected empty-model diagnostic, got: {msg}"
|
||||
);
|
||||
|
||||
let err_local = create_local_chat_provider_from_string("omlx:", &config)
|
||||
.err()
|
||||
.expect("omlx: with empty model must fail via local dispatch");
|
||||
let msg_local = err_local.to_string();
|
||||
assert!(
|
||||
msg_local.contains("empty model") || msg_local.contains("omlx:<model"),
|
||||
"expected empty-model diagnostic from local dispatch, got: {msg_local}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_provider_builds_without_key_uses_no_auth() {
|
||||
// Covers the no-api_key warn branch in make_omlx_provider — must not panic,
|
||||
// must return Ok with the correct model name.
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.local_ai.api_key = None;
|
||||
config.local_ai.base_url = Some("http://127.0.0.1:8000/v1".to_string());
|
||||
let (_provider, model) =
|
||||
super::make_omlx_provider("m", None, &config).expect("omlx provider builds without key");
|
||||
assert_eq!(model, "m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_dispatch_success_builds_provider() {
|
||||
// Covers the success arms (non-empty model -> make_omlx_provider) in both
|
||||
// create_chat_provider_from_string and create_local_chat_provider_from_string.
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.local_ai.api_key = Some("sk-omlx-test".to_string());
|
||||
config.local_ai.base_url = Some("http://127.0.0.1:8000/v1".to_string());
|
||||
|
||||
let (_p, model) = create_chat_provider_from_string("chat", "omlx:my-model", &config)
|
||||
.expect("omlx:<model> builds via public factory");
|
||||
assert_eq!(model, "my-model");
|
||||
|
||||
let (_p_local, model_local) = create_local_chat_provider_from_string("omlx:my-model", &config)
|
||||
.expect("omlx:<model> builds via local dispatch");
|
||||
assert_eq!(model_local, "my-model");
|
||||
}
|
||||
|
||||
// ── #3767: managed-credits gate bypass (gate-only, per-tier) ───────────────
|
||||
//
|
||||
// Routing is NOT changed by this fix — selecting a BYO provider already routes
|
||||
|
||||
@@ -16,6 +16,34 @@ pub struct ModelInfo {
|
||||
pub context_window: Option<u64>,
|
||||
}
|
||||
|
||||
/// Resolve the API key used to probe a provider's `/models` endpoint.
|
||||
///
|
||||
/// Cloud providers store their key in auth-profiles.json (via `lookup_key_for_slug`),
|
||||
/// but the OMLX local-runtime chip persists its Bearer key in
|
||||
/// `config.local_ai.api_key`. When the slug-scoped lookup comes back empty for omlx,
|
||||
/// fall back to that local-runtime key so the reachability probe still sends
|
||||
/// `Authorization: Bearer` (otherwise the OMLX server returns 401).
|
||||
fn resolve_local_runtime_key(
|
||||
slug: &str,
|
||||
looked_up: String,
|
||||
config: &crate::openhuman::config::Config,
|
||||
) -> String {
|
||||
let looked_up = looked_up.trim().to_string();
|
||||
if !looked_up.is_empty() {
|
||||
return looked_up;
|
||||
}
|
||||
if slug == "omlx" {
|
||||
return config
|
||||
.local_ai
|
||||
.api_key
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
}
|
||||
looked_up
|
||||
}
|
||||
|
||||
pub async fn list_configured_models(
|
||||
provider_id: &str,
|
||||
) -> Result<crate::rpc::RpcOutcome<serde_json::Value>, String> {
|
||||
@@ -48,10 +76,10 @@ pub async fn list_configured_models_from_config(
|
||||
.or_else(|| synthesize_local_runtime_entry(&provider_id, config))
|
||||
.ok_or_else(|| format!("no cloud provider with id or slug '{}' found", provider_id))?;
|
||||
|
||||
let api_key =
|
||||
let looked_up =
|
||||
crate::openhuman::inference::provider::factory::lookup_key_for_slug(&entry.slug, config)
|
||||
.unwrap_or_default();
|
||||
let api_key = api_key.trim().to_string();
|
||||
let api_key = resolve_local_runtime_key(&entry.slug, looked_up, config);
|
||||
|
||||
let routing = resolve_openai_codex_routing(config, &entry.slug, &entry.endpoint, &api_key)
|
||||
.unwrap_or_else(|err| {
|
||||
@@ -493,3 +521,39 @@ async fn validate_openrouter_api_key(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_local_runtime_key;
|
||||
use crate::openhuman::config::Config;
|
||||
|
||||
#[test]
|
||||
fn omlx_key_falls_back_to_local_ai_api_key() {
|
||||
let mut config = Config::default();
|
||||
config.local_ai.api_key = Some(" sk-omlx-list ".into());
|
||||
assert_eq!(
|
||||
resolve_local_runtime_key("omlx", String::new(), &config),
|
||||
"sk-omlx-list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looked_up_key_wins_over_local_ai() {
|
||||
let mut config = Config::default();
|
||||
config.local_ai.api_key = Some("sk-local".into());
|
||||
assert_eq!(
|
||||
resolve_local_runtime_key("omlx", "from-profiles".into(), &config),
|
||||
"from-profiles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_omlx_slug_does_not_fall_back() {
|
||||
let mut config = Config::default();
|
||||
config.local_ai.api_key = Some("sk-local".into());
|
||||
assert_eq!(
|
||||
resolve_local_runtime_key("ollama", String::new(), &config),
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ struct InferenceUpdateLocalSettingsParams {
|
||||
usage_heartbeat: Option<bool>,
|
||||
usage_learning_reflection: Option<bool>,
|
||||
usage_subconscious: Option<bool>,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -336,6 +337,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"base_url",
|
||||
"Optional local provider base URL string, or null to clear.",
|
||||
),
|
||||
optional_string(
|
||||
"api_key",
|
||||
"Optional Bearer API key for a local provider that requires one (e.g. OMLX); empty string clears it.",
|
||||
),
|
||||
optional_string("model_id", "Optional generic model id override."),
|
||||
optional_string("chat_model_id", "Optional chat model id override."),
|
||||
optional_bool("usage_embeddings", "Whether embeddings workload may use the local provider."),
|
||||
@@ -779,6 +784,7 @@ fn handle_inference_update_local_settings(params: Map<String, Value>) -> Control
|
||||
usage_heartbeat: update.usage_heartbeat,
|
||||
usage_learning_reflection: update.usage_learning_reflection,
|
||||
usage_subconscious: update.usage_subconscious,
|
||||
api_key: update.api_key,
|
||||
};
|
||||
to_json(crate::openhuman::inference::rpc::inference_update_local_settings(patch).await?)
|
||||
})
|
||||
|
||||
@@ -72,6 +72,24 @@ fn inference_update_local_settings_schema_allows_json_base_url() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_update_local_settings_schema_accepts_api_key() {
|
||||
// Regression: the OMLX Bearer key flows through `inference.update_local_settings`.
|
||||
// The serde struct + handler carried `api_key`, but the ControllerSchema inputs
|
||||
// did not — so `validate_params` rejected it as "unknown param 'api_key'" before
|
||||
// dispatch. The field must be declared here for the RPC to accept it.
|
||||
let schema = schemas("update_local_settings");
|
||||
let field = schema
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|field| field.name == "api_key")
|
||||
.expect("api_key field must be declared so validate_params accepts it");
|
||||
match &field.ty {
|
||||
TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::String)),
|
||||
other => panic!("expected Option<String>, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() {
|
||||
let registered: Vec<&str> = all_registered_controllers()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! "openhuman" → managed OpenHuman backend
|
||||
//! "ollama:<model>" → local Ollama
|
||||
//! "lmstudio:<model>" → local LM Studio
|
||||
//! "omlx:<model>" → local OMLX
|
||||
//! "<slug>:<model>" → the cloud_providers entry whose slug == <slug>
|
||||
//! ```
|
||||
//!
|
||||
@@ -53,7 +54,8 @@
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::inference::provider::factory::{
|
||||
LM_STUDIO_PROVIDER_PREFIX, OLLAMA_PROVIDER_PREFIX, PROVIDER_OPENHUMAN,
|
||||
LM_STUDIO_PROVIDER_PREFIX, LOCAL_OPENAI_PROVIDER_PREFIX, MLX_PROVIDER_PREFIX,
|
||||
OLLAMA_PROVIDER_PREFIX, OMLX_PROVIDER_PREFIX, PROVIDER_OPENHUMAN,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -102,13 +104,18 @@ pub fn run(config: &mut Config) -> anyhow::Result<MigrationStats> {
|
||||
};
|
||||
let s = raw.trim();
|
||||
|
||||
// Managed sentinels and local providers resolve without a
|
||||
// cloud_providers entry — leave them alone.
|
||||
// Managed sentinels and factory-resolvable local providers (ollama:,
|
||||
// lmstudio:, mlx:, omlx:, local-openai:) resolve without a
|
||||
// cloud_providers entry — leave them alone. Keep this in sync with the
|
||||
// local provider prefixes the factory accepts.
|
||||
if s.is_empty()
|
||||
|| s == "cloud"
|
||||
|| s == PROVIDER_OPENHUMAN
|
||||
|| s.starts_with(OLLAMA_PROVIDER_PREFIX)
|
||||
|| s.starts_with(LM_STUDIO_PROVIDER_PREFIX)
|
||||
|| s.starts_with(MLX_PROVIDER_PREFIX)
|
||||
|| s.starts_with(OMLX_PROVIDER_PREFIX)
|
||||
|| s.starts_with(LOCAL_OPENAI_PROVIDER_PREFIX)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -207,3 +207,43 @@ fn idempotent_second_run_scrubs_nothing() {
|
||||
assert_eq!(second.workload_fields_scrubbed, 0);
|
||||
assert!(!second.primary_cloud_cleared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omlx_routing_ref_is_not_orphaned() {
|
||||
// An omlx:<model> ref is a local provider and must be left intact.
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("omlx:my-model".to_string());
|
||||
|
||||
let stats = run(&mut config).expect("migration should succeed");
|
||||
|
||||
assert_eq!(
|
||||
stats.workload_fields_scrubbed, 0,
|
||||
"omlx: prefix is local and must not be scrubbed"
|
||||
);
|
||||
assert_eq!(
|
||||
config.chat_provider.as_deref(),
|
||||
Some("omlx:my-model"),
|
||||
"omlx:<model> ref must survive reconciliation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolvable_local_provider_refs_are_not_orphaned() {
|
||||
// mlx: and local-openai: also resolve in the factory without a
|
||||
// cloud_providers entry, so they must survive reconciliation too.
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("mlx:some-model".to_string());
|
||||
config.reasoning_provider = Some("local-openai:some-model".to_string());
|
||||
|
||||
let stats = run(&mut config).expect("migration should succeed");
|
||||
|
||||
assert_eq!(
|
||||
stats.workload_fields_scrubbed, 0,
|
||||
"mlx: and local-openai: prefixes are local and must not be scrubbed"
|
||||
);
|
||||
assert_eq!(config.chat_provider.as_deref(), Some("mlx:some-model"));
|
||||
assert_eq!(
|
||||
config.reasoning_provider.as_deref(),
|
||||
Some("local-openai:some-model")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,8 +277,8 @@ mod tests {
|
||||
|
||||
// ── ensure_triggered_workflow_subscriber (C1 boot-path helper) ───────────
|
||||
|
||||
#[test]
|
||||
fn ensure_triggered_workflow_subscriber_is_idempotent_and_safe() {
|
||||
#[tokio::test]
|
||||
async fn ensure_triggered_workflow_subscriber_is_idempotent_and_safe() {
|
||||
// Covers the boot-path helper called from both `start_channels` and
|
||||
// `bootstrap_core_runtime`: it loads workflow metadata from the
|
||||
// workspace and registers the subscriber exactly once via the
|
||||
@@ -286,6 +286,12 @@ mod tests {
|
||||
// workflows, so registration resolves to `None`; the call must not
|
||||
// panic and must be safe to repeat (the OnceLock makes the second call
|
||||
// a no-op).
|
||||
//
|
||||
// Runs under a tokio runtime (like the real async boot paths): the
|
||||
// process-global OnceLock means whichever test initializes it first runs
|
||||
// the registration closure, and if leaked global workspace state makes
|
||||
// `load_workflow_metadata` find workflows, `subscribe_global` will
|
||||
// `tokio::spawn` the subscriber task — which panics without a runtime.
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
ensure_triggered_workflow_subscriber(tmp.path());
|
||||
ensure_triggered_workflow_subscriber(tmp.path());
|
||||
|
||||
Reference in New Issue
Block a user