Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions client/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 84 additions & 5 deletions client/src/pages/KeysPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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 (
<div>
<PageHeader
Expand All @@ -537,6 +560,56 @@ export default function KeysPage() {
}
/>

{showBanner && (
<div className="mb-6 rounded-2xl border border-primary/30 bg-primary/5 px-4 py-3 text-xs">
<div className="mb-2 text-foreground">
{t('keys.unconfiguredTitle', { count: bannerProviders.length })} —{' '}
{bannerProviders.map(p => t('keys.unconfiguredModelCount', { name: p.name, count: p.models })).join(', ')}.
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger className={buttonVariants({ variant: 'outline', size: 'sm' })}>
{t('keys.nudgeAddKey')}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
{bannerProviders.map(p => (
<DropdownMenuItem
key={p.platform}
onClick={() => {
setPlatform(p.platform as Platform)
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' })
}}
>
{p.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger className={buttonVariants({ variant: 'ghost', size: 'sm' })}>
{t('keys.nudgeDismiss')}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuItem onClick={() => dismissNudge.mutate({ scope: 'snooze' })}>
{t('keys.nudgeSnooze')}
</DropdownMenuItem>
{bannerProviders.map(p => (
<DropdownMenuItem
key={p.platform}
onClick={() => dismissNudge.mutate({ scope: 'mute', platform: p.platform })}
>
{t('keys.nudgeMute', { name: p.name })}
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => dismissNudge.mutate({ scope: 'disable' })}>
{t('keys.nudgeDisable')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}

<div className="space-y-8">
<UnifiedKeySection />

Expand All @@ -551,10 +624,16 @@ export default function KeysPage() {
<SelectTrigger className="w-[220px]">
<SelectValue placeholder={t('keys.selectPlatform')} />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
<SelectContent className="w-auto min-w-[220px]">
{PLATFORMS.map(p => {
const models = unconfiguredByPlatform.get(p.value)
return (
<SelectItem key={p.value} value={p.value}>
{p.label}
{models ? ` ${t('keys.dropdownNoKeyHint', { count: models })}` : ''}
</SelectItem>
)
})}
</SelectContent>
</Select>
{(() => {
Expand Down
84 changes: 84 additions & 0 deletions server/src/__tests__/routes/provider-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
82 changes: 82 additions & 0 deletions server/src/__tests__/services/provider-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
3 changes: 3 additions & 0 deletions server/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -51,6 +52,8 @@ healthRouter.get('/', (_req: Request, res: Response) => {
createdAt: k.created_at,
lastCheckedAt: k.last_checked_at,
})),
unconfiguredProviders: getUnconfiguredProviders(),
nudgeState: getNudgeState(),
});
});

Expand Down
24 changes: 24 additions & 0 deletions server/src/routes/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading