remote provider health checks + compact display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+91
-28
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Save, Trash2, RefreshCw, Loader2, X } from 'lucide-react';
|
import { Plus, Save, Trash2, RefreshCw, Loader2, X, Pencil } from 'lucide-react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -112,6 +112,13 @@ export const AIHarnessesSection = () => {
|
|||||||
const connectedProviders = PI_PROVIDERS.filter((p) => storedPiIds.has(p.piId));
|
const connectedProviders = PI_PROVIDERS.filter((p) => storedPiIds.has(p.piId));
|
||||||
const unconnectedProviders = PI_PROVIDERS.filter((p) => !storedPiIds.has(p.piId));
|
const unconnectedProviders = PI_PROVIDERS.filter((p) => !storedPiIds.has(p.piId));
|
||||||
|
|
||||||
|
const { data: remoteHealth = {} as Record<string, boolean | null> } = useQuery({
|
||||||
|
queryKey: ['PI_MONO_REMOTE_HEALTH'],
|
||||||
|
queryFn: () => client.get<Record<string, boolean | null>>('/server-settings/pi-mono/api-keys/health'),
|
||||||
|
enabled: connectedProviders.length > 0,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
const getStoredMasked = (piId: string) =>
|
const getStoredMasked = (piId: string) =>
|
||||||
storedKeys.find((k) => k.provider === piId)?.value ?? '';
|
storedKeys.find((k) => k.provider === piId)?.value ?? '';
|
||||||
|
|
||||||
@@ -122,12 +129,14 @@ export const AIHarnessesSection = () => {
|
|||||||
try {
|
try {
|
||||||
await client.put('/server-settings/pi-mono/api-keys', { provider: piId, value });
|
await client.put('/server-settings/pi-mono/api-keys', { provider: piId, value });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
||||||
setKeyInputs((prev) => {
|
setKeyInputs((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[piId];
|
delete next[piId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setEditingProvider(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null);
|
setSavingKey(null);
|
||||||
}
|
}
|
||||||
@@ -136,6 +145,7 @@ export const AIHarnessesSection = () => {
|
|||||||
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
|
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
|
||||||
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
|
await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
|
||||||
if (editingProvider === provider.key) setEditingProvider(null);
|
if (editingProvider === provider.key) setEditingProvider(null);
|
||||||
};
|
};
|
||||||
@@ -422,37 +432,79 @@ export const AIHarnessesSection = () => {
|
|||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
{connectedProviders.map((provider) => (
|
{connectedProviders.map((provider) => (
|
||||||
<div key={provider.piId} className="flex items-center gap-2">
|
<div key={provider.piId} className="flex flex-col gap-2">
|
||||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<span
|
||||||
type="password"
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
|
||||||
className="h-7 text-xs flex-1"
|
remoteHealth[provider.piId] === true
|
||||||
placeholder={getStoredMasked(provider.piId) || 'Not set'}
|
? 'bg-green-500'
|
||||||
value={keyInputs[provider.piId] ?? ''}
|
: remoteHealth[provider.piId] === false
|
||||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
? 'bg-red-500'
|
||||||
/>
|
: 'bg-duck-dark/20'
|
||||||
<button
|
}`}
|
||||||
type="button"
|
title={
|
||||||
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
remoteHealth[provider.piId] === true
|
||||||
onClick={() => saveApiKey(provider.piId)}
|
? 'Valid'
|
||||||
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
: remoteHealth[provider.piId] === false
|
||||||
title="Save key"
|
? 'Invalid'
|
||||||
>
|
: 'Unknown'
|
||||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
}
|
||||||
</button>
|
/>
|
||||||
<button
|
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{provider.key}</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => disconnectProvider(provider)}
|
type="button"
|
||||||
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
onClick={() => setEditingProvider(editingProvider === provider.key ? null : provider.key)}
|
||||||
title="Disconnect provider"
|
className="shrink-0 p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
|
||||||
>
|
title="Edit key"
|
||||||
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
>
|
||||||
</button>
|
<Pencil className="h-3 w-3 text-duck-dark/30 hover:text-duck-teal" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => disconnectProvider(provider)}
|
||||||
|
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||||
|
title="Disconnect provider"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingProvider === provider.key && (
|
||||||
|
<div className="flex items-center gap-2 pl-4">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="h-7 text-xs flex-1"
|
||||||
|
placeholder={getStoredMasked(provider.piId) || 'Enter API key'}
|
||||||
|
value={keyInputs[provider.piId] ?? ''}
|
||||||
|
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||||
|
if (ev.key === 'Escape') setEditingProvider(null);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={keyInputs[provider.piId] === undefined || savingKey === provider.piId}
|
||||||
|
onClick={() => saveApiKey(provider.piId)}
|
||||||
|
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
|
||||||
|
title="Save key"
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingProvider(null)}
|
||||||
|
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editingProvider && (() => {
|
{editingProvider && (() => {
|
||||||
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
|
||||||
if (!provider || connectedProviders.includes(provider)) return null;
|
if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null;
|
||||||
return (
|
return (
|
||||||
<div key={provider.piId} className="flex items-center gap-2">
|
<div key={provider.piId} className="flex items-center gap-2">
|
||||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">{provider.key}</label>
|
||||||
@@ -462,6 +514,10 @@ export const AIHarnessesSection = () => {
|
|||||||
placeholder="Enter API key"
|
placeholder="Enter API key"
|
||||||
value={keyInputs[provider.piId] ?? ''}
|
value={keyInputs[provider.piId] ?? ''}
|
||||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId);
|
||||||
|
if (ev.key === 'Escape') setEditingProvider(null);
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -473,6 +529,13 @@ export const AIHarnessesSection = () => {
|
|||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
<Save className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingProvider(null)}
|
||||||
|
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -387,6 +387,52 @@ piMonoRouter.put('/api-keys', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const REMOTE_HEALTH_CONFIG: Record<string, {
|
||||||
|
url: string | ((key: string) => string);
|
||||||
|
headers: (key: string) => Record<string, string>;
|
||||||
|
}> = {
|
||||||
|
openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) },
|
||||||
|
google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) },
|
||||||
|
groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||||
|
};
|
||||||
|
|
||||||
|
piMonoRouter.get('/api-keys/health', async (ctx) => {
|
||||||
|
const auth = await readAuthJson();
|
||||||
|
const results: Record<string, boolean | null> = {};
|
||||||
|
|
||||||
|
const checks = PROVIDERS
|
||||||
|
.filter((p) => auth[p.piId]?.key?.trim())
|
||||||
|
.map(async (p) => {
|
||||||
|
const config = REMOTE_HEALTH_CONFIG[p.piId];
|
||||||
|
if (!config) {
|
||||||
|
results[p.piId] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = auth[p.piId]!.key;
|
||||||
|
const url = typeof config.url === 'function' ? config.url(key) : config.url;
|
||||||
|
const headers = config.headers(key);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 3000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers, signal: controller.signal });
|
||||||
|
results[p.piId] = res.ok;
|
||||||
|
} catch {
|
||||||
|
results[p.piId] = false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(checks);
|
||||||
|
return ctx.json(results);
|
||||||
|
});
|
||||||
|
|
||||||
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
||||||
|
|
||||||
const getPaths = async () => {
|
const getPaths = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user