From f8c9698ecc9f33b7b2a9b7cc18db5468461b1c0e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 23 May 2026 08:33:33 -0700 Subject: [PATCH] feat(inference): OpenAI-compatible /v1 router with user-managed API key (#2523) --- AGENTS.md | 1 + CLAUDE.md | 1 + .../components/settings/panels/AIPanel.tsx | 125 ++++++++++++++++++ .../panels/__tests__/AIPanel.test.tsx | 75 +++++++++++ app/src/lib/i18n/chunks/ar-4.ts | 12 ++ app/src/lib/i18n/chunks/bn-4.ts | 12 ++ app/src/lib/i18n/chunks/de-4.ts | 12 ++ app/src/lib/i18n/chunks/en-4.ts | 12 ++ app/src/lib/i18n/chunks/es-4.ts | 12 ++ app/src/lib/i18n/chunks/fr-4.ts | 12 ++ app/src/lib/i18n/chunks/hi-4.ts | 12 ++ app/src/lib/i18n/chunks/id-4.ts | 12 ++ app/src/lib/i18n/chunks/it-4.ts | 12 ++ app/src/lib/i18n/chunks/pt-4.ts | 12 ++ app/src/lib/i18n/chunks/ru-4.ts | 12 ++ app/src/lib/i18n/chunks/zh-CN-4.ts | 12 ++ app/src/lib/i18n/en.ts | 12 ++ .../api/__tests__/aiSettingsApi.test.ts | 66 ++++++++- app/src/services/api/aiSettingsApi.ts | 41 +++++- src/core/auth.rs | 99 +++++++++++++- src/openhuman/inference/http/mod.rs | 12 +- src/openhuman/inference/http/server.rs | 109 ++++++++++++--- src/openhuman/inference/http/tests.rs | 8 ++ 23 files changed, 669 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c45cf9b9a..ad7a2af55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -525,6 +525,7 @@ Follow this order so behavior is **specified**, **proven in Rust**, **proven ove - **Pre-merge checks** (when touching code): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust (`Cargo.toml` at root and/or `app/src-tauri/Cargo.toml` as appropriate). - **No dynamic imports** in production **`app/src`** code — use **static** `import` / `import type` at the top of the module. Do **not** use `import()` (async dynamic import), `React.lazy(() => import(...))`, or `await import('…')` to load app modules, Tauri APIs, or RPC clients. **Why:** predictable chunk graph, simpler static analysis, fewer surprises in Tauri + Vite, and easier code review. **If a module must not run at load time** (e.g. heavy optional path), use a static import and **guard the call site** with `try/catch` or an explicit runtime check instead of deferring module load via dynamic import. **Exceptions:** Vitest harness patterns (`vi.importActual`, dynamic imports **only** inside `*.test.ts` / `__tests__` / `test/setup.ts` when required by the runner); ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).- **Type-only imports**: `import type` where appropriate. - **Dual socket / tool sync**: If you change realtime protocol, keep **frontend** (`socketService` / MCP transport) and **core** socket behavior aligned (see [`gitbooks/developing/architecture.md`](gitbooks/developing/architecture.md) dual-socket section). +- **i18n for all UI text**: Every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, dialog copy, `aria-label`, etc.) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the new key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. **Exceptions:** developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). --- diff --git a/CLAUDE.md b/CLAUDE.md index c24f5347a..b35a41661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,7 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test. - **Pre-merge** (code changes): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust. - **No dynamic imports** in production `app/src` code — static `import` / `import type` only. No `import()`, `React.lazy(() => import(...))`, `await import(...)`. For heavy optional paths, use a static import and guard the call site with `try/catch` or a runtime check. *Exceptions*: Vitest harness patterns in `*.test.ts` / `__tests__` / `test/setup.ts`; ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc). - **Dual socket sync**: when changing the realtime protocol, keep `socketService` / MCP transport aligned with core socket behavior (see `gitbooks/developing/architecture.md` dual-socket section). +- **i18n for all UI text**: every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, error messages, dialog copy) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. Exceptions: developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values). --- diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 172edbdc1..3da3da735 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -18,15 +18,18 @@ import { type AISettings as ApiAISettings, type ProviderRef as ApiProviderRef, clearCloudProviderKey, + clearOpenAICompatEndpointKey, type CloudProviderView, flushCloudProviders, listProviderModels, loadAISettings, loadLocalProviderSnapshot, + loadOpenAICompatEndpointStatus, type LocalProviderSnapshot, type ModelInfo, saveAISettings, setCloudProviderKey, + setOpenAICompatEndpointKey, } from '../../../services/api/aiSettingsApi'; import { creditsApi, @@ -2002,6 +2005,12 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { const [customDialogFor, setCustomDialogFor] = useState(null); // Which provider slug's API-key dialog is currently open (null = closed). const [keyDialogFor, setKeyDialogFor] = useState(null); + const [openAiCompatDialogOpen, setOpenAiCompatDialogOpen] = useState(false); + const [openAiCompatStatus, setOpenAiCompatStatus] = useState<{ + baseUrl: string | null; + has_api_key: boolean; + }>({ baseUrl: null, has_api_key: false }); + const [openAiCompatBusy, setOpenAiCompatBusy] = useState(null); // When the user toggles LM Studio / Ollama (local runtimes), we // need to remember which label to attach to the upserted provider so the // chip can find it again. Cleared when the dialog closes. @@ -2010,6 +2019,24 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { const updateRouting = (id: WorkloadId, next: ProviderRef) => setDraft({ ...draft, routing: { ...draft.routing, [id]: next } }); + useEffect(() => { + let active = true; + loadOpenAICompatEndpointStatus() + .then(status => { + if (active) { + setOpenAiCompatStatus(status); + } + }) + .catch(() => { + if (active) { + setOpenAiCompatStatus({ baseUrl: null, has_api_key: false }); + } + }); + return () => { + active = false; + }; + }, []); + // applyPreset removed alongside the Cloud / Local / Mixed preset pills — // the new Default/Custom binary toggle handles routing per workload. @@ -2060,6 +2087,85 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {

+
+
+
+

+ {t('settings.ai.openAiCompat.title')} +

+

+ {t('settings.ai.openAiCompat.description')} +

+
+
+ + {openAiCompatStatus.has_api_key + ? t('settings.ai.openAiCompat.keyConfigured') + : t('settings.ai.openAiCompat.keyRequired')} + + + {openAiCompatStatus.has_api_key ? ( + + ) : null} +
+
+ +
+
+ + +
+
+ +
+ {t('settings.ai.openAiCompat.authHeaderExample')} +
+
+
+
+ {/* ─── Provider chip-toggle list ────────────────────────────────── */}
{loading && ( @@ -2378,6 +2484,25 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { ); })()} + {openAiCompatDialogOpen && ( + setOpenAiCompatDialogOpen(false)} + onSubmit={async value => { + setOpenAiCompatBusy('save'); + try { + await setOpenAICompatEndpointKey(value.trim()); + setOpenAiCompatStatus(prev => ({ ...prev, has_api_key: true })); + setOpenAiCompatDialogOpen(false); + } finally { + setOpenAiCompatBusy(null); + } + }} + /> + )} + {keyDialogFor && ( ({ 'subconscious', ], loadAISettings: vi.fn(), + loadOpenAICompatEndpointStatus: vi.fn(), saveAISettings: vi.fn(), loadLocalProviderSnapshot: vi.fn(), + setOpenAICompatEndpointKey: vi.fn(), + clearOpenAICompatEndpointKey: vi.fn().mockResolvedValue(undefined), setCloudProviderKey: vi.fn(), clearCloudProviderKey: vi.fn().mockResolvedValue(undefined), serializeProviderRef: vi.fn((r: { kind: string; providerSlug?: string; model?: string }) => @@ -189,7 +195,13 @@ describe('AIPanel', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(loadAISettings).mockResolvedValue(baseSettings); + vi.mocked(loadOpenAICompatEndpointStatus).mockResolvedValue({ + baseUrl: 'http://127.0.0.1:7788/v1', + has_api_key: false, + }); vi.mocked(loadLocalProviderSnapshot).mockResolvedValue(baseLocalSnapshot); + vi.mocked(setOpenAICompatEndpointKey).mockResolvedValue(undefined); + vi.mocked(clearOpenAICompatEndpointKey).mockResolvedValue(undefined); vi.mocked(setCloudProviderKey).mockResolvedValue(undefined); vi.mocked(listProviderModels).mockResolvedValue([]); vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({ @@ -231,6 +243,69 @@ describe('AIPanel', () => { expect(screen.getAllByText(/^Routing$/).length).toBeGreaterThan(0); }); + it('renders the OpenAI-compatible endpoint card with the local /v1 base URL', async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('OpenAI-compatible endpoint')).toBeInTheDocument()); + expect(screen.getByDisplayValue('http://127.0.0.1:7788/v1')).toBeInTheDocument(); + expect(screen.getByText(/Authorization: Bearer/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Set key' })).toBeInTheDocument(); + }); + + it('renders Rotate/Clear controls when an OpenAI-compat key is configured', async () => { + vi.mocked(loadOpenAICompatEndpointStatus).mockResolvedValueOnce({ + baseUrl: 'http://127.0.0.1:7788/v1', + has_api_key: true, + }); + renderWithProviders(); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Rotate key' })).toBeInTheDocument() + ); + expect(screen.getByRole('button', { name: 'Clear key' })).toBeInTheDocument(); + expect(screen.getByText('Key configured')).toBeInTheDocument(); + }); + + it('falls back to the localized "Unavailable" base URL when resolution fails', async () => { + vi.mocked(loadOpenAICompatEndpointStatus).mockRejectedValueOnce(new Error('boom')); + renderWithProviders(); + + await waitFor(() => expect(screen.getByDisplayValue('Unavailable')).toBeInTheDocument()); + }); + + it('clears the OpenAI-compat key when the Clear button is clicked', async () => { + vi.mocked(loadOpenAICompatEndpointStatus).mockResolvedValueOnce({ + baseUrl: 'http://127.0.0.1:7788/v1', + has_api_key: true, + }); + renderWithProviders(); + + const clearBtn = await screen.findByRole('button', { name: 'Clear key' }); + fireEvent.click(clearBtn); + + await waitFor(() => expect(clearOpenAICompatEndpointKey).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Set key' })).toBeInTheDocument() + ); + }); + + it('persists a new OpenAI-compat key via the Set key dialog', async () => { + renderWithProviders(); + + const setBtn = await screen.findByRole('button', { name: 'Set key' }); + fireEvent.click(setBtn); + + const input = await screen.findByLabelText(/API Key/i); + fireEvent.change(input, { target: { value: 'sk-test-12345' } }); + const submit = screen.getByRole('button', { name: /^Save$/ }); + fireEvent.click(submit); + + await waitFor(() => expect(setOpenAICompatEndpointKey).toHaveBeenCalledWith('sk-test-12345')); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Rotate key' })).toBeInTheDocument() + ); + }); + it('renders the OpenHuman primary card after load', async () => { renderWithProviders(); // The OpenHuman label now appears in multiple places (provider card, diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index a8542964f..40005a44f 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -295,6 +295,18 @@ const ar4: TranslationMap = { 'settings.ai.localOllama': 'محلي (Ollama)', 'settings.ai.modelLabel': 'النموذج', 'settings.ai.noCustomProviders': 'لا يوجد مزودون مخصصون', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'المزود', 'settings.ai.routing': 'التوجيه', 'settings.ai.routingCustom': 'توجيه مخصص', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 68ce23e3a..d4124ed13 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -297,6 +297,18 @@ const bn4: TranslationMap = { 'settings.ai.localOllama': 'লোকাল (Ollama)', 'settings.ai.modelLabel': 'মডেল', 'settings.ai.noCustomProviders': 'কোনো কাস্টম প্রোভাইডার নেই', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'প্রোভাইডার', 'settings.ai.routing': 'রুটিং', 'settings.ai.routingCustom': 'কাস্টম রুটিং', diff --git a/app/src/lib/i18n/chunks/de-4.ts b/app/src/lib/i18n/chunks/de-4.ts index 2fb9ae2ff..c62746847 100644 --- a/app/src/lib/i18n/chunks/de-4.ts +++ b/app/src/lib/i18n/chunks/de-4.ts @@ -305,6 +305,18 @@ const de4: TranslationMap = { 'settings.ai.localOllama': 'Lokal (Ollama)', 'settings.ai.modelLabel': 'Modell', 'settings.ai.noCustomProviders': 'Keine benutzerdefinierten Anbieter', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Anbieter', 'settings.ai.routing': 'Routenführung', 'settings.ai.routingCustom': 'Routing benutzerdefiniert', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index b5b3e70cf..9f43f460b 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -303,6 +303,18 @@ const en4: TranslationMap = { 'settings.ai.localOllama': 'Local (Ollama)', 'settings.ai.modelLabel': 'Model', 'settings.ai.noCustomProviders': 'No custom providers', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Provider', 'settings.ai.routing': 'Routing', 'settings.ai.routingCustom': 'Routing custom', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index f0e60635f..c818c7cf9 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -300,6 +300,18 @@ const es4: TranslationMap = { 'settings.ai.localOllama': 'Local (Ollama)', 'settings.ai.modelLabel': 'Modelo', 'settings.ai.noCustomProviders': 'Sin proveedores personalizados', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Proveedor', 'settings.ai.routing': 'Enrutamiento', 'settings.ai.routingCustom': 'Enrutamiento personalizado', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index a8da2d56d..7cd625967 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -299,6 +299,18 @@ const fr4: TranslationMap = { 'settings.ai.localOllama': 'Local (Ollama)', 'settings.ai.modelLabel': 'Modèle', 'settings.ai.noCustomProviders': 'Aucun fournisseur personnalisé', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Fournisseur', 'settings.ai.routing': 'Routage', 'settings.ai.routingCustom': 'Routage personnalisé', diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 5892e10b9..0364f12b4 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -298,6 +298,18 @@ const hi4: TranslationMap = { 'settings.ai.localOllama': 'लोकल (Ollama)', 'settings.ai.modelLabel': 'मॉडल', 'settings.ai.noCustomProviders': 'कोई कस्टम प्रोवाइडर नहीं', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'प्रोवाइडर', 'settings.ai.routing': 'रूटिंग', 'settings.ai.routingCustom': 'कस्टम रूटिंग', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index f358752c0..091387aec 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -299,6 +299,18 @@ const id4: TranslationMap = { 'settings.ai.localOllama': 'Lokal (Ollama)', 'settings.ai.modelLabel': 'Model', 'settings.ai.noCustomProviders': 'Tidak ada penyedia kustom', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Penyedia', 'settings.ai.routing': 'Perutean', 'settings.ai.routingCustom': 'Routing kustom', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index d31922e65..bd52cc04e 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -300,6 +300,18 @@ const it4: TranslationMap = { 'settings.ai.localOllama': 'Locale (Ollama)', 'settings.ai.modelLabel': 'Modello', 'settings.ai.noCustomProviders': 'Nessun provider personalizzato', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Provider', 'settings.ai.routing': 'Instradamento', 'settings.ai.routingCustom': 'Instradamento personalizzato', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index 5bd2212b5..4cc448437 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -300,6 +300,18 @@ const pt4: TranslationMap = { 'settings.ai.localOllama': 'Local (Ollama)', 'settings.ai.modelLabel': 'Modelo', 'settings.ai.noCustomProviders': 'Sem provedores personalizados', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Provedor', 'settings.ai.routing': 'Roteamento', 'settings.ai.routingCustom': 'Roteamento personalizado', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index f5671fb92..a3a4ff812 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -297,6 +297,18 @@ const ru4: TranslationMap = { 'settings.ai.localOllama': 'Локально (Ollama)', 'settings.ai.modelLabel': 'Модель', 'settings.ai.noCustomProviders': 'Нет пользовательских провайдеров', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Провайдер', 'settings.ai.routing': 'Маршрутизация', 'settings.ai.routingCustom': 'Пользовательская маршрутизация', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 3f2f6d737..700295dd0 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -291,6 +291,18 @@ const zhCN4: TranslationMap = { 'settings.ai.localOllama': '本地(Ollama)', 'settings.ai.modelLabel': '模型', 'settings.ai.noCustomProviders': '未配置自定义提供商', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': '提供商', 'settings.ai.routing': '路由', 'settings.ai.routingCustom': '自定义路由', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b666e3271..aba5dba94 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1703,6 +1703,18 @@ const en: TranslationMap = { 'settings.ai.localOllama': 'Local (Ollama)', 'settings.ai.modelLabel': 'Model', 'settings.ai.noCustomProviders': 'No custom providers', + 'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer ', + 'settings.ai.openAiCompat.authHeaderLabel': 'Auth header', + 'settings.ai.openAiCompat.baseUrlLabel': 'Base URL', + 'settings.ai.openAiCompat.baseUrlUnavailable': 'Unavailable', + 'settings.ai.openAiCompat.clearKey': 'Clear key', + 'settings.ai.openAiCompat.description': + "Point local harnesses at this /v1 server to route through the providers configured below. Authentication uses a stable key you set here, not the app's internal core bearer.", + 'settings.ai.openAiCompat.keyConfigured': 'Key configured', + 'settings.ai.openAiCompat.keyRequired': 'Key required', + 'settings.ai.openAiCompat.rotateKey': 'Rotate key', + 'settings.ai.openAiCompat.setKey': 'Set key', + 'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint', 'settings.ai.providerLabel': 'Provider', 'settings.ai.routing': 'Routing', 'settings.ai.routingCustom': 'Custom routing', diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 0253ea9c4..71736b400 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -11,10 +11,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type AISettings, clearCloudProviderKey, + clearOpenAICompatEndpointKey, flushCloudProviders, listProviderModels, loadAISettings, loadLocalProviderSnapshot, + loadOpenAICompatEndpointStatus, localProvider, parseProviderString, type ProviderRef, @@ -22,6 +24,7 @@ import { serializeProviderRef, setCloudProviderKey, setLocalRuntimeEnabled, + setOpenAICompatEndpointKey, } from '../aiSettingsApi'; // ─── Mock declarations (must be hoisted before imports) ─────────────────────── @@ -33,13 +36,17 @@ const mockOpenhumanUpdateLocalAiSettings = vi.fn(); const mockAuthStoreProviderCredentials = vi.fn(); const mockAuthRemoveProviderCredentials = vi.fn(); const mockCallCoreRpc = vi.fn(); +const mockGetCoreHttpBaseUrl = vi.fn(); const mockIsTauri = vi.fn(() => true); const mockOpenhumanLocalAiStatus = vi.fn(); const mockOpenhumanLocalAiDiagnostics = vi.fn(); const mockOpenhumanLocalAiPresets = vi.fn(); const mockOpenhumanLocalAiApplyPreset = vi.fn(); -vi.mock('../../coreRpcClient', () => ({ callCoreRpc: (a: unknown) => mockCallCoreRpc(a) })); +vi.mock('../../coreRpcClient', () => ({ + callCoreRpc: (a: unknown) => mockCallCoreRpc(a), + getCoreHttpBaseUrl: () => mockGetCoreHttpBaseUrl(), +})); vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: () => mockIsTauri(), @@ -458,6 +465,34 @@ describe('loadAISettings', () => { }); }); +describe('loadOpenAICompatEndpointStatus', () => { + beforeEach(() => { + mockGetCoreHttpBaseUrl.mockReset(); + mockAuthListProviderCredentials.mockReset(); + }); + + it('returns the local /v1 base URL and configured-key status', async () => { + mockGetCoreHttpBaseUrl.mockResolvedValue('http://127.0.0.1:7788'); + mockAuthListProviderCredentials.mockResolvedValue( + makeAuthProfileResult([{ id: 'prof-external', provider: 'external-openai-compat' }]) + ); + + const status = await loadOpenAICompatEndpointStatus(); + + expect(mockAuthListProviderCredentials).toHaveBeenCalledWith('external-openai-compat'); + expect(status).toEqual({ baseUrl: 'http://127.0.0.1:7788/v1', has_api_key: true }); + }); + + it('degrades gracefully when URL resolution or auth-list lookup fails', async () => { + mockGetCoreHttpBaseUrl.mockRejectedValue(new Error('unavailable')); + mockAuthListProviderCredentials.mockRejectedValue(new Error('no profiles file')); + + const status = await loadOpenAICompatEndpointStatus(); + + expect(status).toEqual({ baseUrl: null, has_api_key: false }); + }); +}); + describe('local provider facade', () => { beforeEach(() => { mockOpenhumanUpdateLocalAiSettings.mockReset(); @@ -692,6 +727,35 @@ describe('clearCloudProviderKey', () => { }); }); +describe('OpenAI-compatible endpoint key helpers', () => { + beforeEach(() => { + mockAuthStoreProviderCredentials.mockReset(); + mockAuthStoreProviderCredentials.mockResolvedValue({ result: {} }); + mockAuthRemoveProviderCredentials.mockReset(); + mockAuthRemoveProviderCredentials.mockResolvedValue({ result: { removed: true } }); + }); + + it('stores the endpoint bearer under the dedicated provider id', async () => { + await setOpenAICompatEndpointKey('router-key'); + + expect(mockAuthStoreProviderCredentials).toHaveBeenCalledWith({ + provider: 'external-openai-compat', + profile: 'default', + token: 'router-key', + setActive: true, + }); + }); + + it('clears the endpoint bearer under the dedicated provider id', async () => { + await clearOpenAICompatEndpointKey(); + + expect(mockAuthRemoveProviderCredentials).toHaveBeenCalledWith({ + provider: 'external-openai-compat', + profile: 'default', + }); + }); +}); + // ─── listProviderModels ─────────────────────────────────────────────────────── describe('listProviderModels', () => { diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index e801641c2..0f6c5a5f0 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -15,7 +15,7 @@ * through this file. Keeps the wiring testable and the panel focused on * presentation. */ -import { callCoreRpc } from '../../services/coreRpcClient'; +import { callCoreRpc, getCoreHttpBaseUrl } from '../../services/coreRpcClient'; import { authListProviderCredentials, type AuthProfileSummary, @@ -119,6 +119,11 @@ export interface AISettings { routing: Record; } +export interface OpenAICompatEndpointStatus { + baseUrl: string | null; + has_api_key: boolean; +} + // ─── Read path: load + parse ─────────────────────────────────────────────── /** @@ -175,6 +180,10 @@ function authKeyForSlug(slug: string): string { return `provider:${slug}`; } +function openAiCompatAuthProvider(): string { + return 'external-openai-compat'; +} + /** * Loads the full AI settings view by joining: * - the core's client-config snapshot (cloud_providers + *_provider fields) @@ -220,6 +229,23 @@ export async function loadAISettings(): Promise { return { cloudProviders, routing }; } +export async function loadOpenAICompatEndpointStatus(): Promise { + const [baseUrl, profilesRes] = await Promise.all([ + getCoreHttpBaseUrl() + .then(url => `${url.replace(/\/$/, '')}/v1`) + .catch((): string | null => null), + authListProviderCredentials(openAiCompatAuthProvider()).catch( + (): { result: AuthProfileSummary[] } => ({ result: [] }) + ), + ]); + + const has_api_key = profilesRes.result.some( + profile => profile.provider.toLowerCase() === openAiCompatAuthProvider() + ); + + return { baseUrl, has_api_key }; +} + // ─── Write path: diff + save ─────────────────────────────────────────────── /** @@ -300,6 +326,19 @@ export async function clearCloudProviderKey(slug: string): Promise { await authRemoveProviderCredentials({ provider: authKeyForSlug(slug), profile: 'default' }); } +export async function setOpenAICompatEndpointKey(apiKey: string): Promise { + await authStoreProviderCredentials({ + provider: openAiCompatAuthProvider(), + profile: 'default', + token: apiKey, + setActive: true, + }); +} + +export async function clearOpenAICompatEndpointKey(): Promise { + await authRemoveProviderCredentials({ provider: openAiCompatAuthProvider(), profile: 'default' }); +} + /** * Eagerly write the cloud_providers list to the core config. * diff --git a/src/core/auth.rs b/src/core/auth.rs index a1498d11e..e85b8a02c 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -31,14 +31,18 @@ //! headers, so the FE forwards the bearer as a query param. Validated //! against the same in-process RPC token — no separate secret. //! -//! Only `POST /rpc` carries executable commands and requires the bearer token. +//! Executable surfaces: +//! - `POST /rpc` requires the per-launch core bearer token. +//! - `GET /v1/models` and `POST /v1/chat/completions` accept either that +//! internal bearer or a stable user-managed external API key stored under +//! `openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER`. use std::io::Write as _; use std::path::Path; use std::sync::OnceLock; #[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::os::unix::fs::OpenOptionsExt as _; use axum::http::{header, Method, StatusCode}; use axum::middleware::Next; @@ -46,12 +50,16 @@ use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; +use crate::openhuman::config::Config; +use crate::openhuman::credentials::AuthService; +use crate::openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER; + static RPC_TOKEN: OnceLock = OnceLock::new(); /// Paths that bypass bearer-token authentication. /// -/// Only `/rpc` carries executable commands and must be protected. All other -/// routes are read-only, streaming, or WebSocket upgrades whose clients +/// `/rpc` and `/v1/*` carry executable surfaces and must be protected. All +/// other routes are read-only, streaming, or WebSocket upgrades whose clients /// (browser `EventSource`, browser `WebSocket`) cannot set `Authorization` /// headers via standard APIs. const PUBLIC_PATHS: &[&str] = &[ @@ -159,8 +167,9 @@ pub fn verify_bearer_token(supplied: &str) -> bool { /// endpoints. /// /// Public paths (see [`PUBLIC_PATHS`]) and CORS preflight `OPTIONS` requests -/// bypass this check. All other requests must carry the exact bearer token -/// that was written to `core.token` at startup. +/// bypass this check. `/rpc` requires the exact per-launch bearer token that +/// was written to `core.token` at startup; `/v1/*` additionally accepts a +/// stable user-managed external API key. pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Response { let path = req.uri().path().to_string(); @@ -196,6 +205,11 @@ pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Res return next.run(req).await; } + if is_external_inference_path(&path) && verify_external_inference_bearer(header_token).await { + log::trace!("[auth] authorized request to {path} (external inference bearer)"); + return next.run(req).await; + } + // Header path failed — fall back to `?token=…` for SSE/WS routes whose // browser clients cannot set headers. The query token is validated // against the same in-process RPC bearer (single source of truth), so @@ -228,6 +242,42 @@ fn bearer_matches(supplied: &str, expected: &str) -> bool { !supplied.is_empty() && supplied == expected } +fn is_external_inference_path(path: &str) -> bool { + path == "/v1" || path.starts_with("/v1/") +} + +fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) -> bool { + if supplied.trim().is_empty() { + return false; + } + + let auth = AuthService::from_config(config); + match auth.get_provider_bearer_token(EXTERNAL_OPENAI_COMPAT_PROVIDER, None) { + Ok(Some(expected)) => bearer_matches(supplied, expected.trim()), + Ok(None) => false, + Err(err) => { + log::warn!("[auth] failed to read external inference bearer: {err}"); + false + } + } +} + +async fn verify_external_inference_bearer(supplied: &str) -> bool { + if supplied.trim().is_empty() { + return false; + } + + let config = match Config::load_or_init().await { + Ok(config) => config, + Err(err) => { + log::warn!("[auth] failed to load config for external inference bearer: {err}"); + return false; + } + }; + + verify_external_inference_bearer_for_config(&config, supplied) +} + /// Pull the first `token` query parameter out of a URL query string. /// /// Returns `None` when the query is absent, the key is missing, or the @@ -378,6 +428,8 @@ mod tests { #[cfg(unix)] #[test] fn token_file_has_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let tmp = std::env::temp_dir().join(format!("core-auth-perms-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); let path = tmp.join("core.token"); @@ -386,4 +438,39 @@ mod tests { assert_eq!(mode & 0o777, 0o600, "token file must be 0o600"); std::fs::remove_dir_all(&tmp).ok(); } + + #[test] + fn is_external_inference_path_matches_only_v1_routes() { + assert!(is_external_inference_path("/v1")); + assert!(is_external_inference_path("/v1/models")); + assert!(is_external_inference_path("/v1/chat/completions")); + assert!(!is_external_inference_path("/rpc")); + assert!(!is_external_inference_path("/v10/models")); + } + + #[test] + fn verify_external_inference_bearer_for_config_accepts_stored_key() { + let tmp = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.config_path = tmp.path().join("config.toml"); + + let auth = AuthService::from_config(&config); + auth.store_provider_token( + EXTERNAL_OPENAI_COMPAT_PROVIDER, + "default", + "external-test-key", + std::collections::HashMap::new(), + true, + ) + .unwrap(); + + assert!(verify_external_inference_bearer_for_config( + &config, + "external-test-key" + )); + assert!(!verify_external_inference_bearer_for_config( + &config, + "wrong-key" + )); + } } diff --git a/src/openhuman/inference/http/mod.rs b/src/openhuman/inference/http/mod.rs index 775ee9c35..9984bed9b 100644 --- a/src/openhuman/inference/http/mod.rs +++ b/src/openhuman/inference/http/mod.rs @@ -6,7 +6,17 @@ //! ```ignore //! .nest("/v1", crate::openhuman::inference::http::router()) //! ``` -//! It inherits the same bearer-token auth middleware that guards `/rpc`. +//! It inherits the core bearer-token auth middleware, but `/v1/*` also accepts +//! a stable user-managed external API key so local harnesses can treat +//! OpenHuman like an OpenAI-compatible router. + +/// Auth-profile provider id used for the stable external bearer that guards +/// the OpenAI-compatible `/v1/*` endpoint. +/// +/// The value is stored through the existing credentials/auth RPC surface and +/// resolved from `auth-profiles.json` on each external request. This keeps the +/// secret encrypted at rest and scoped to the active user workspace. +pub const EXTERNAL_OPENAI_COMPAT_PROVIDER: &str = "external-openai-compat"; pub mod server; pub mod types; diff --git a/src/openhuman/inference/http/server.rs b/src/openhuman/inference/http/server.rs index 5c4f877de..66ff16a25 100644 --- a/src/openhuman/inference/http/server.rs +++ b/src/openhuman/inference/http/server.rs @@ -8,9 +8,13 @@ //! //! ## Authentication //! -//! All routes require `Authorization: Bearer ` — the -//! same per-launch token used by the JSON-RPC endpoint. Missing or wrong -//! tokens get a `401 Unauthorized` from the shared middleware. +//! All routes accept `Authorization: Bearer `, but the token may be +//! either: +//! - the per-launch `OPENHUMAN_CORE_TOKEN` used by the desktop shell, or +//! - a stable user-managed external API key stored under +//! `EXTERNAL_OPENAI_COMPAT_PROVIDER` for local harnesses. +//! +//! Missing or wrong tokens get a `401 Unauthorized` from the shared middleware. //! //! ## Provider routing //! @@ -26,6 +30,7 @@ use axum::routing::{get, post}; use axum::{extract::State, Json, Router}; use futures_util::stream::{self, StreamExt}; use serde_json::json; +use std::collections::HashSet; use tracing::{debug, error}; use crate::core::types::AppState; @@ -259,27 +264,89 @@ async fn models_handler(State(_state): State) -> Response { let created = chrono::Utc::now().timestamp(); let mut data: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let mut push_model = |id: String, owned_by: String| { + if seen.insert(id.clone()) { + data.push(ModelObject { + id, + object: "model", + created, + owned_by, + }); + } + }; + + // Stable managed-router sentinel for callers that want OpenHuman to keep + // selecting the effective upstream model based on the current routing config. + push_model("openhuman".to_string(), "openhuman".to_string()); + + if let Some(default_model) = config + .default_model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + push_model( + strip_temperature_suffix(default_model).to_string(), + "openhuman".to_string(), + ); + } // Cloud provider default models for cp in &config.cloud_providers { if let Some(ref model) = cp.default_model { - data.push(ModelObject { - id: format!("{}:{}", cp.slug, model), - object: "model", - created, - owned_by: cp.slug.clone(), - }); + push_model( + format!("{}:{}", cp.slug, strip_temperature_suffix(model)), + cp.slug.clone(), + ); } } // Configured local chat model (Ollama) if !config.local_ai.chat_model_id.is_empty() { - data.push(ModelObject { - id: format!("ollama:{}", config.local_ai.chat_model_id), - object: "model", - created, - owned_by: "ollama".to_string(), - }); + push_model( + format!("ollama:{}", config.local_ai.chat_model_id), + "ollama".to_string(), + ); + } + + for provider_string in [ + config.chat_provider.as_deref(), + config.reasoning_provider.as_deref(), + config.agentic_provider.as_deref(), + config.coding_provider.as_deref(), + config.memory_provider.as_deref(), + config.embeddings_provider.as_deref(), + config.heartbeat_provider.as_deref(), + config.learning_provider.as_deref(), + config.subconscious_provider.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "cloud") + { + if provider_string == "openhuman" { + continue; + } + + if let Some(model) = provider_string.strip_prefix("ollama:") { + push_model( + format!("ollama:{}", strip_temperature_suffix(model)), + "ollama".to_string(), + ); + continue; + } + + if let Some((slug, model)) = provider_string.split_once(':') { + if slug != "openhuman" { + push_model( + format!("{}:{}", slug, strip_temperature_suffix(model)), + slug.to_string(), + ); + } + } } debug!(model_count = data.len(), "{LOG_PREFIX} models: ok"); @@ -293,6 +360,18 @@ async fn models_handler(State(_state): State) -> Response { .into_response() } +pub(crate) fn strip_temperature_suffix(model: &str) -> &str { + let trimmed = model.trim(); + let Some((head, tail)) = trimmed.rsplit_once('@') else { + return trimmed; + }; + if tail.parse::().is_ok() { + head.trim() + } else { + trimmed + } +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/src/openhuman/inference/http/tests.rs b/src/openhuman/inference/http/tests.rs index 987ceaffd..52682e933 100644 --- a/src/openhuman/inference/http/tests.rs +++ b/src/openhuman/inference/http/tests.rs @@ -15,6 +15,7 @@ use tower::ServiceExt; use crate::core::auth::CORE_TOKEN_ENV_VAR; use crate::core::jsonrpc::build_core_http_router; +use crate::openhuman::inference::http::server::strip_temperature_suffix; const TEST_RPC_TOKEN: &str = "inference-http-tests-token"; @@ -122,3 +123,10 @@ async fn test_chat_completions_with_bearer_not_rejected_as_auth_error() { "403 must not fire when bearer is present" ); } + +#[test] +fn strip_temperature_suffix_only_removes_numeric_suffixes() { + assert_eq!(strip_temperature_suffix("gpt-4o@0.7"), "gpt-4o"); + assert_eq!(strip_temperature_suffix("llama3.1:8b@1"), "llama3.1:8b"); + assert_eq!(strip_temperature_suffix("gpt@beta"), "gpt@beta"); +}