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')}
+
+ setOpenAiCompatDialogOpen(true)}
+ disabled={openAiCompatBusy !== null}
+ className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
+ {openAiCompatStatus.has_api_key
+ ? t('settings.ai.openAiCompat.rotateKey')
+ : t('settings.ai.openAiCompat.setKey')}
+
+ {openAiCompatStatus.has_api_key ? (
+ {
+ setOpenAiCompatBusy('clear');
+ clearOpenAICompatEndpointKey()
+ .then(() => {
+ setOpenAiCompatStatus(prev => ({ ...prev, has_api_key: false }));
+ })
+ .catch(err => {
+ console.warn(
+ '[ai-settings] clearOpenAICompatEndpointKey failed',
+ err instanceof Error ? err.message : String(err)
+ );
+ })
+ .finally(() => setOpenAiCompatBusy(null));
+ }}
+ disabled={openAiCompatBusy !== null}
+ className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-50">
+ {t('settings.ai.openAiCompat.clearKey')}
+
+ ) : null}
+
+
+
+
+
+
+ {t('settings.ai.openAiCompat.baseUrlLabel')}
+
+
+
+
+
+ {t('settings.ai.openAiCompat.authHeaderLabel')}
+
+
+ {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");
+}