Download server/src/services/custom-model-sync.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 7.36 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/custom-model-sync.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/services/custom-model-sync.ts
-
curl -L -o custom-model-sync.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/custom-model-sync.ts
7.36 kB
| import type { Db } from '../db/types.js'; | |
| import type { Scheduler } from '../lib/scheduler.js'; | |
| import { discoverEndpointModels } from './model-discovery.js'; | |
| import { registerCustomModels, type CustomModelEntry } from './custom-model-register.js'; | |
| import { isCustomModelTombstoned } from './custom-model-tombstone.js'; | |
| import { listCustomEndpoints } from './custom-endpoint.js'; | |
| import { endpointScopeForBaseUrl } from '../lib/endpoint-scope.js'; | |
| // ββ Scheduled custom-model sync (#674/#663/#656) βββββββββββββββββββββββββββββ | |
| // | |
| // Operators add a custom OpenAI-compatible provider and fetch its model list | |
| // once, but providers change their model availability almost daily. This walks | |
| // every configured custom endpoint on a schedule and registers whatever NEW | |
| // models it now serves, reusing the exact same registration path as the manual | |
| // POST /custom (see custom-model-register.ts) so the two can never drift. | |
| // | |
| // Deliberate bounds: | |
| // - ADD ONLY. Models that disappear upstream are left alone β an automated | |
| // pass must never delete a row the operator may have tuned by hand. | |
| // - One failing endpoint is logged and skipped; the others still sync, so a | |
| // dead relay cannot hold the whole schedule hostage. | |
| // - No catalog paywall interplay: these models come from the operator's own | |
| // endpoint with the operator's own key, not from the published catalog. | |
| /** Default: once a day (#674 asks for daily; #663's 5-minute ask targets the | |
| * bundled free catalog, which this pass deliberately never touches). */ | |
| const DEFAULT_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; | |
| /** Interval in ms from `CUSTOM_MODEL_SYNC_INTERVAL_MS`; 0 disables the pass, | |
| * anything unset or malformed falls back to the daily default. */ | |
| export function customModelSyncIntervalMs(): number { | |
| const raw = process.env.CUSTOM_MODEL_SYNC_INTERVAL_MS; | |
| if (raw === undefined || raw.trim() === '') return DEFAULT_SYNC_INTERVAL_MS; | |
| const ms = Number(raw); | |
| return Number.isFinite(ms) && ms >= 0 ? ms : DEFAULT_SYNC_INTERVAL_MS; | |
| } | |
| /** Comma-separated glob patterns of model ids that are known-free (#746). | |
| * When set, the sync registers ONLY models matching a pattern β anything else | |
| * (presumably paid) is skipped, honoring the repo's free-only policy. When | |
| * unset, the sync keeps its legacy behavior of registering everything, so | |
| * existing operators are unaffected. */ | |
| export function customModelSyncFreePatterns(): string[] { | |
| const raw = process.env.CUSTOM_MODEL_SYNC_FREE_PATTERNS; | |
| if (raw === undefined || raw.trim() === '') return []; | |
| return raw.split(',').map(p => p.trim()).filter(p => p.length > 0); | |
| } | |
| /** Glob match where `*` matches any run of chars (incl. empty). */ | |
| function patternMatches(pattern: string, id: string): boolean { | |
| const escaped = pattern.split('*').map(seg => seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); | |
| return new RegExp(`^${escaped.join('.*')}$`).test(id); | |
| } | |
| export interface CustomModelSyncResult { | |
| endpoints: number; | |
| /** Models registered for the first time. */ | |
| added: number; | |
| /** Models already on this endpoint that were skipped. */ | |
| skipped: number; | |
| /** Models skipped because they matched no free pattern (#746). */ | |
| paidSkipped: number; | |
| /** Models skipped because the operator deleted them and they must stay deleted (#926). */ | |
| tombstoned: number; | |
| /** Models skipped because they are discernibly not chat models β embedding, | |
| * image, audio, transcription or video ids (#1051). Registering those as | |
| * chat models is how they used to 404 forever. */ | |
| nonChatSkipped: number; | |
| failures: Array<{ baseUrl: string; error: string }>; | |
| } | |
| /** Sync every configured custom endpoint once. Exported so tests (and an admin | |
| * that wants a manual pass) can run it directly without waiting for the timer. */ | |
| export async function runCustomModelSync(db: Db): Promise<CustomModelSyncResult> { | |
| const endpoints = listCustomEndpoints(db); | |
| const result: CustomModelSyncResult = { endpoints: endpoints.length, added: 0, skipped: 0, paidSkipped: 0, tombstoned: 0, nonChatSkipped: 0, failures: [] }; | |
| for (const endpoint of endpoints) { | |
| try { | |
| // A submitted key wins on the manual route; a scheduled pass can only use | |
| // what the endpoint already has on record. Keyless local servers keep the | |
| // 'no-key' sentinel, which the provider turns into an anonymous call | |
| // with no Authorization header (#1331). | |
| const discovered = await discoverEndpointModels(endpoint.baseUrl, endpoint.apiKey ?? 'no-key'); | |
| const scope = endpointScopeForBaseUrl(endpoint.baseUrl); | |
| const registeredIds = new Set( | |
| (db.prepare("SELECT model_id FROM models WHERE platform = 'custom' AND endpoint_scope = ?").all(scope) as { model_id: string }[]) | |
| .map(row => row.model_id), | |
| ); | |
| const freePatterns = customModelSyncFreePatterns(); | |
| const fresh: CustomModelEntry[] = []; | |
| for (const model of discovered) { | |
| if (registeredIds.has(model.id)) { | |
| result.skipped += 1; | |
| continue; | |
| } | |
| // #926: the operator deleted this model (or the key that owned it) and | |
| // expects it to stay gone. The sync's "add only" contract must not turn | |
| // a deliberate deletion into the next pass's "new model". | |
| if (isCustomModelTombstoned(db, scope, model.id)) { | |
| result.tombstoned += 1; | |
| continue; | |
| } | |
| // Free-only policy (#746): when FREE_PATTERNS is configured, a model | |
| // that matches none of the known-free patterns is presumably paid and | |
| // is skipped rather than silently registered. | |
| if (freePatterns.length > 0 && !freePatterns.some(p => patternMatches(p, model.id))) { | |
| result.paidSkipped += 1; | |
| continue; | |
| } | |
| // #1051: a diffusion/whisper/video/embedding id is not a chat model, | |
| // and registering it here is how it ends up 404ing in every chain. | |
| // The sync only ADDS chat models; media and embedding models go | |
| // through their own explicit registration paths. | |
| if (model.kind !== undefined) { | |
| result.nonChatSkipped += 1; | |
| continue; | |
| } | |
| // Bare id only, like the dashboard's bulk "Fetch models" submit: no name | |
| // or capability claims invented here, they resolve in SQL on insert. | |
| fresh.push({ modelId: model.id, displayName: null }); | |
| } | |
| if (fresh.length > 0) { | |
| const registered = registerCustomModels(db, endpoint.baseUrl, undefined, undefined, endpoint.keyId, fresh); | |
| result.added += registered.registered.filter(m => m.created).length; | |
| } | |
| } catch (err: any) { | |
| const message = err?.message ?? String(err); | |
| result.failures.push({ baseUrl: endpoint.baseUrl, error: message }); | |
| console.error(`[custom-model-sync] ${endpoint.baseUrl}: ${message}`); | |
| } | |
| } | |
| return result; | |
| } | |
| /** Register the daily pass on the server's scheduler. Returns null when the | |
| * interval is configured to 0 (disabled). */ | |
| export function startCustomModelSync(db: Db, scheduler: Scheduler): (() => void) | null { | |
| const intervalMs = customModelSyncIntervalMs(); | |
| if (intervalMs <= 0) return null; | |
| return scheduler.every(intervalMs, () => { void runCustomModelSync(db); }, { name: 'custom-model-sync' }); | |
| } | |