Omlx local provider (#3862)

Co-authored-by: OpenHuman <package@openhuman.org>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Heiko Liebscher
2026-06-22 13:00:51 -07:00
committed by GitHub
co-authored by OpenHuman Steven Enamakel
parent f6f65df71f
commit 6762efd7f3
22 changed files with 874 additions and 32 deletions
+122 -14
View File
@@ -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
+6
View File
@@ -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;
+1 -1
View File
@@ -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:
+25 -2
View File
@@ -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(
+61
View File
@@ -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();
+2 -2
View File
@@ -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?)
})
+1
View File
@@ -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)]
+5 -1
View File
@@ -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(
+14
View File
@@ -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();
+48
View File
@@ -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"));
}
}
+17
View File
@@ -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");
}
}
+84 -4
View File
@@ -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
+66 -2
View File
@@ -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),
""
);
}
}
+6
View File
@@ -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?)
})
+18
View File
@@ -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")
);
}
+8 -2
View File
@@ -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());