This commit is contained in:
241
packages/server/src/local/litellm-runtime-config.ts
Normal file
241
packages/server/src/local/litellm-runtime-config.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
discoverProviderModels,
|
||||
PROVIDER_MODEL_CATALOGS,
|
||||
type DiscoveryOptions,
|
||||
type DiscoveredProviderModel,
|
||||
} from './provider-model-catalog.js';
|
||||
import { applyProviderKeyToEnv, getProviderApiKey } from './provider-env.js';
|
||||
import { startLiteLLM, stopLiteLLM } from './lifecycle.js';
|
||||
|
||||
interface LiteLLMProviderRoute {
|
||||
envName: string;
|
||||
modelPrefix: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
interface LiteLLMModelEntry {
|
||||
model_name: string;
|
||||
litellm_params: {
|
||||
model: string;
|
||||
api_key: string;
|
||||
api_base?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LiteLLMRuntimeConfigResult {
|
||||
configPath: string | null;
|
||||
modelIds: string[];
|
||||
unavailableProviders: string[];
|
||||
}
|
||||
|
||||
export interface LiteLLMRefreshResult {
|
||||
managed: boolean;
|
||||
ready: boolean;
|
||||
port: number;
|
||||
models: string[];
|
||||
unavailableProviders: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Routing metadata only. Model identities always come from provider APIs. */
|
||||
const LITELLM_PROVIDER_ROUTES: Record<string, LiteLLMProviderRoute> = {
|
||||
anthropic: { envName: 'ANTHROPIC_API_KEY', modelPrefix: 'anthropic' },
|
||||
openai: { envName: 'OPENAI_API_KEY', modelPrefix: 'openai' },
|
||||
google: { envName: 'GEMINI_API_KEY', modelPrefix: 'gemini' },
|
||||
deepseek: { envName: 'DEEPSEEK_API_KEY', modelPrefix: 'deepseek' },
|
||||
xai: { envName: 'XAI_API_KEY', modelPrefix: 'xai' },
|
||||
mistral: { envName: 'MISTRAL_API_KEY', modelPrefix: 'mistral' },
|
||||
alibaba: {
|
||||
envName: 'DASHSCOPE_API_KEY',
|
||||
modelPrefix: 'openai',
|
||||
apiBase: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
minimax: {
|
||||
envName: 'MINIMAX_API_KEY',
|
||||
modelPrefix: 'openai',
|
||||
apiBase: 'https://api.minimax.io/v1',
|
||||
},
|
||||
zhipu: {
|
||||
envName: 'ZHIPU_API_KEY',
|
||||
modelPrefix: 'openai',
|
||||
apiBase: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
},
|
||||
moonshot: {
|
||||
envName: 'MOONSHOT_API_KEY',
|
||||
modelPrefix: 'openai',
|
||||
apiBase: 'https://api.moonshot.ai/v1',
|
||||
},
|
||||
perplexity: { envName: 'PERPLEXITY_API_KEY', modelPrefix: 'perplexity' },
|
||||
openrouter: { envName: 'OPENROUTER_API_KEY', modelPrefix: 'openrouter' },
|
||||
};
|
||||
|
||||
function routeModel(providerId: string, model: DiscoveredProviderModel): string {
|
||||
const route = LITELLM_PROVIDER_ROUTES[providerId];
|
||||
const providerPrefix = `${providerId}/`;
|
||||
const providerModelId = model.id.startsWith(providerPrefix)
|
||||
? model.id.slice(providerPrefix.length)
|
||||
: model.id;
|
||||
return `${route.modelPrefix}/${providerModelId}`;
|
||||
}
|
||||
|
||||
export function buildLiteLLMRuntimeConfig(
|
||||
catalogs: ReadonlyMap<string, DiscoveredProviderModel[]>,
|
||||
customBaseUrls: ReadonlyMap<string, string> = new Map(),
|
||||
): { model_list: LiteLLMModelEntry[] } {
|
||||
const modelList: LiteLLMModelEntry[] = [];
|
||||
for (const [providerId, models] of catalogs) {
|
||||
const route = LITELLM_PROVIDER_ROUTES[providerId];
|
||||
if (!route) continue;
|
||||
for (const model of models) {
|
||||
const apiBase = customBaseUrls.get(providerId) ?? route.apiBase;
|
||||
modelList.push({
|
||||
model_name: model.id,
|
||||
litellm_params: {
|
||||
model: routeModel(providerId, model),
|
||||
api_key: `os.environ/${route.envName}`,
|
||||
...(apiBase ? { api_base: apiBase } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { model_list: modelList };
|
||||
}
|
||||
|
||||
/** Build the executable router catalog from the same live APIs used by the UI. */
|
||||
export async function prepareLiteLLMRuntimeConfig(
|
||||
dataDir: string,
|
||||
vault: VaultStore,
|
||||
discoveryOptions: DiscoveryOptions = {},
|
||||
): Promise<LiteLLMRuntimeConfigResult> {
|
||||
const catalogs = new Map<string, DiscoveredProviderModel[]>();
|
||||
const customBaseUrls = new Map<string, string>();
|
||||
const unavailableProviders: string[] = [];
|
||||
|
||||
await Promise.all(Object.keys(PROVIDER_MODEL_CATALOGS).map(async (providerId) => {
|
||||
const entry = vault.get(providerId);
|
||||
const apiKey = getProviderApiKey(providerId, vault);
|
||||
if (!apiKey) return;
|
||||
// Keep aliases such as GEMINI_API_KEY / GOOGLE_API_KEY aligned so the
|
||||
// generated config's canonical env reference always resolves. Overwrite:
|
||||
// getProviderApiKey resolves vault-first, and a stale machine-level env
|
||||
// var (which node --env-file never overrides) would otherwise poison the
|
||||
// child's os.environ/* key references while discovery used the vault key.
|
||||
applyProviderKeyToEnv(providerId, apiKey, true);
|
||||
const baseUrl = typeof entry?.metadata?.baseUrl === 'string' ? entry.metadata.baseUrl : undefined;
|
||||
if (baseUrl) customBaseUrls.set(providerId, baseUrl);
|
||||
const result = await discoverProviderModels(providerId, apiKey, baseUrl, discoveryOptions);
|
||||
if (result.models.length > 0) catalogs.set(providerId, result.models);
|
||||
if (result.status === 'unavailable') unavailableProviders.push(providerId);
|
||||
}));
|
||||
|
||||
const config = buildLiteLLMRuntimeConfig(catalogs, customBaseUrls);
|
||||
const modelIds = config.model_list.map((entry) => entry.model_name);
|
||||
if (modelIds.length === 0) {
|
||||
return { configPath: null, modelIds, unavailableProviders };
|
||||
}
|
||||
|
||||
const configPath = path.join(dataDir, 'litellm.runtime.json');
|
||||
const tempPath = `${configPath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tempPath, configPath);
|
||||
} catch {
|
||||
try { fs.unlinkSync(configPath); } catch { /* first write */ }
|
||||
fs.renameSync(tempPath, configPath);
|
||||
}
|
||||
return { configPath, modelIds, unavailableProviders };
|
||||
}
|
||||
|
||||
/** Rebuild and restart the managed router after credentials or catalogs change. */
|
||||
export async function refreshManagedLiteLLM(server: FastifyInstance): Promise<LiteLLMRefreshResult> {
|
||||
const port = server.localConfig.managedLiteLLMPort ?? 4000;
|
||||
if (!server.localConfig.manageLiteLLM) {
|
||||
return {
|
||||
managed: false,
|
||||
ready: false,
|
||||
port,
|
||||
models: [],
|
||||
unavailableProviders: [],
|
||||
error: 'LiteLLM lifecycle is managed outside this server.',
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = await prepareLiteLLMRuntimeConfig(server.localConfig.dataDir, server.vault);
|
||||
if (!runtime.configPath) {
|
||||
return {
|
||||
managed: true,
|
||||
ready: false,
|
||||
port,
|
||||
models: runtime.modelIds,
|
||||
unavailableProviders: runtime.unavailableProviders,
|
||||
error: 'No provider model catalog is currently available.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await stopLiteLLM();
|
||||
} catch {
|
||||
// The prior child may already have exited. startLiteLLM checks the port.
|
||||
}
|
||||
const status = await startLiteLLM(port, runtime.configPath);
|
||||
const ready = status.status === 'running' || status.status === 'started';
|
||||
if (ready) {
|
||||
server.localConfig.litellmUrl = `http://localhost:${port}`;
|
||||
server.agentState.litellmApiKey = process.env.LITELLM_API_KEY
|
||||
?? process.env.LITELLM_MASTER_KEY
|
||||
?? 'sk-waggle-dev';
|
||||
server.agentState.llmProvider = {
|
||||
provider: 'litellm',
|
||||
health: 'healthy',
|
||||
detail: `LiteLLM on port ${port} (${runtime.modelIds.length} provider models)`,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
managed: true,
|
||||
ready,
|
||||
port,
|
||||
models: runtime.modelIds,
|
||||
unavailableProviders: runtime.unavailableProviders,
|
||||
...(!ready ? {
|
||||
error: status.error ?? (status.status === 'timeout'
|
||||
? 'LiteLLM did not start in time'
|
||||
: 'LiteLLM did not become ready.'),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readManagedModelIds(dataDir: string): string[] {
|
||||
const configPath = path.join(dataDir, 'litellm.runtime.json');
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
model_list?: Array<{ model_name?: unknown }>;
|
||||
};
|
||||
return (parsed.model_list ?? [])
|
||||
.map((entry) => entry.model_name)
|
||||
.filter((modelName): modelName is string => typeof modelName === 'string' && modelName.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a provider-discovered model executable in the already-running managed
|
||||
* router. Existing models are a zero-cost read; only a genuinely new model
|
||||
* causes catalog regeneration and one router restart.
|
||||
*/
|
||||
export async function ensureManagedLiteLLMModel(
|
||||
server: FastifyInstance,
|
||||
model: string,
|
||||
): Promise<boolean> {
|
||||
if (!server.localConfig.manageLiteLLM || model.startsWith('ollama/')) return true;
|
||||
if (readManagedModelIds(server.localConfig.dataDir).includes(model)) return true;
|
||||
|
||||
const refresh = await refreshManagedLiteLLM(server);
|
||||
return refresh.ready && refresh.models.includes(model);
|
||||
}
|
||||
Reference in New Issue
Block a user