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))); +}