mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(inference): OpenAI-compatible /v1 router with user-managed API key (#2523)
This commit is contained in:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<WorkloadId | null>(null);
|
||||
// Which provider slug's API-key dialog is currently open (null = closed).
|
||||
const [keyDialogFor, setKeyDialogFor] = useState<string | null>(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<string | null>(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 = {}) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-stone-50/80 dark:bg-neutral-900/70 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.ai.openAiCompat.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.ai.openAiCompat.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ${
|
||||
openAiCompatStatus.has_api_key
|
||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
|
||||
: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
|
||||
}`}>
|
||||
{openAiCompatStatus.has_api_key
|
||||
? t('settings.ai.openAiCompat.keyConfigured')
|
||||
: t('settings.ai.openAiCompat.keyRequired')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
</button>
|
||||
{openAiCompatStatus.has_api_key ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.ai.openAiCompat.baseUrlLabel')}
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={
|
||||
openAiCompatStatus.baseUrl ?? t('settings.ai.openAiCompat.baseUrlUnavailable')
|
||||
}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.ai.openAiCompat.authHeaderLabel')}
|
||||
</label>
|
||||
<div className="mt-1 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-700 dark:text-neutral-300">
|
||||
{t('settings.ai.openAiCompat.authHeaderExample')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Provider chip-toggle list ────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
{loading && (
|
||||
@@ -2378,6 +2484,25 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{openAiCompatDialogOpen && (
|
||||
<ProviderKeyDialog
|
||||
slug="external-openai-compat"
|
||||
label={t('settings.ai.openAiCompat.title')}
|
||||
isLocalRuntime={false}
|
||||
onCancel={() => 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 && (
|
||||
<ProviderKeyDialog
|
||||
slug={keyDialogFor}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listConnections as listComposioConnections } from '../../../../lib/composio/composioApi';
|
||||
import {
|
||||
clearOpenAICompatEndpointKey,
|
||||
listProviderModels,
|
||||
loadAISettings,
|
||||
loadLocalProviderSnapshot,
|
||||
loadOpenAICompatEndpointStatus,
|
||||
saveAISettings,
|
||||
setCloudProviderKey,
|
||||
setOpenAICompatEndpointKey,
|
||||
} from '../../../../services/api/aiSettingsApi';
|
||||
import { creditsApi } from '../../../../services/api/creditsApi';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
@@ -33,8 +36,11 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
'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(<AIPanel />);
|
||||
|
||||
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(<AIPanel />);
|
||||
|
||||
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(<AIPanel />);
|
||||
|
||||
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(<AIPanel />);
|
||||
|
||||
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(<AIPanel />);
|
||||
|
||||
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(<AIPanel />);
|
||||
// The OpenHuman label now appears in multiple places (provider card,
|
||||
|
||||
@@ -295,6 +295,18 @@ const ar4: TranslationMap = {
|
||||
'settings.ai.localOllama': 'محلي (Ollama)',
|
||||
'settings.ai.modelLabel': 'النموذج',
|
||||
'settings.ai.noCustomProviders': 'لا يوجد مزودون مخصصون',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'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': 'توجيه مخصص',
|
||||
|
||||
@@ -297,6 +297,18 @@ const bn4: TranslationMap = {
|
||||
'settings.ai.localOllama': 'লোকাল (Ollama)',
|
||||
'settings.ai.modelLabel': 'মডেল',
|
||||
'settings.ai.noCustomProviders': 'কোনো কাস্টম প্রোভাইডার নেই',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'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': 'কাস্টম রুটিং',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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é',
|
||||
|
||||
@@ -298,6 +298,18 @@ const hi4: TranslationMap = {
|
||||
'settings.ai.localOllama': 'लोकल (Ollama)',
|
||||
'settings.ai.modelLabel': 'मॉडल',
|
||||
'settings.ai.noCustomProviders': 'कोई कस्टम प्रोवाइडर नहीं',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'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': 'कस्टम रूटिंग',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -297,6 +297,18 @@ const ru4: TranslationMap = {
|
||||
'settings.ai.localOllama': 'Локально (Ollama)',
|
||||
'settings.ai.modelLabel': 'Модель',
|
||||
'settings.ai.noCustomProviders': 'Нет пользовательских провайдеров',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'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': 'Пользовательская маршрутизация',
|
||||
|
||||
@@ -291,6 +291,18 @@ const zhCN4: TranslationMap = {
|
||||
'settings.ai.localOllama': '本地(Ollama)',
|
||||
'settings.ai.modelLabel': '模型',
|
||||
'settings.ai.noCustomProviders': '未配置自定义提供商',
|
||||
'settings.ai.openAiCompat.authHeaderExample': 'Authorization: Bearer <your key>',
|
||||
'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': '自定义路由',
|
||||
|
||||
@@ -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 <your key>',
|
||||
'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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<WorkloadId, ProviderRef>;
|
||||
}
|
||||
|
||||
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<AISettings> {
|
||||
return { cloudProviders, routing };
|
||||
}
|
||||
|
||||
export async function loadOpenAICompatEndpointStatus(): Promise<OpenAICompatEndpointStatus> {
|
||||
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<void> {
|
||||
await authRemoveProviderCredentials({ provider: authKeyForSlug(slug), profile: 'default' });
|
||||
}
|
||||
|
||||
export async function setOpenAICompatEndpointKey(apiKey: string): Promise<void> {
|
||||
await authStoreProviderCredentials({
|
||||
provider: openAiCompatAuthProvider(),
|
||||
profile: 'default',
|
||||
token: apiKey,
|
||||
setActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearOpenAICompatEndpointKey(): Promise<void> {
|
||||
await authRemoveProviderCredentials({ provider: openAiCompatAuthProvider(), profile: 'default' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly write the cloud_providers list to the core config.
|
||||
*
|
||||
|
||||
+93
-6
@@ -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<String> = 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
//!
|
||||
//! ## Authentication
|
||||
//!
|
||||
//! All routes require `Authorization: Bearer <OPENHUMAN_CORE_TOKEN>` — 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 <token>`, 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<AppState>) -> Response {
|
||||
|
||||
let created = chrono::Utc::now().timestamp();
|
||||
let mut data: Vec<ModelObject> = Vec::new();
|
||||
let mut seen: HashSet<String> = 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<AppState>) -> 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::<f64>().is_ok() {
|
||||
head.trim()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user