mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-16 01:09:40 +02:00
pull the models from the upstream server
This commit is contained in:
@@ -28,6 +28,7 @@ import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
|||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -46,6 +47,7 @@ async function startServers() {
|
|||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
initAcmeCertSync();
|
initAcmeCertSync();
|
||||||
startRebuildQueueProcessor();
|
startRebuildQueueProcessor();
|
||||||
|
await initAiModelCatalog();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import axios from "axios";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export type CatalogProvider =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "gemini"
|
||||||
|
| "vertex"
|
||||||
|
| "azure"
|
||||||
|
| "bedrock";
|
||||||
|
|
||||||
|
export type AiModelCatalogEntry = {
|
||||||
|
provider: CatalogProvider;
|
||||||
|
model: string;
|
||||||
|
pricing: {
|
||||||
|
input: number | null;
|
||||||
|
output: number | null;
|
||||||
|
cacheRead: number | null;
|
||||||
|
reasoningOutput: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let catalog: AiModelCatalogEntry[] = [];
|
||||||
|
let refreshTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async function fetchFromFile(filePath: string): Promise<AiModelCatalogEntry[] | null> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
logger.warn(
|
||||||
|
`AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] };
|
||||||
|
return parsed.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to read AI model catalog file", { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromUpstream(
|
||||||
|
upstreamUrl: string
|
||||||
|
): Promise<AiModelCatalogEntry[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await axios.get<{ data: AiModelCatalogEntry[] }>(
|
||||||
|
upstreamUrl,
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
return res.data?.data ?? [];
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCatalog(): Promise<void> {
|
||||||
|
const { file, upstream_url } = config.getRawConfig().ai.model_catalog;
|
||||||
|
|
||||||
|
const fetched = file
|
||||||
|
? await fetchFromFile(file)
|
||||||
|
: await fetchFromUpstream(upstream_url);
|
||||||
|
|
||||||
|
if (fetched) {
|
||||||
|
catalog = fetched;
|
||||||
|
logger.debug(
|
||||||
|
`AI model catalog refreshed: ${catalog.length} models loaded`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextRefresh(): void {
|
||||||
|
const { refresh_interval_min_hours, refresh_interval_max_hours } =
|
||||||
|
config.getRawConfig().ai.model_catalog;
|
||||||
|
|
||||||
|
// Jittered rather than fixed so that many self-hosted instances don't
|
||||||
|
// all hit the upstream catalog endpoint at the same moment.
|
||||||
|
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
|
||||||
|
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
|
||||||
|
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
|
||||||
|
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
}
|
||||||
|
refreshTimer = setTimeout(async () => {
|
||||||
|
await refreshCatalog();
|
||||||
|
scheduleNextRefresh();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the AI model pricing catalog into memory and schedules periodic
|
||||||
|
* background refreshes. Call once at server startup.
|
||||||
|
*/
|
||||||
|
export async function initAiModelCatalog(): Promise<void> {
|
||||||
|
await refreshCatalog();
|
||||||
|
scheduleNextRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiModelCatalog(): AiModelCatalogEntry[] {
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { APP_PATH } from "@server/lib/consts";
|
|
||||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||||
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
||||||
import logger from "@server/logger";
|
import {
|
||||||
|
getAiModelCatalog,
|
||||||
const MODELS_JSON_PATH = path.join(APP_PATH, "models.json");
|
type AiModelCatalogEntry,
|
||||||
|
type CatalogProvider
|
||||||
export type CatalogProvider =
|
} from "@server/lib/aiModelCatalog";
|
||||||
| "openai"
|
|
||||||
| "anthropic"
|
|
||||||
| "gemini"
|
|
||||||
| "vertex"
|
|
||||||
| "azure"
|
|
||||||
| "bedrock";
|
|
||||||
|
|
||||||
export type AiModelCatalogEntry = {
|
|
||||||
provider: CatalogProvider;
|
|
||||||
model: string;
|
|
||||||
pricing: {
|
|
||||||
input: number | null;
|
|
||||||
output: number | null;
|
|
||||||
cacheRead: number | null;
|
|
||||||
reasoningOutput: number | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AiModelPricing = {
|
export type AiModelPricing = {
|
||||||
inputCostPerToken: number | null;
|
inputCostPerToken: number | null;
|
||||||
@@ -56,34 +36,28 @@ const PROVIDER_CATALOG_MAP: Record<
|
|||||||
vercelAiGateway: null
|
vercelAiGateway: null
|
||||||
};
|
};
|
||||||
|
|
||||||
let modelsByName: Map<string, AiModelCatalogEntry[]> | null = null;
|
// Indexed view over the in-memory catalog, rebuilt only when
|
||||||
|
// getAiModelCatalog() returns a different array instance (i.e. after a
|
||||||
|
// background refresh swaps it out), not on every lookup.
|
||||||
|
let indexedCatalog: AiModelCatalogEntry[] | null = null;
|
||||||
|
let indexedByName: Map<string, AiModelCatalogEntry[]> = new Map();
|
||||||
|
|
||||||
function loadModels(): Map<string, AiModelCatalogEntry[]> {
|
function getIndexedCatalog(): Map<string, AiModelCatalogEntry[]> {
|
||||||
if (modelsByName) {
|
const catalog = getAiModelCatalog();
|
||||||
return modelsByName;
|
if (catalog === indexedCatalog) {
|
||||||
|
return indexedByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const byName = new Map<string, AiModelCatalogEntry[]>();
|
const byName = new Map<string, AiModelCatalogEntry[]>();
|
||||||
try {
|
for (const entry of catalog) {
|
||||||
if (fs.existsSync(MODELS_JSON_PATH)) {
|
if (!entry.model) continue;
|
||||||
const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
|
const list = byName.get(entry.model) ?? [];
|
||||||
const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] };
|
list.push(entry);
|
||||||
for (const entry of parsed.data ?? []) {
|
byName.set(entry.model, list);
|
||||||
if (!entry.model) continue;
|
|
||||||
const list = byName.get(entry.model) ?? [];
|
|
||||||
list.push(entry);
|
|
||||||
byName.set(entry.model, list);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`AI model pricing file not found at ${MODELS_JSON_PATH}; cost calculation will fall back to unknown pricing`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn("Failed to load AI model pricing file", { error });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
modelsByName = byName;
|
indexedCatalog = catalog;
|
||||||
|
indexedByName = byName;
|
||||||
return byName;
|
return byName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +118,7 @@ export function getModelPricing(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const byName = loadModels();
|
const byName = getIndexedCatalog();
|
||||||
const catalogProvider =
|
const catalogProvider =
|
||||||
providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType];
|
providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType];
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,36 @@ export const configSchema = z
|
|||||||
disable_enterprise_features: z.boolean().optional()
|
disable_enterprise_features: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ai: z
|
||||||
|
.object({
|
||||||
|
model_catalog: z
|
||||||
|
.object({
|
||||||
|
upstream_url: z
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.default("https://api.fossorial.io/api/v1/models"),
|
||||||
|
// No default - only used when an operator wants to
|
||||||
|
// pin the catalog to a local file instead of
|
||||||
|
// fetching it from upstream_url.
|
||||||
|
file: z.string().optional(),
|
||||||
|
refresh_interval_min_hours: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(6),
|
||||||
|
refresh_interval_max_hours: z
|
||||||
|
.number()
|
||||||
|
.positive()
|
||||||
|
.gt(0)
|
||||||
|
.optional()
|
||||||
|
.default(12)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.prefault({})
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.prefault({}),
|
||||||
dns: z
|
dns: z
|
||||||
.object({
|
.object({
|
||||||
nameservers: z
|
nameservers: z
|
||||||
|
|||||||
Reference in New Issue
Block a user