Files
routstrd/src/daemon/models.ts
T

344 lines
12 KiB
TypeScript

import {
canonicalIdForModel,
findModelForId,
ModelManager,
ProviderManager,
type MintDiscovery,
type Model,
type SdkStore,
} from "@routstr/sdk";
import type { ExposedModel } from "./types";
import { logger } from "../utils/logger";
export type ModelProviderInfo = {
baseUrl: string;
disabled: boolean;
pricing: {
prompt: number;
completion: number;
request: number;
max_cost: number;
};
};
export type ModelWithProviders = ExposedModel & {
providers: ModelProviderInfo[];
};
export function createModelService(
modelManager: ModelManager,
providerManager: ProviderManager,
store: SdkStore,
mintDiscovery?: MintDiscovery,
) {
let providerBootstrapPromise: Promise<void> | null = null;
let mintDiscoveryPromise: Promise<void> | null = null;
const scheduleMintDiscovery = (providers: string[]): void => {
if (!mintDiscovery || mintDiscoveryPromise) return;
const discovery = mintDiscovery;
mintDiscoveryPromise = Promise.resolve()
.then(async () => {
await discovery.discoverMints(providers);
logger.log(`Discovered mints for ${providers.length} provider(s)`);
})
.catch((error) => logger.error("Mint discovery failed:", error))
.finally(() => { mintDiscoveryPromise = null; });
};
const normalizeBaseUrl = (url: string): string =>
url.endsWith("/") ? url : `${url}/`;
/**
* Build the same cheapest-per-model view as ModelManager.fetchModels, but
* exclusively from the persisted cache. Model-list reads must not wait for
* unavailable providers; explicit and scheduled refreshes own network I/O.
*/
const getCachedModels = (): ExposedModel[] => {
type PricedModel = ExposedModel & {
sats_pricing?: { completion?: number };
};
const cachedByProvider = modelManager.getAllCachedModels();
const currentProviders = new Set(
modelManager.getBaseUrls().map(normalizeBaseUrl),
);
const s = store.getState();
const manuallyEnabled = new Set(
(s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
);
const disabledProviders = new Set(
[
...(s.disabledProviders || []),
...(s.manuallyDisabledProviders || []),
]
.map(normalizeBaseUrl)
.filter((url) => !manuallyEnabled.has(url)),
);
const bestById = new Map<string, PricedModel>();
for (const [baseUrl, models] of Object.entries(cachedByProvider)) {
const normalized = normalizeBaseUrl(baseUrl);
if (
disabledProviders.has(normalized) ||
(currentProviders.size > 0 && !currentProviders.has(normalized))
) {
continue;
}
for (const model of models as PricedModel[]) {
if (!model.sats_pricing) continue;
// Group by canonical id so providers serving the same model under a
// mapped variant id or alias fold into one aggregate entry.
const canonicalId = canonicalIdForModel(model as Model);
const existing = bestById.get(canonicalId);
if (
!existing ||
(model.sats_pricing.completion ?? 0) <
(existing.sats_pricing?.completion ?? 0)
) {
bestById.set(canonicalId, model);
}
}
}
return [...bestById.values()];
};
const ensureProvidersBootstrapped = (): Promise<void> => {
if (!providerBootstrapPromise) {
providerBootstrapPromise = (async () => {
logger.log("Bootstrapping providers...");
const providers = await modelManager.bootstrapProviders(false);
logger.log(`Bootstrapped ${providers.length} providers`);
// Mirror discovery into the store so `providers list` reports the same
// set that the model manager polls and routes to. The list is
// *replaced*, not merged: an add-only merge keeps URLs discovery no
// longer reports (e.g. after a bootstrap regression or a provider
// unpublishing) visible to the CLI and to per-model views while
// routing silently ignores them.
const {
baseUrlsList,
setBaseUrlsList,
setDisabledProviders,
} = store.getState();
const known = new Set(baseUrlsList);
const inSync =
known.size === providers.length &&
providers.every((url) => known.has(url));
if (!inSync) {
setBaseUrlsList(providers);
logger.log(
`Synced ${providers.length} discovered provider(s) into store (was ${baseUrlsList.length})`,
);
}
// Mirror the review-disabled set (kind 38425) into the store. The SDK
// applies review disables to the discovery adapter during bootstrap,
// but `providers list` and the per-model provider views read the store,
// so without this a fresh install reports "0 disabled" while routing
// silently excludes the review-disabled providers.
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
providers,
);
if (reviewedDisabled !== null) {
setDisabledProviders(reviewedDisabled);
}
logger.log("Provider bootstrap complete.");
// Populate the mint cache for every enabled provider. The SDK refuses
// to spend a mint a provider does not advertise, so without this the
// routing layer spends the wallet's largest mint and gets rejected
// (finding #4). MintDiscovery owns its own 21-minute TTL, so repeated
// calls within that window are cache hits and never refetch.
scheduleMintDiscovery(providers);
})().catch((error) => {
providerBootstrapPromise = null;
logger.error("Provider bootstrap failed:", error);
throw error;
});
}
return providerBootstrapPromise;
};
const getRoutstr21Models = async (
forceRefresh = false,
): Promise<ExposedModel[]> => {
const routstr21ModelIds = Array.from(
new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
);
let discoveredModels: ExposedModel[];
if (!forceRefresh) {
discoveredModels = getCachedModels();
} else {
discoveredModels = [];
}
// Warm reads are cache-only. A cold start and explicit refresh still
// populate models from the provider network.
if (forceRefresh || discoveredModels.length === 0) {
await ensureProvidersBootstrapped();
discoveredModels = await modelManager.fetchModels(
modelManager.getBaseUrls(),
forceRefresh,
);
}
// Resolve each routstr21 id against the aggregated provider models. The
// aggregation folds providers' mapped variant ids/aliases into one entry
// per canonical id, but the surviving entry is the provider's raw model,
// whose `.id` may be the variant (e.g. z-ai-glm-5-3-flash) — while still
// carrying the full metadata (context_length, architecture, reasoning).
// A plain id map would miss and degrade the entry to a bare stub, so
// resolve through the SDK's model mappings (exact native id wins, same as
// getModelProviders) and always expose the requested canonical id.
return routstr21ModelIds.map((modelId) => {
const model = findModelForId(discoveredModels as Model[], modelId);
return model
? { ...model, id: modelId }
: { id: modelId, name: modelId };
});
};
const getModelProviders = async (
modelId: string,
): Promise<ModelWithProviders | null> => {
await ensureProvidersBootstrapped();
const s = store.getState();
const manuallyEnabled = new Set<string>(
(s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
);
const disabledSet = new Set<string>(
[
...(s.disabledProviders || []),
...(s.manuallyDisabledProviders || []),
].filter((url) => !manuallyEnabled.has(normalizeBaseUrl(url))),
);
// Use the SDK ranking (sorted by prompt+completion per million tokens)
// so the display order matches real routing. includeDisabled keeps
// disabled providers visible so we can annotate them.
const ranking = providerManager.getProviderPriceRankingForModel(modelId, {
includeDisabled: true,
});
const providers: ModelProviderInfo[] = ranking.map((entry: any) => ({
baseUrl: entry.baseUrl,
disabled: disabledSet.has(entry.baseUrl),
pricing: {
prompt: entry.promptPerMillion / 1_000_000,
completion: entry.completionPerMillion / 1_000_000,
request: entry.model.sats_pricing?.request ?? 0,
max_cost: entry.model.sats_pricing?.max_cost ?? 0,
},
}));
if (providers.length === 0) {
return null;
}
// Get model metadata. Prefer a provider that serves the requested
// canonical id natively (nicest name/description); fall back to the
// cheapest provider matched through the SDK's model mappings, since it
// may only know the model under a mapped variant id or alias.
const cheapest = providers[0]!;
const allModels = modelManager.getAllCachedModels();
let modelInfo: Model | undefined;
for (const p of providers) {
const exact = (allModels[p.baseUrl] as Model[] | undefined)?.find(
(m) => m.id === modelId,
);
if (exact) {
modelInfo = exact;
break;
}
}
if (!modelInfo) {
const firstProvider = allModels[cheapest.baseUrl];
modelInfo = firstProvider
? findModelForId(firstProvider as Model[], modelId)
: undefined;
}
if (!modelInfo) {
return null;
}
return {
id: modelId,
name: modelInfo.name,
description: modelInfo.description,
context_length: modelInfo.context_length,
providers,
};
};
/**
* Force-refresh everything: re-fetch Nostr provider discovery events,
* routstr21 model list, Nostr review events, and models from all enabled
* providers. Syncs the discovered provider list into the store.
*/
const refreshProvidersAndModels = async (): Promise<void> => {
// Reset the bootstrap promise so we don't reuse cached results
providerBootstrapPromise = null;
console.log("Force-refreshing providers from Nostr...");
// Force-refresh provider discovery from Nostr (kind 38421)
const providers = await modelManager.bootstrapProviders(false, true);
console.log(`Discovered ${providers.length} providers from Nostr`);
// Force-refresh routstr21 models from Nostr (kind 38423)
const routstr21ModelIds = await modelManager.fetchRoutstr21Models(true);
console.log(`Fetched ${routstr21ModelIds.length} routstr21 model IDs from Nostr`);
// Force-refresh models from all providers
const models = await modelManager.fetchModels(providers, true);
console.log(`Fetched ${models.length} models from ${providers.length} providers`);
// Refresh each provider's advertised mint list alongside its models so
// routing always has current mint data (TTL-gated by MintDiscovery).
scheduleMintDiscovery(providers);
// Sync review events from Nostr (kind 38425) and apply disabled status
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
providers,
undefined,
true,
);
if (reviewedDisabled && reviewedDisabled.length > 0) {
console.log(
`Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
);
}
// Sync discovered providers into the store
const { setBaseUrlsList, setDisabledProviders } = store.getState() as any;
// Replace baseUrlsList with the fresh provider list
setBaseUrlsList(providers);
// Mirror the review-disabled set into the store's auto-disabled list.
// `null` means the review sync left the adapter unchanged (e.g. no lgtm
// reviews found), so we must not clobber the store list with an empty array.
if (reviewedDisabled !== null) {
setDisabledProviders(reviewedDisabled);
}
console.log(
`Provider refresh complete: ${providers.length} total, ${reviewedDisabled?.length ?? store.getState().disabledProviders?.length ?? 0} review-disabled`,
);
};
return {
ensureProvidersBootstrapped,
getRoutstr21Models,
getModelProviders,
refreshProvidersAndModels,
};
}