diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 9e6f28495..6cf3b58cb 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -180,6 +180,14 @@
"enable": "Enable",
"noProviderKeys": "No provider keys yet. Add one above to start routing.",
"configuredProviders": "Configured providers",
+ "unconfiguredTitle": "{count} providers have free models you're not using",
+ "unconfiguredModelCount": "{name} ({count})",
+ "dropdownNoKeyHint": "· {count} free models, no key",
+ "nudgeAddKey": "Add key",
+ "nudgeDismiss": "Dismiss",
+ "nudgeSnooze": "Snooze until a new provider appears",
+ "nudgeMute": "Don't show for {name}",
+ "nudgeDisable": "Don't ask again",
"proxyToggleLabel": "proxy",
"keyCountOne": "{count} key",
"keyCountOther": "{count} keys",
diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx
index 64dc2a7ed..a1cd589ee 100644
--- a/client/src/pages/KeysPage.tsx
+++ b/client/src/pages/KeysPage.tsx
@@ -1,11 +1,12 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
-import { Button } from '@/components/ui/button'
+import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { PageHeader } from '@/components/page-header'
import type { ApiKey, Platform } from '../../../shared/types'
@@ -91,9 +92,14 @@ interface HealthPlatform {
unknownKeys: number
}
+interface UnconfiguredProvider { platform: string; name: string; models: number }
+interface NudgeState { disabled: boolean; muted: string[]; snoozed: string[] }
+
interface HealthData {
platforms: HealthPlatform[]
keys: { id: number; platform: string; status: string; lastCheckedAt: string | null }[]
+ unconfiguredProviders?: UnconfiguredProvider[]
+ nudgeState?: NudgeState
}
function UnifiedKeySection() {
@@ -412,6 +418,12 @@ export default function KeysPage() {
},
})
+ const dismissNudge = useMutation({
+ mutationFn: (body: { scope: 'snooze' | 'mute' | 'disable'; platform?: string }) =>
+ apiFetch('/api/keys/nudge', { method: 'POST', body: JSON.stringify(body) }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['health'] }),
+ })
+
const deleteKey = useMutation({
mutationFn: (id: number) => apiFetch(`/api/keys/${id}`, { method: 'DELETE' }),
onSuccess: () => {
@@ -523,6 +535,17 @@ export default function KeysPage() {
keys: keys.filter(k => k.platform === p.value),
})).filter(p => p.keys.length > 0)
+ // Unconfigured-provider nudge. The raw list drives the permanent dropdown hint
+ // (survives "Don't ask again"); the banner shows the dismiss-filtered subset.
+ const nudge = healthData?.nudgeState
+ const unconfiguredByPlatform = new Map(
+ (healthData?.unconfiguredProviders ?? []).map(p => [p.platform, p.models]),
+ )
+ const bannerProviders = (healthData?.unconfiguredProviders ?? []).filter(
+ p => !nudge?.muted.includes(p.platform) && !nudge?.snoozed.includes(p.platform),
+ )
+ const showBanner = !nudge?.disabled && bannerProviders.length > 0
+
return (
+ {showBanner && (
+
+
+ {t('keys.unconfiguredTitle', { count: bannerProviders.length })} —{' '}
+ {bannerProviders.map(p => t('keys.unconfiguredModelCount', { name: p.name, count: p.models })).join(', ')}.
+
+
+
+
+ {t('keys.nudgeAddKey')}
+
+
+ {bannerProviders.map(p => (
+ {
+ setPlatform(p.platform as Platform)
+ document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' })
+ }}
+ >
+ {p.name}
+
+ ))}
+
+
+
+
+ {t('keys.nudgeDismiss')}
+
+
+ dismissNudge.mutate({ scope: 'snooze' })}>
+ {t('keys.nudgeSnooze')}
+
+ {bannerProviders.map(p => (
+ dismissNudge.mutate({ scope: 'mute', platform: p.platform })}
+ >
+ {t('keys.nudgeMute', { name: p.name })}
+
+ ))}
+ dismissNudge.mutate({ scope: 'disable' })}>
+ {t('keys.nudgeDisable')}
+
+
+
+
+
+ )}
+
@@ -551,10 +624,16 @@ export default function KeysPage() {
-
- {PLATFORMS.map(p => (
- {p.label}
- ))}
+
+ {PLATFORMS.map(p => {
+ const models = unconfiguredByPlatform.get(p.value)
+ return (
+
+ {p.label}
+ {models ? ` ${t('keys.dropdownNoKeyHint', { count: models })}` : ''}
+
+ )
+ })}
{(() => {
diff --git a/server/src/__tests__/routes/provider-nudge.test.ts b/server/src/__tests__/routes/provider-nudge.test.ts
new file mode 100644
index 000000000..828e6a258
--- /dev/null
+++ b/server/src/__tests__/routes/provider-nudge.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
+import type { Express } from 'express';
+import { createApp } from '../../app.js';
+import { initDb, getDb } from '../../db/index.js';
+import { mintDashboardToken, isGatedApiPath } from '../helpers/auth.js';
+
+let dashToken = '';
+
+async function request(app: Express, method: string, path: string, body?: any) {
+ const server = app.listen(0);
+ const addr = server.address() as any;
+ const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, {
+ method,
+ headers: {
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
+ ...(isGatedApiPath(path) ? { Authorization: `Bearer ${dashToken}` } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ server.close();
+ let json: any = null;
+ try { json = JSON.parse(text); } catch { /* non-JSON */ }
+ return { status: res.status, body: json };
+}
+
+describe('provider key-nudge routes', () => {
+ let app: Express;
+ beforeAll(() => {
+ process.env.ENCRYPTION_KEY = '0'.repeat(64);
+ initDb(':memory:');
+ app = createApp();
+ dashToken = mintDashboardToken();
+ });
+ beforeEach(() => {
+ const db = getDb();
+ db.prepare('DELETE FROM api_keys').run();
+ db.prepare("DELETE FROM settings WHERE key LIKE 'nudge_%'").run();
+ });
+
+ it('GET /api/health exposes raw unconfiguredProviders + nudgeState', async () => {
+ const { status, body } = await request(app, 'GET', '/api/health');
+ expect(status).toBe(200);
+ expect(Array.isArray(body.unconfiguredProviders)).toBe(true);
+ expect(body.unconfiguredProviders.some((p: any) => p.platform === 'groq')).toBe(true);
+ expect(body.nudgeState).toEqual({ disabled: false, muted: [], snoozed: [] });
+ });
+
+ it('POST /api/keys/nudge mute adds a platform; reflected in nudgeState', async () => {
+ const r = await request(app, 'POST', '/api/keys/nudge', { scope: 'mute', platform: 'groq' });
+ expect(r.status).toBe(200);
+ const { body } = await request(app, 'GET', '/api/health');
+ expect(body.nudgeState.muted).toContain('groq');
+ });
+
+ it('POST /api/keys/nudge disable sets the flag', async () => {
+ await request(app, 'POST', '/api/keys/nudge', { scope: 'disable' });
+ const { body } = await request(app, 'GET', '/api/health');
+ expect(body.nudgeState.disabled).toBe(true);
+ });
+
+ it('POST /api/keys/nudge mute without platform → 400', async () => {
+ const r = await request(app, 'POST', '/api/keys/nudge', { scope: 'mute' });
+ expect(r.status).toBe(400);
+ });
+
+ it('POST /api/keys/nudge with unknown scope → 400', async () => {
+ const r = await request(app, 'POST', '/api/keys/nudge', { scope: 'bogus' });
+ expect(r.status).toBe(400);
+ });
+
+ it('adding a key prunes the provider from snooze + drops it from unconfigured', async () => {
+ await request(app, 'POST', '/api/keys/nudge', { scope: 'snooze' });
+ let health = (await request(app, 'GET', '/api/health')).body;
+ expect(health.nudgeState.snoozed).toContain('groq');
+
+ const add = await request(app, 'POST', '/api/keys', { platform: 'groq', key: 'k_groq_nudge', label: 'x' });
+ expect(add.status).toBe(201);
+
+ health = (await request(app, 'GET', '/api/health')).body;
+ expect(health.nudgeState.snoozed).not.toContain('groq'); // pruned
+ expect(health.unconfiguredProviders.some((p: any) => p.platform === 'groq')).toBe(false); // now has a key
+ });
+});
diff --git a/server/src/__tests__/services/provider-nudge.test.ts b/server/src/__tests__/services/provider-nudge.test.ts
new file mode 100644
index 000000000..0ba702d47
--- /dev/null
+++ b/server/src/__tests__/services/provider-nudge.test.ts
@@ -0,0 +1,82 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { initDb, getDb } from '../../db/index.js';
+import {
+ getUnconfiguredProviders,
+ getNudgeState,
+ dismissNudge,
+ pruneNudgeState,
+} from '../../services/provider-nudge.js';
+
+describe('getUnconfiguredProviders', () => {
+ beforeEach(() => {
+ process.env.ENCRYPTION_KEY = '0'.repeat(64);
+ initDb(':memory:');
+ });
+
+ it('lists a provider with enabled models but no key', () => {
+ const list = getUnconfiguredProviders();
+ const groq = list.find(p => p.platform === 'groq');
+ expect(groq).toBeDefined();
+ expect(groq!.models).toBeGreaterThan(0);
+ expect(typeof groq!.name).toBe('string');
+ });
+
+ it('drops a provider once it has an enabled key', () => {
+ getDb().prepare(
+ "INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled) VALUES ('groq','x','x','x','x','unknown',1)",
+ ).run();
+ expect(getUnconfiguredProviders().some(p => p.platform === 'groq')).toBe(false);
+ });
+
+ it('excludes keyless providers and unroutable platforms', () => {
+ expect(getUnconfiguredProviders().some(p => p.platform === 'pollinations')).toBe(false);
+ getDb().prepare(
+ "INSERT INTO models (platform, model_id, display_name, intelligence_rank, speed_rank, enabled) VALUES ('bogus','m','M',1,1,1)",
+ ).run();
+ expect(getUnconfiguredProviders().some(p => p.platform === 'bogus')).toBe(false);
+ });
+});
+
+describe('nudge state', () => {
+ beforeEach(() => {
+ process.env.ENCRYPTION_KEY = '0'.repeat(64);
+ initDb(':memory:');
+ });
+
+ it('defaults to empty/disabled-false', () => {
+ expect(getNudgeState()).toEqual({ disabled: false, muted: [], snoozed: [] });
+ });
+
+ it('mute adds one platform; disable sets the flag', () => {
+ dismissNudge('mute', 'groq');
+ expect(getNudgeState().muted).toEqual(['groq']);
+ dismissNudge('disable');
+ expect(getNudgeState().disabled).toBe(true);
+ });
+
+ it('mute without a platform throws', () => {
+ expect(() => dismissNudge('mute')).toThrow();
+ });
+
+ it('snooze snapshots raw-unconfigured minus muted', () => {
+ dismissNudge('mute', 'groq');
+ dismissNudge('snooze');
+ const { snoozed } = getNudgeState();
+ expect(snoozed).not.toContain('groq'); // excluded (muted)
+ expect(snoozed).toContain('cerebras'); // a seeded unconfigured provider
+ });
+
+ it('pruneNudgeState clears a platform from muted and snoozed', () => {
+ dismissNudge('mute', 'groq');
+ dismissNudge('snooze'); // snoozes cerebras et al.
+ pruneNudgeState('cerebras');
+ expect(getNudgeState().snoozed).not.toContain('cerebras');
+ pruneNudgeState('groq');
+ expect(getNudgeState().muted).not.toContain('groq');
+ });
+
+ it('corrupt settings JSON degrades to empty arrays', () => {
+ getDb().prepare("INSERT INTO settings (key, value) VALUES ('nudge_muted_platforms', 'not json')").run();
+ expect(getNudgeState().muted).toEqual([]);
+ });
+});
diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts
index 171ad8350..aa7c3a3ca 100644
--- a/server/src/routes/health.ts
+++ b/server/src/routes/health.ts
@@ -3,6 +3,7 @@ import type { Request, Response } from 'express';
import { getDb } from '../db/index.js';
import { checkKeyHealth, checkAllKeys } from '../services/health.js';
import { hasProvider } from '../providers/index.js';
+import { getUnconfiguredProviders, getNudgeState } from '../services/provider-nudge.js';
export const healthRouter = Router();
@@ -51,6 +52,8 @@ healthRouter.get('/', (_req: Request, res: Response) => {
createdAt: k.created_at,
lastCheckedAt: k.last_checked_at,
})),
+ unconfiguredProviders: getUnconfiguredProviders(),
+ nudgeState: getNudgeState(),
});
});
diff --git a/server/src/routes/keys.ts b/server/src/routes/keys.ts
index a1fdc2ecf..d6e852eb6 100644
--- a/server/src/routes/keys.ts
+++ b/server/src/routes/keys.ts
@@ -4,9 +4,31 @@ import { z } from 'zod';
import { getDb } from '../db/index.js';
import { resolveProvider } from '../providers/index.js';
import { encrypt, decrypt, maskKey } from '../lib/crypto.js';
+import { dismissNudge, pruneNudgeState } from '../services/provider-nudge.js';
export const keysRouter = Router();
+const nudgeSchema = z.object({
+ scope: z.enum(['snooze', 'mute', 'disable']),
+ platform: z.string().min(1).optional(),
+});
+
+// Dismiss the unconfigured-provider nudge. Body: { scope, platform? }.
+keysRouter.post('/nudge', (req: Request, res: Response) => {
+ const parsed = nudgeSchema.safeParse(req.body);
+ if (!parsed.success) {
+ res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } });
+ return;
+ }
+ const { scope, platform } = parsed.data;
+ if (scope === 'mute' && !platform) {
+ res.status(400).json({ error: { message: 'platform is required to mute' } });
+ return;
+ }
+ dismissNudge(scope, platform);
+ res.json({ ok: true });
+});
+
// Active providers — must match providers/index.ts registrations + shared/types.ts Platform.
// Moonshot and MiniMax direct integrations were dropped in V4. HuggingFace
// was dropped in V4 and re-added in V13 via the router.huggingface.co route.
@@ -90,6 +112,7 @@ keysRouter.post('/', (req: Request, res: Response) => {
const existing = db.prepare('SELECT id FROM api_keys WHERE platform = ? LIMIT 1').get(platform) as { id: number } | undefined;
if (existing) {
db.prepare("UPDATE api_keys SET enabled = 1, status = 'unknown' WHERE id = ?").run(existing.id);
+ pruneNudgeState(platform);
res.status(200).json({
id: existing.id,
platform,
@@ -108,6 +131,7 @@ keysRouter.post('/', (req: Request, res: Response) => {
VALUES (?, ?, ?, ?, ?, 'unknown', 1)
`).run(platform, label ?? '', encrypted, iv, authTag);
+ pruneNudgeState(platform);
res.status(201).json({
id: result.lastInsertRowid,
platform,
diff --git a/server/src/services/provider-nudge.ts b/server/src/services/provider-nudge.ts
new file mode 100644
index 000000000..4b9df374b
--- /dev/null
+++ b/server/src/services/provider-nudge.ts
@@ -0,0 +1,92 @@
+import type { Platform } from '@freellmapi/shared/types.js';
+import { getDb, getSetting, setSetting } from '../db/index.js';
+import { getAllProviders } from '../providers/index.js';
+
+export interface UnconfiguredProvider {
+ platform: string;
+ name: string;
+ models: number;
+}
+
+/**
+ * Providers with at least one enabled model and no enabled key — the RAW list,
+ * with NO mute/snooze/disable filtering applied (that is banner-only display
+ * state, derived on the frontend). Excludes `custom`, keyless providers (they
+ * route without a key), and platforms this binary can't route.
+ */
+export function getUnconfiguredProviders(): UnconfiguredProvider[] {
+ const db = getDb();
+ const rows = db.prepare(`
+ SELECT m.platform AS platform, COUNT(*) AS models
+ FROM models m
+ WHERE m.enabled = 1 AND m.platform != 'custom'
+ AND NOT EXISTS (
+ SELECT 1 FROM api_keys k WHERE k.platform = m.platform AND k.enabled = 1
+ )
+ GROUP BY m.platform
+ `).all() as { platform: string; models: number }[];
+
+ const byPlatform = new Map(getAllProviders().map(p => [p.platform, p]));
+ const out: UnconfiguredProvider[] = [];
+ for (const r of rows) {
+ const provider = byPlatform.get(r.platform as Platform);
+ if (!provider) continue; // not routable by this binary
+ if (provider.keyless) continue; // routes without a key — nothing to nudge
+ out.push({ platform: r.platform, name: provider.name, models: r.models });
+ }
+ return out;
+}
+
+export interface NudgeState {
+ disabled: boolean;
+ muted: string[];
+ snoozed: string[];
+}
+
+const KEY_DISABLED = 'nudge_disabled';
+const KEY_MUTED = 'nudge_muted_platforms';
+const KEY_SNOOZED = 'nudge_snoozed_platforms';
+
+function readList(key: string): string[] {
+ const raw = getSetting(key);
+ if (!raw) return [];
+ try {
+ const v: unknown = JSON.parse(raw);
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
+ } catch {
+ return [];
+ }
+}
+
+export function getNudgeState(): NudgeState {
+ return {
+ disabled: getSetting(KEY_DISABLED) === '1',
+ muted: readList(KEY_MUTED),
+ snoozed: readList(KEY_SNOOZED),
+ };
+}
+
+export function dismissNudge(scope: 'snooze' | 'mute' | 'disable', platform?: string): void {
+ if (scope === 'disable') {
+ setSetting(KEY_DISABLED, '1');
+ return;
+ }
+ if (scope === 'mute') {
+ if (!platform) throw new Error('mute requires a platform');
+ const muted = new Set(readList(KEY_MUTED));
+ muted.add(platform);
+ setSetting(KEY_MUTED, JSON.stringify([...muted]));
+ return;
+ }
+ // snooze: snapshot the currently-shown set (raw unconfigured minus muted) so a
+ // later brand-new unconfigured provider is absent and re-triggers the banner.
+ const muted = new Set(readList(KEY_MUTED));
+ const shown = getUnconfiguredProviders().map(p => p.platform).filter(p => !muted.has(p));
+ setSetting(KEY_SNOOZED, JSON.stringify(shown));
+}
+
+/** Drop a platform from mute + snooze sets (called when its key is added). */
+export function pruneNudgeState(platform: string): void {
+ setSetting(KEY_MUTED, JSON.stringify(readList(KEY_MUTED).filter(p => p !== platform)));
+ setSetting(KEY_SNOOZED, JSON.stringify(readList(KEY_SNOOZED).filter(p => p !== platform)));
+}