Files
homelab/chat/lib/provider-connectivity.ts
T

189 lines
7.8 KiB
TypeScript

import {applyProviderAuthentication} from "./provider-model";
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type JsonRecord = Record<string, unknown>;
function accountEndpoints(provider: ProviderDefinition) {
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const url = new URL(baseUrl);
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`];
if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
return [];
}
async function responseText(response: Response, maximum = 4 * 1024 * 1024) {
const text = await response.text();
if (text.length > maximum) throw new Error("Provider response is too large");
return text;
}
function jsonRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
}
function publicMetadata(value: unknown, depth = 0): unknown {
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
if (depth >= 3) return undefined;
if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined);
const record = jsonRecord(value);
if (!record) return undefined;
return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined));
}
function selectedMetadata(record: JsonRecord, pattern: RegExp) {
return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined));
}
export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
return source.slice(0, 300).map((value) => {
const model = jsonRecord(value);
if (!model) return null;
const rawId = model.id || model.name || model.model;
if (typeof rawId !== "string" || !rawId.trim()) return null;
const id = rawId.replace(/^models\//, "");
const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i);
return {
id,
name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id,
...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}),
...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}),
...(Object.keys(pricing).length ? {pricing} : {})
};
}).filter((model): model is ProviderModel => model !== null);
}
function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const modelPath = typeof root.model_path === "string" ? root.model_path : "";
const modelAlias = typeof root.model_alias === "string" ? root.model_alias : "";
const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel;
const generationSettings = jsonRecord(root.default_generation_settings);
const contextWindow = Number(generationSettings?.n_ctx);
return [{
id,
name: id,
ownedBy: "llamacpp",
...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {})
}];
}
function responseRateLimits(response: Response) {
return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)));
}
async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) {
const response = await providerFetch(endpoint, {
method: "GET",
headers,
signal: AbortSignal.timeout(timeoutMs)
});
const text = await responseText(response);
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = {preview: text.slice(0, 1000)};
}
return {response, payload};
}
async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) {
const endpoint = provider.discovery.url;
const headers = applyProviderAuthentication(provider, secret, {
"Accept": "application/json",
...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {})
});
const startedAt = performance.now();
const initial = await fetchJson(providerFetch, endpoint, headers);
let {response, payload} = initial;
let resolvedEndpoint = endpoint;
let models = response.ok ? normalizeDiscoveredModels(payload) : [];
if (provider.id === "llama.cpp" && models.length === 0) {
const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
try {
const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000);
const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : [];
if (propsModels.length > 0) {
response = props.response;
payload = props.payload;
resolvedEndpoint = propsEndpoint;
models = propsModels;
}
} catch {
// Preserve the primary /v1/models error when the compatibility probe also fails.
}
}
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
if (!response.ok) {
const root = jsonRecord(payload);
const detail = root?.preview || root?.error || root?.message;
const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`);
Object.assign(error, {statusCode: response.status});
throw error;
}
return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models};
}
export async function discoverProviderModels(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
return {
endpoint: probe.endpoint,
status: probe.response.status,
latencyMs: probe.latencyMs,
models: probe.models
};
}
export async function testProviderConnectivity(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
const {endpoint, headers, response, payload, latencyMs, models} = probe;
const root = jsonRecord(payload) || {};
const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i);
const rateLimits = responseRateLimits(response);
const accountUrls = accountEndpoints(provider);
let accountProbe: unknown = null;
for (const accountUrl of accountUrls) {
try {
const probe = await fetchJson(providerFetch, accountUrl, headers, 5000);
if (!probe.response.ok) continue;
accountProbe = {
endpoint: accountUrl,
status: probe.response.status,
data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload)
};
break;
} catch {
// Account metadata is best-effort and must not fail a successful model probe.
}
}
return {
ok: true,
status: response.status,
latencyMs,
endpoint,
modelCount: models.length,
models,
account: Object.keys(account).length ? account : null,
accountProbe,
rateLimits: Object.keys(rateLimits).length ? rateLimits : null
};
}