571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
import { join } from 'node:path';
|
|
import { createRouter } from '../../create-router';
|
|
import { PI_CONFIG_DIR } from '../../data-path';
|
|
import { invalidateModelCache } from '../pi/list-models';
|
|
import { logger } from '../pi/logger';
|
|
import { readConfigValue, writeConfigValue } from 'officerdb';
|
|
|
|
export const piMonoRouter = createRouter();
|
|
|
|
const PI_AUTH_FILE = join(PI_CONFIG_DIR, 'auth.json');
|
|
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
|
|
|
// --- Local provider types ---
|
|
|
|
export type LocalProvider = {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
|
|
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
|
|
};
|
|
|
|
type ProbeResult = {
|
|
success: boolean;
|
|
apiType?: LocalProvider['apiType'];
|
|
name?: string;
|
|
needsAuth?: boolean;
|
|
authType?: 'api-key' | 'basic' | 'unknown';
|
|
models?: string[];
|
|
error?: string;
|
|
};
|
|
|
|
type PiModelConfig = {
|
|
providers: Record<string, {
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
api: string;
|
|
models: {
|
|
id: string;
|
|
name: string;
|
|
reasoning: boolean;
|
|
input: string[];
|
|
contextWindow: number;
|
|
maxTokens: number;
|
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
}[];
|
|
_officer?: OfficerMeta;
|
|
}>;
|
|
};
|
|
|
|
type OfficerMeta = {
|
|
name: string;
|
|
url: string;
|
|
apiType: LocalProvider['apiType'];
|
|
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
|
|
};
|
|
|
|
async function readModelsConfig(): Promise<PiModelConfig> {
|
|
try {
|
|
const file = Bun.file(PI_MODELS_FILE);
|
|
if (!(await file.exists())) return { providers: {} };
|
|
return (await file.json()) as PiModelConfig;
|
|
} catch {
|
|
return { providers: {} };
|
|
}
|
|
}
|
|
|
|
async function writeModelsConfig(config: PiModelConfig): Promise<void> {
|
|
await Bun.write(PI_MODELS_FILE, JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Read local providers from models.json by extracting officer-local-* entries.
|
|
*/
|
|
export async function readLocalProviders(): Promise<LocalProvider[]> {
|
|
const config = await readModelsConfig();
|
|
const providers: LocalProvider[] = [];
|
|
for (const [key, entry] of Object.entries(config.providers)) {
|
|
if (!key.startsWith('officer-local-') || !entry._officer) continue;
|
|
const id = key.replace('officer-local-', '');
|
|
providers.push({
|
|
id,
|
|
name: entry._officer.name,
|
|
url: entry._officer.url,
|
|
apiType: entry._officer.apiType,
|
|
auth: entry._officer.auth,
|
|
});
|
|
}
|
|
return providers;
|
|
}
|
|
|
|
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
|
|
const base = url.replace(/\/+$/, '');
|
|
const timeout = 5000;
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (auth?.type === 'api-key') {
|
|
headers['Authorization'] = `Bearer ${auth.apiKey}`;
|
|
} else if (auth?.type === 'basic') {
|
|
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`;
|
|
}
|
|
|
|
const tryFetch = async (path: string) => {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
try {
|
|
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
|
return res;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
// 1. Try Ollama: GET /api/tags
|
|
const ollamaRes = await tryFetch('/api/tags');
|
|
if (ollamaRes) {
|
|
if (ollamaRes.status === 401 || ollamaRes.status === 403) {
|
|
return { success: true, apiType: 'ollama', name: 'Ollama', needsAuth: true, authType: 'unknown' };
|
|
}
|
|
if (ollamaRes.ok) {
|
|
try {
|
|
const data = (await ollamaRes.json()) as { models?: { name: string }[] };
|
|
if (data.models) {
|
|
return {
|
|
success: true,
|
|
apiType: 'ollama',
|
|
name: 'Ollama',
|
|
needsAuth: false,
|
|
models: data.models.map((m) => m.name),
|
|
};
|
|
}
|
|
} catch {
|
|
// not ollama, continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Try LM Studio: GET /v1/models (LM Studio returns specific format)
|
|
// 3. Try OpenAI-compatible: GET /v1/models
|
|
const oaiRes = await tryFetch('/v1/models');
|
|
if (oaiRes) {
|
|
if (oaiRes.status === 401 || oaiRes.status === 403) {
|
|
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
|
|
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
|
|
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
|
|
}
|
|
if (oaiRes.ok) {
|
|
try {
|
|
const data = (await oaiRes.json()) as { data?: { id: string }[]; object?: string };
|
|
if (data.data) {
|
|
// LM Studio includes "lm-studio" in model IDs
|
|
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
|
|
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
|
|
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
|
|
return {
|
|
success: true,
|
|
apiType,
|
|
name,
|
|
needsAuth: false,
|
|
models: data.data.map((m) => m.id),
|
|
};
|
|
}
|
|
} catch {
|
|
// not valid JSON
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Try bare /models (some servers)
|
|
const bareRes = await tryFetch('/models');
|
|
if (bareRes) {
|
|
if (bareRes.status === 401 || bareRes.status === 403) {
|
|
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
|
|
}
|
|
if (bareRes.ok) {
|
|
try {
|
|
const data = (await bareRes.json()) as { data?: { id: string }[] };
|
|
if (data.data) {
|
|
return {
|
|
success: true,
|
|
apiType: 'openai-compatible',
|
|
name: 'OpenAI-compatible',
|
|
needsAuth: false,
|
|
models: data.data.map((m) => m.id),
|
|
};
|
|
}
|
|
} catch {
|
|
// continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: false, error: 'Could not detect API type at this URL' };
|
|
}
|
|
|
|
// --- Model fetching for local providers ---
|
|
|
|
const REASONING_PATTERNS = [/\bqwen3\b/i, /\bqwq\b/i, /\bdeepseek-r1\b/i, /\br1\b/i, /\breasoning\b/i, /\bthink/i];
|
|
|
|
function isReasoningModel(modelId: string): boolean {
|
|
return REASONING_PATTERNS.some((p) => p.test(modelId));
|
|
}
|
|
|
|
async function fetchModelsFromProvider(
|
|
lp: LocalProvider,
|
|
): Promise<{ id: string; name?: string; contextWindow?: number; maxTokens?: number }[]> {
|
|
try {
|
|
const base = lp.url.replace(/\/+$/, '');
|
|
const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models';
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
|
|
else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
|
clearTimeout(timer);
|
|
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
|
|
if (lp.apiType === 'ollama' && data.models) {
|
|
return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
|
|
} else if (data.data) {
|
|
return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
|
|
}
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a local provider entry directly into models.json with probed models.
|
|
*/
|
|
async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise<void> {
|
|
const models = await fetchModelsFromProvider(lp);
|
|
if (models.length === 0) {
|
|
logger.warn('No models found for local provider', { provider: lp.name });
|
|
}
|
|
|
|
const baseUrl = lp.apiType === 'ollama'
|
|
? `${lp.url}/v1`
|
|
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
|
|
|
|
const config = await readModelsConfig();
|
|
config.providers[`officer-local-${lp.id}`] = {
|
|
baseUrl,
|
|
apiKey: lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none',
|
|
api: 'openai-completions',
|
|
models: models.map((m) => ({
|
|
id: m.id,
|
|
name: m.name || m.id,
|
|
reasoning: isReasoningModel(m.id),
|
|
input: ['text'],
|
|
contextWindow: m.contextWindow || 128000,
|
|
maxTokens: m.maxTokens || 4096,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
})),
|
|
_officer: {
|
|
name: lp.name,
|
|
url: lp.url,
|
|
apiType: lp.apiType,
|
|
auth: lp.auth,
|
|
},
|
|
};
|
|
await writeModelsConfig(config);
|
|
invalidateModelCache();
|
|
}
|
|
|
|
async function removeLocalProviderFromModelsConfig(id: string): Promise<boolean> {
|
|
const config = await readModelsConfig();
|
|
const key = `officer-local-${id}`;
|
|
if (!(key in config.providers)) return false;
|
|
delete config.providers[key];
|
|
await writeModelsConfig(config);
|
|
invalidateModelCache();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Re-probe all local providers and update their models in models.json.
|
|
*/
|
|
async function refreshLocalProviders(): Promise<void> {
|
|
const config = await readModelsConfig();
|
|
for (const [key, entry] of Object.entries(config.providers)) {
|
|
if (!key.startsWith('officer-local-') || !entry._officer) continue;
|
|
const lp: LocalProvider = {
|
|
id: key.replace('officer-local-', ''),
|
|
name: entry._officer.name,
|
|
url: entry._officer.url,
|
|
apiType: entry._officer.apiType,
|
|
auth: entry._officer.auth,
|
|
};
|
|
const models = await fetchModelsFromProvider(lp);
|
|
|
|
const baseUrl = lp.apiType === 'ollama'
|
|
? `${lp.url}/v1`
|
|
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
|
|
|
|
entry.baseUrl = baseUrl;
|
|
entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none';
|
|
entry.models = models.map((m) => ({
|
|
id: m.id,
|
|
name: m.name || m.id,
|
|
reasoning: isReasoningModel(m.id),
|
|
input: ['text'],
|
|
contextWindow: m.contextWindow || 128000,
|
|
maxTokens: m.maxTokens || 4096,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
}));
|
|
}
|
|
await writeModelsConfig(config);
|
|
invalidateModelCache();
|
|
}
|
|
|
|
type AuthEntry = { type: 'api_key'; key: string };
|
|
type AuthJson = Record<string, AuthEntry>;
|
|
|
|
async function readAuthJson(): Promise<AuthJson> {
|
|
try {
|
|
const file = Bun.file(PI_AUTH_FILE);
|
|
if (!(await file.exists())) return {};
|
|
return (await file.json()) as AuthJson;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeAuthJson(auth: AuthJson): Promise<void> {
|
|
await Bun.write(PI_AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Hardcoded map of display name → Pi provider ID.
|
|
* Pi's native providers have built-in models; adding a key to auth.json
|
|
* is all that's needed to make their models appear in `pi --list-models`.
|
|
*/
|
|
export const PROVIDERS: { key: string; piId: string }[] = [
|
|
{ key: 'Anthropic', piId: 'anthropic' },
|
|
{ key: 'OpenAI', piId: 'openai' },
|
|
{ key: 'Google', piId: 'google' },
|
|
{ key: 'Groq', piId: 'groq' },
|
|
{ key: 'Mistral', piId: 'mistral' },
|
|
{ key: 'xAI', piId: 'xai' },
|
|
{ key: 'OpenRouter', piId: 'openrouter' },
|
|
{ key: 'MiniMax', piId: 'minimax' },
|
|
{ key: 'Hugging Face', piId: 'huggingface' },
|
|
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
|
|
{ key: 'OpenCode Zen', piId: 'zai' },
|
|
{ key: 'Cerebras', piId: 'cerebras' },
|
|
];
|
|
|
|
piMonoRouter.get('/auth', async (ctx) => {
|
|
const auth = await readAuthJson();
|
|
const providers = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => p.key);
|
|
return ctx.json({ authenticated: providers.length > 0, providers });
|
|
});
|
|
|
|
const maskValue = (value: string) => {
|
|
if (value.length <= 8) return '***';
|
|
return value.slice(0, 3) + '...' + value.slice(-3);
|
|
};
|
|
|
|
piMonoRouter.get('/api-keys', async (ctx) => {
|
|
const auth = await readAuthJson();
|
|
const keys = PROVIDERS
|
|
.filter((p) => auth[p.piId]?.key?.trim())
|
|
.map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) }));
|
|
return ctx.json({ keys });
|
|
});
|
|
|
|
piMonoRouter.put('/api-keys', async (ctx) => {
|
|
const { provider, value } = await ctx.req.json<{ provider: string; value: string }>();
|
|
if (!PROVIDERS.some((p) => p.piId === provider)) return ctx.json({ error: 'Invalid provider' }, 400);
|
|
|
|
const auth = await readAuthJson();
|
|
if (value.trim()) {
|
|
auth[provider] = { type: 'api_key', key: value.trim() };
|
|
} else {
|
|
delete auth[provider];
|
|
}
|
|
await writeAuthJson(auth);
|
|
invalidateModelCache();
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
type AccessPolicy = { allowedModels: string[] };
|
|
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
|
|
|
piMonoRouter.get('/access-policy', async (ctx) => {
|
|
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
|
|
return ctx.json(policy);
|
|
});
|
|
|
|
piMonoRouter.put('/access-policy', async (ctx) => {
|
|
const body = await ctx.req.json<AccessPolicy>();
|
|
await writeConfigValue(ACCESS_POLICY_KEY, body);
|
|
return ctx.json(body);
|
|
});
|
|
|
|
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 getPaths = async () => {
|
|
try {
|
|
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
|
|
const output = await new Response(proc.stdout).text();
|
|
await proc.exited;
|
|
if (proc.exitCode !== 0) return { path: null, globalPath: null };
|
|
const paths = [...new Set(output.trim().split('\n'))];
|
|
const path = paths[0] ?? null;
|
|
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
|
|
return { path, globalPath };
|
|
} catch {
|
|
return { path: null, globalPath: null };
|
|
}
|
|
};
|
|
|
|
piMonoRouter.get('/version', async (ctx) => {
|
|
try {
|
|
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
|
const output = await new Response(proc.stdout).text();
|
|
await proc.exited;
|
|
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
|
|
const { path, globalPath } = await getPaths();
|
|
return ctx.json({ version: output.trim(), path, globalPath });
|
|
} catch {
|
|
return ctx.json({ version: null, path: null, globalPath: null });
|
|
}
|
|
});
|
|
|
|
piMonoRouter.post('/install', async (ctx) => {
|
|
try {
|
|
const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
await proc.exited;
|
|
if (proc.exitCode !== 0) {
|
|
const stderr = await new Response(proc.stderr).text();
|
|
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
|
|
}
|
|
const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
|
const output = await new Response(versionProc.stdout).text();
|
|
await versionProc.exited;
|
|
const { path, globalPath } = await getPaths();
|
|
return ctx.json({ version: output.trim(), path, globalPath });
|
|
} catch {
|
|
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
|
|
}
|
|
});
|
|
|
|
// --- Local providers ---
|
|
|
|
piMonoRouter.get('/local-providers', async (ctx) => {
|
|
const providers = await readLocalProviders();
|
|
return ctx.json(providers.map((p) => ({
|
|
...p,
|
|
auth: p.auth ? { type: p.auth.type } : undefined,
|
|
})));
|
|
});
|
|
|
|
piMonoRouter.post('/local-providers/probe', async (ctx) => {
|
|
const { url, auth } = await ctx.req.json<{ url: string; auth?: LocalProvider['auth'] }>();
|
|
if (!url?.trim()) return ctx.json({ success: false, error: 'URL is required' }, 400);
|
|
const result = await probeUrl(url.trim(), auth);
|
|
return ctx.json(result);
|
|
});
|
|
|
|
piMonoRouter.post('/local-providers', async (ctx) => {
|
|
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
|
|
|
|
const provider: LocalProvider = {
|
|
id: crypto.randomUUID(),
|
|
name: body.name ?? body.apiType,
|
|
url: body.url.replace(/\/+$/, ''),
|
|
apiType: body.apiType,
|
|
auth: body.auth,
|
|
};
|
|
|
|
await addLocalProviderToModelsConfig(provider);
|
|
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
|
});
|
|
|
|
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
|
const { id } = ctx.req.param();
|
|
const removed = await removeLocalProviderFromModelsConfig(id);
|
|
if (!removed) return ctx.json({ error: 'Not found' }, 404);
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
piMonoRouter.post('/local-providers/refresh', async (ctx) => {
|
|
await refreshLocalProviders();
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
piMonoRouter.get('/local-providers/health', async (ctx) => {
|
|
const providers = await readLocalProviders();
|
|
const results: Record<string, boolean> = {};
|
|
|
|
await Promise.all(providers.map(async (p) => {
|
|
const base = p.url.replace(/\/+$/, '');
|
|
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
|
|
const headers: Record<string, string> = {};
|
|
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
|
|
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 3000);
|
|
try {
|
|
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
|
results[p.id] = res.ok;
|
|
} catch {
|
|
results[p.id] = false;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}));
|
|
|
|
return ctx.json(results);
|
|
});
|