feat(inference): OpenAI-compatible /v1 router with user-managed API key (#2523)

This commit is contained in:
Steven Enamakel
2026-05-23 08:33:33 -07:00
committed by GitHub
parent 7745d58945
commit f8c9698ecc
23 changed files with 669 additions and 24 deletions
+1
View File
@@ -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).
---
+1
View File
@@ -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,
+12
View File
@@ -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': 'توجيه مخصص',
+12
View File
@@ -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': 'কাস্টম রুটিং',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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é',
+12
View File
@@ -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': 'कस्टम रूटिंग',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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': 'Пользовательская маршрутизация',
+12
View File
@@ -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': '自定义路由',
+12
View File
@@ -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', () => {
+40 -1
View File
@@ -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
View File
@@ -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"
));
}
}
+11 -1
View File
@@ -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;
+94 -15
View File
@@ -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;
+8
View File
@@ -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");
}