mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
344 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|