diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index a14d20dde..9a1e491ca 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -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 = { 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 = { lmstudio: 'LM Studio', ollama: 'Ollama' }; +const LOCAL_CHIP_LABEL: Record = { + 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 } | 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; + /** 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; }) => { 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( initialValue ?? (isLocalRuntime ? defaultEndpointFor(slug) : '') ); + const [keyValue, setKeyValue] = useState(initialKeyValue ?? ''); const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle'); const [error, setError] = useState(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 ? ( + <> + + { + setKeyValue(e.target.value); + setError(null); + }} + /> + + ) : null} {error ? : null} @@ -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 ''; } diff --git a/app/src/components/settings/panels/__tests__/AIPanel.omlx.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.omlx.test.tsx new file mode 100644 index 000000000..43906d144 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/AIPanel.omlx.test.tsx @@ -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( + '../../../../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(); + 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(); + 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(); + 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' }); + }); +}); diff --git a/app/src/components/settings/panels/aiRouting.ts b/app/src/components/settings/panels/aiRouting.ts index f1c65c941..a0248f2c5 100644 --- a/app/src/components/settings/panels/aiRouting.ts +++ b/app/src/components/settings/panels/aiRouting.ts @@ -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 diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 40a1ab44a..5bf78d57a 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -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; diff --git a/docs/WEEKLY-CODE-REVIEW.md b/docs/WEEKLY-CODE-REVIEW.md index 017ebc1de..0896d02c6 100644 --- a/docs/WEEKLY-CODE-REVIEW.md +++ b/docs/WEEKLY-CODE-REVIEW.md @@ -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: diff --git a/src/openhuman/config/ops/model.rs b/src/openhuman/config/ops/model.rs index 529f528ad..94720bfe7 100644 --- a/src/openhuman/config/ops/model.rs +++ b/src/openhuman/config/ops/model.rs @@ -83,6 +83,7 @@ pub struct LocalAiSettingsPatch { pub usage_heartbeat: Option, pub usage_learning_reflection: Option, pub usage_subconscious: Option, + pub api_key: Option, } #[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( diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 95616391a..ae2db12a2 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -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(); diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs index 496e7ecd1..08499d874 100644 --- a/src/openhuman/config/schema/local_ai.rs +++ b/src/openhuman/config/schema/local_ai.rs @@ -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 diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index abde9f96a..a94c28829 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -522,6 +522,7 @@ fn handle_update_local_ai_settings(params: Map) -> 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?) }) diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index 016243d01..cda9c76d7 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -155,6 +155,7 @@ pub(super) struct LocalAiSettingsUpdate { pub(super) usage_heartbeat: Option, pub(super) usage_learning_reflection: Option, pub(super) usage_subconscious: Option, + pub(super) api_key: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index fb5ef2fed..b02532e50 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -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( diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index 97e9ffb7c..e355b0c6a 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -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, got {other:?}"), + } +} + #[test] fn deserialize_params_parses_workspace_onboarding_flag_params() { let out: WorkspaceOnboardingFlagParams = deserialize_params(Map::new()).unwrap(); diff --git a/src/openhuman/inference/local/profile.rs b/src/openhuman/inference/local/profile.rs index 26044b094..3779b2447 100644 --- a/src/openhuman/inference/local/profile.rs +++ b/src/openhuman/inference/local/profile.rs @@ -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 { 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")); + } } diff --git a/src/openhuman/inference/local/provider.rs b/src/openhuman/inference/local/provider.rs index 772651dcc..e869ac8b7 100644 --- a/src/openhuman/inference/local/provider.rs +++ b/src/openhuman/inference/local/provider.rs @@ -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:`), 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"); + } } diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 3381733bd..05cbca05f 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -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:"`. pub const MLX_PROVIDER_PREFIX: &str = "mlx:"; +/// Prefix for OMLX local providers: `"omlx:"`. +pub const OMLX_PROVIDER_PREFIX: &str = "omlx:"; /// Prefix for generic local OpenAI-compatible providers: `"local-openai:"`. pub const LOCAL_OPENAI_PROVIDER_PREFIX: &str = "local-openai:"; /// Prefix for the Claude Agent SDK subprocess provider: `"claude_agent_sdk:"`. @@ -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'", + 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:, lmstudio:, mlx:, \ + Valid forms: openhuman, ollama:, lmstudio:, mlx:, omlx:, \ local-openai:, claude_agent_sdk, claude_agent_sdk:, :. \ 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:'", + 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:, lmstudio:, mlx:, local-openai:", + ollama:, lmstudio:, mlx:, omlx:, local-openai:", 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, + config: &Config, +) -> anyhow::Result<(Box, 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 diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 777494407..dc14fea2c 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -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: 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: 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: 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 diff --git a/src/openhuman/inference/provider/ops/models.rs b/src/openhuman/inference/provider/ops/models.rs index ef077a276..d07cc125d 100644 --- a/src/openhuman/inference/provider/ops/models.rs +++ b/src/openhuman/inference/provider/ops/models.rs @@ -16,6 +16,34 @@ pub struct ModelInfo { pub context_window: Option, } +/// 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, 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), + "" + ); + } +} diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 89751371e..423f6c3a2 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -115,6 +115,7 @@ struct InferenceUpdateLocalSettingsParams { usage_heartbeat: Option, usage_learning_reflection: Option, usage_subconscious: Option, + api_key: Option, } #[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) -> 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?) }) diff --git a/src/openhuman/inference/schemas_tests.rs b/src/openhuman/inference/schemas_tests.rs index 38e83b40f..680278053 100644 --- a/src/openhuman/inference/schemas_tests.rs +++ b/src/openhuman/inference/schemas_tests.rs @@ -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, got {other:?}"), + } +} + #[test] fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() { let registered: Vec<&str> = all_registered_controllers() diff --git a/src/openhuman/migrations/reconcile_orphaned_providers.rs b/src/openhuman/migrations/reconcile_orphaned_providers.rs index a645afc7e..207fe8901 100644 --- a/src/openhuman/migrations/reconcile_orphaned_providers.rs +++ b/src/openhuman/migrations/reconcile_orphaned_providers.rs @@ -11,6 +11,7 @@ //! "openhuman" → managed OpenHuman backend //! "ollama:" → local Ollama //! "lmstudio:" → local LM Studio +//! "omlx:" → local OMLX //! ":" → the cloud_providers entry whose 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 { }; 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; } diff --git a/src/openhuman/migrations/reconcile_orphaned_providers_tests.rs b/src/openhuman/migrations/reconcile_orphaned_providers_tests.rs index d2f2c8ab7..9b85adf27 100644 --- a/src/openhuman/migrations/reconcile_orphaned_providers_tests.rs +++ b/src/openhuman/migrations/reconcile_orphaned_providers_tests.rs @@ -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: 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: 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") + ); +} diff --git a/src/openhuman/workflows/bus.rs b/src/openhuman/workflows/bus.rs index af7f367b0..56336fa98 100644 --- a/src/openhuman/workflows/bus.rs +++ b/src/openhuman/workflows/bus.rs @@ -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());