Download server/src/services/model-groups.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 18.9 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/model-groups.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/services/model-groups.ts
-
curl -L -o model-groups.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/services/model-groups.ts
18.9 kB
| /** | |
| * Model grouping β "unify" the same logical model that several providers serve | |
| * into ONE item. The `models` table keeps one row per (platform, model_id); a | |
| * model offered by N providers is N rows. This module computes a logical group | |
| * for those rows at runtime (NO schema change) from the curated `display_name`, | |
| * plus operator overrides stored as JSON in the existing `settings` table. | |
| * | |
| * Pure by design: the core functions (normalizeGroupKey / groupRows / | |
| * resolveRequestedIdToMembers) take rows as arguments and touch no globals, so | |
| * they're trivially unit-testable. Only the settings getters/setters and the | |
| * getModelGroups() convenience touch the DB. | |
| * | |
| * Gated by the `unify_models_enabled` setting (default ON). When OFF, callers | |
| * keep their pre-unification behavior. | |
| */ | |
| import { z } from 'zod'; | |
| import { getDb, getSetting, setSetting } from '../db/index.js'; | |
| import { | |
| ENDPOINT_ID_SEPARATOR, | |
| endpointRefMatches, | |
| groupRefMatches, | |
| qualifiedModelMemberId, | |
| } from '../lib/endpoint-scope.js'; | |
| // ββ Settings keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export const UNIFY_ENABLED_KEY = 'unify_models_enabled'; | |
| export const UNIFY_OVERRIDES_KEY = 'model_unify_overrides'; | |
| export const unifyOverridesSchema = z.object({ | |
| // Coalesce several grouping tokens into one group keyed by `into`. Each key is | |
| // a normalized display-name OR an exact "platform:model_id" member id. | |
| merges: z.array(z.object({ | |
| into: z.string().min(1), | |
| keys: z.array(z.string().min(1)).min(1), | |
| })).default([]), | |
| // Force a specific "platform:model_id" row out of its computed group into a | |
| // singleton (or into an explicit groupKey). | |
| splits: z.array(z.object({ | |
| member: z.string().min(1), | |
| groupKey: z.string().optional(), | |
| })).default([]), | |
| }).default({ merges: [], splits: [] }); | |
| export type UnifyOverrides = z.infer<typeof unifyOverridesSchema>; | |
| const EMPTY_OVERRIDES: UnifyOverrides = { merges: [], splits: [] }; | |
| // ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // The minimal row shape grouping needs. Catalog queries select more columns; | |
| // extra fields are ignored here and preserved on `members`. | |
| export interface GroupableRow { | |
| model_db_id: number; | |
| platform: string; | |
| model_id: string; | |
| display_name: string; | |
| intelligence_rank?: number; | |
| // Which custom endpoint this row belongs to; '' (or absent) for every catalog | |
| // platform and for legacy un-scoped custom rows (#651). | |
| endpoint_scope?: string; | |
| // The operator's group label for that endpoint (#1176); null/absent when the | |
| // endpoint is in the plain "Custom" group or the row is not a custom one. | |
| endpoint_group?: string | null; | |
| } | |
| /** | |
| * How a resolved member was reached: by its own model_id (or a group the | |
| * operator built), versus only through a group's auto-derived slug. | |
| */ | |
| export type MatchTier = 'literal' | 'slug'; | |
| export interface TieredMember { | |
| modelDbId: number; | |
| tier: MatchTier; | |
| } | |
| export interface ModelGroup { | |
| groupKey: string; // normalized display name β the grouping identity | |
| canonicalId: string; // stable slug advertised on /v1/models | |
| groupLabel: string; // human label = representative member's stripped name | |
| members: GroupableRow[]; // all rows in the group (any enabled state) | |
| // True when an operator override (a merge or an explicit group key) put this | |
| // group together, rather than the catalog's display names doing it by | |
| // accident. A slug match on a name the user asserted themselves is a | |
| // first-class answer; one on an auto-derived slug is only a fallback. | |
| userDefined: boolean; | |
| } | |
| // ββ Settings accessors βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Unification is now always on: a model served by several providers is always | |
| // shown as one logical model that fails over across its providers. The on/off | |
| // toggle was removed from the UI, so a user who previously turned it off is | |
| // still unified. (setUnifyEnabled + the stored setting remain only so the | |
| // settings endpoint stays backward-compatible; the value is ignored here.) | |
| export function isUnifyEnabled(): boolean { | |
| return true; | |
| } | |
| export function setUnifyEnabled(on: boolean): void { | |
| setSetting(UNIFY_ENABLED_KEY, on ? '1' : '0'); | |
| } | |
| export function getUnifyOverrides(): UnifyOverrides { | |
| const raw = getSetting(UNIFY_OVERRIDES_KEY); | |
| if (!raw) return EMPTY_OVERRIDES; | |
| try { | |
| const parsed = unifyOverridesSchema.safeParse(JSON.parse(raw)); | |
| if (parsed.success) return parsed.data; | |
| } catch { /* corrupt JSON β safe default */ } | |
| return EMPTY_OVERRIDES; | |
| } | |
| export function setUnifyOverrides(input: unknown): UnifyOverrides { | |
| const norm = unifyOverridesSchema.parse(input); | |
| setSetting(UNIFY_OVERRIDES_KEY, JSON.stringify(norm)); | |
| return norm; | |
| } | |
| // ββ Normalization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Catalog display names tag the provider/variant in a trailing parenthetical: | |
| // "GPT-OSS 120B (Groq)", "Llama 3.3 70B (HF)", "... (free)". Strip ONE trailing | |
| // "(...)" (no nested parens) to recover the logical name. Genuine variants like | |
| // "Llama 3.3 70B fp8-fast (CF)" strip to "Llama 3.3 70B fp8-fast" β a DISTINCT | |
| // name that only merges into "Llama 3.3 70B" via an override (by design). | |
| export function stripProviderSuffix(displayName: string): string { | |
| let s = (displayName ?? '').trim(); | |
| // Iteratively drop trailing markers: a "(...)" provider/variant parenthetical, | |
| // or a standalone "free" word. "Free" is a pricing tier, not a different model | |
| // β "DeepSeek V4 Flash Free" is the same model as "DeepSeek V4 Flash" β so it's | |
| // normalized away so the two group together. (A model literally named just | |
| // "Free" is left alone: the regex needs a word before it.) | |
| let prev: string; | |
| do { | |
| prev = s; | |
| s = s.replace(/\s*\([^()]*\)\s*$/, '').trim(); | |
| s = s.replace(/\s+free$/i, '').trim(); | |
| } while (s !== prev); | |
| return s; | |
| } | |
| // The grouping identity: suffix-stripped, lowercased, with hyphens/underscores/ | |
| // whitespace all treated as one separator β so catalog spelling differences like | |
| // "Qwen3 Coder" vs "Qwen3-Coder" group together. Meaningful characters such as | |
| // '+' are kept, so "Command R" and "Command R+" stay distinct. | |
| export function normalizeGroupKey(displayName: string): string { | |
| return stripProviderSuffix(displayName).toLowerCase().replace(/[\s\-_]+/g, ' ').trim(); | |
| } | |
| // A stable, human-friendly slug for the API. Keeps digits and dots ("3.3"). | |
| export function slugifyGroupLabel(label: string): string { | |
| const slug = (label ?? '').toLowerCase() | |
| .replace(/[^a-z0-9.\s-]/g, '') | |
| .trim() | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-') | |
| .replace(/^-+|-+$/g, ''); | |
| return slug || 'model'; | |
| } | |
| // ββ Grouping βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function memberId(row: GroupableRow): string { | |
| return `${row.platform}:${row.model_id}`; | |
| } | |
| /** | |
| * The endpoint-qualified member id, or null when the row needs no qualifier. | |
| * Only custom rows that actually carry an endpoint scope have one, which is why | |
| * an install with a single relay never sees a qualified id anywhere (#651). | |
| */ | |
| export function qualifiedMemberId(row: GroupableRow): string | null { | |
| return qualifiedModelMemberId(row.platform, row.model_id, row.endpoint_scope); | |
| } | |
| /** The grouping token plus whether an operator override chose it. */ | |
| function tokenSourceForRow(row: GroupableRow, ov: UnifyOverrides): { token: string; userDefined: boolean } { | |
| const mid = memberId(row); | |
| // A qualified id names ONE relay's copy, so an override written against it | |
| // must only move that copy. Plain member ids keep matching as before, which is | |
| // what makes existing overrides survive untouched. | |
| const qid = qualifiedMemberId(row); | |
| const names = (candidate: string) => candidate === mid || (qid !== null && candidate === qid); | |
| const split = ov.splits.find(s => names(s.member)); | |
| if (split) { | |
| return split.groupKey | |
| ? { token: normalizeGroupKey(split.groupKey), userDefined: true } | |
| : { token: `__split__:${split.member}`, userDefined: true }; | |
| } | |
| const base = normalizeGroupKey(row.display_name); | |
| const merge = ov.merges.find(mg => mg.keys.some(k => names(k) || normalizeGroupKey(k) === base)); | |
| return merge | |
| ? { token: normalizeGroupKey(merge.into), userDefined: true } | |
| : { token: base, userDefined: false }; | |
| } | |
| // Assign a unique canonicalId to each group. Deterministic: groups sorted by | |
| // groupKey, slug collisions disambiguated with "-2", "-3", β¦ | |
| function assignCanonicalIds(groups: ModelGroup[]): void { | |
| const used = new Set<string>(); | |
| for (const g of [...groups].sort((a, b) => a.groupKey.localeCompare(b.groupKey))) { | |
| const base = slugifyGroupLabel(g.groupLabel); | |
| let cand = base; | |
| let n = 2; | |
| while (used.has(cand)) cand = `${base}-${n++}`; | |
| used.add(cand); | |
| g.canonicalId = cand; | |
| } | |
| } | |
| /** | |
| * Group catalog rows into logical models. Pure β pass overrides explicitly in | |
| * tests; defaults to the persisted overrides. | |
| */ | |
| export function groupRows(rows: GroupableRow[], ov: UnifyOverrides): ModelGroup[] { | |
| const map = new Map<string, ModelGroup>(); | |
| for (const row of rows) { | |
| const { token: key, userDefined } = tokenSourceForRow(row, ov); | |
| let g = map.get(key); | |
| if (!g) { | |
| g = { | |
| groupKey: key, canonicalId: '', groupLabel: stripProviderSuffix(row.display_name), | |
| members: [], userDefined: false, | |
| }; | |
| map.set(key, g); | |
| } | |
| g.members.push(row); | |
| g.userDefined ||= userDefined; | |
| } | |
| // Representative label = the best (lowest intelligence_rank) member, tiebroken | |
| // by shortest stripped name then model_db_id, so the label is deterministic. | |
| for (const g of map.values()) { | |
| const rep = [...g.members].sort((a, b) => | |
| (a.intelligence_rank ?? Number.MAX_SAFE_INTEGER) - (b.intelligence_rank ?? Number.MAX_SAFE_INTEGER) | |
| || stripProviderSuffix(a.display_name).length - stripProviderSuffix(b.display_name).length | |
| || a.model_db_id - b.model_db_id)[0]; | |
| g.groupLabel = stripProviderSuffix(rep.display_name); | |
| } | |
| const groups = [...map.values()]; | |
| assignCanonicalIds(groups); | |
| return groups; | |
| } | |
| /** | |
| * Resolve a requested model id to the db ids of its group members, or null. | |
| * | |
| * The ladder, most specific first: | |
| * 1. "custom:model_id#endpoint" β exactly that relay's copy (#651) β the only | |
| * form that can separate two endpoints offering the same model id. The | |
| * endpoint part accepts the short handle or the endpoint URL itself. | |
| * It may also name an endpoint GROUP (#1176): "custom:model_id#my-relays" | |
| * (or the bare "model_id#my-relays") β every copy of that model on the | |
| * group's endpoints, so the router fails over inside the group only. An | |
| * endpoint match wins over a group of the same name; | |
| * 2. "platform:model_id" β that platform's copies. Normally exactly one row, | |
| * so this is unchanged (#580: naming the platform means "this provider's | |
| * copy", no failover to the rest of the group). Two relays sharing a model | |
| * id are both 'custom', so this names both and the router picks the better | |
| * one β nothing an existing setup can notice, and no error to hit; | |
| * 3. a canonical group slug OR a bare `model_id` β the union of every group | |
| * that answers to it. Display name is presentation, not identity: it may | |
| * EXPAND what a bare id reaches (that is the unify feature β one name, | |
| * several providers) but it must never SHRINK it. Renaming one relay's | |
| * copy, or splitting it out for display, moves it to its own group; | |
| * stopping at the first match would strand the other relay, unreachable by | |
| * the id every client already has (#651). | |
| * | |
| * Member order here is incidental β the router re-orders by the active strategy. | |
| */ | |
| export function resolveRequestedIdToTieredMembers(requested: string, groups: ModelGroup[]): TieredMember[] | null { | |
| if (!requested) return null; | |
| const sepAt = requested.lastIndexOf(ENDPOINT_ID_SEPARATOR); | |
| if (sepAt > 0) { | |
| const base = requested.slice(0, sepAt); | |
| const endpointRef = requested.slice(sepAt + 1); | |
| for (const g of groups) { | |
| for (const m of g.members) { | |
| if (memberId(m) !== base) continue; | |
| if (m.endpoint_scope && endpointRefMatches(endpointRef, m.endpoint_scope)) { | |
| return [{ modelDbId: m.model_db_id, tier: 'literal' }]; | |
| } | |
| } | |
| } | |
| const inGroup = groups.flatMap(g => g.members.filter(m => | |
| m.platform === 'custom' | |
| && (memberId(m) === base || m.model_id === base) | |
| && groupRefMatches(endpointRef, m.endpoint_group))); | |
| if (inGroup.length > 0) { | |
| return inGroup.map(m => ({ modelDbId: m.model_db_id, tier: 'literal' as MatchTier })); | |
| } | |
| } | |
| // Exact "platform:model_id". Checked before the bare model_id scan; a bare | |
| // model_id that itself contains a colon (e.g. Ollama's "gpt-oss:120b") never | |
| // collides because platforms aren't model-name prefixes. | |
| const exact = groups.flatMap(g => g.members.filter(m => memberId(m) === requested)); | |
| if (exact.length > 0) { | |
| return exact.map(m => ({ modelDbId: m.model_db_id, tier: 'literal' as MatchTier })); | |
| } | |
| // Canonical slug and bare model id resolve TOGETHER, as an ORDERED union. | |
| // | |
| // Keeping them as separate early-returns let a slug shadow the id: rename one | |
| // relay's copy and its group's slug is no longer 'deepseek-v3.1', while the | |
| // untouched relay's group still slugs to exactly that β so the bare id matched | |
| // one group by slug, returned early, and the renamed relay became unreachable. | |
| // | |
| // But the two matches are not equally intended. A canonicalId is a SLUG of a | |
| // display name, so it can coincide with an unrelated model's literal id (the | |
| // shipped catalog has one: Cloudflare's "Gemma 4 26B-A4B it" slugs to | |
| // `gemma-4-26b-a4b-it`, Google's literal model_id in another group). A request | |
| // written literally must therefore try the literal model FIRST; a coincidental | |
| // slug sibling is a fallback that may serve only once the real one is | |
| // exhausted. Groups the operator built by hand rank with the literal matches β | |
| // they asserted that name themselves. | |
| const literal: TieredMember[] = []; | |
| const slugOnly: TieredMember[] = []; | |
| const seen = new Set<number>(); | |
| for (const g of groups) { | |
| const byMember = g.members.some(m => m.model_id === requested); | |
| if (!byMember && g.canonicalId !== requested) continue; | |
| const bucket = byMember || g.userDefined ? literal : slugOnly; | |
| const tier: MatchTier = bucket === literal ? 'literal' : 'slug'; | |
| for (const m of g.members) { | |
| if (seen.has(m.model_db_id)) continue; | |
| seen.add(m.model_db_id); | |
| bucket.push({ modelDbId: m.model_db_id, tier }); | |
| } | |
| } | |
| const matched = [...literal, ...slugOnly]; | |
| return matched.length > 0 ? matched : null; | |
| } | |
| /** | |
| * The same resolution, flattened to db ids in tier order. Callers that dispatch | |
| * a request should prefer the tiered form and pass `demotedMemberIds()` to the | |
| * router, so a fallback match cannot outrank a literal one on score alone. | |
| */ | |
| export function resolveRequestedIdToMembers(requested: string, groups: ModelGroup[]): number[] | null { | |
| return resolveRequestedIdToTieredMembers(requested, groups)?.map(m => m.modelDbId) ?? null; | |
| } | |
| /** The subset of a tiered resolution that was reached only through a slug. */ | |
| export function demotedMemberIds(members: readonly TieredMember[]): Set<number> { | |
| return new Set(members.filter(m => m.tier === 'slug').map(m => m.modelDbId)); | |
| } | |
| export interface DispatchMembers { | |
| /** Every member, in tier order. */ | |
| memberDbIds: number[]; | |
| /** Those of them that are fallbacks β hand this to resolveModelGroupCandidates. */ | |
| demotedDbIds: Set<number>; | |
| } | |
| /** | |
| * One-call resolution for the request path: the member ids to route over plus | |
| * the ones that must not outrank a literal match on score (#651). | |
| */ | |
| export function resolveRequestedIdForDispatch( | |
| requested: string, | |
| groups: ModelGroup[], | |
| ): DispatchMembers | null { | |
| const tiered = resolveRequestedIdToTieredMembers(requested, groups); | |
| if (!tiered) return null; | |
| return { memberDbIds: tiered.map(m => m.modelDbId), demotedDbIds: demotedMemberIds(tiered) }; | |
| } | |
| // ββ DB convenience βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // #1047: GET /api/fallback calls this on every request, and grouping the whole | |
| // catalog (regex + canonical-id assignment over hundreds of rows) dominated the | |
| // handler once the chain read went catalog-wide (#1023). The SELECT itself is a | |
| // cheap pk scan, so run it every time and memoize only the expensive grouping, | |
| // keyed on a fingerprint of the exact inputs β any row change, in place or not, | |
| // and any override change recomputes, so this can never serve stale groups. | |
| let groupsCache: { fingerprint: string; groups: ModelGroup[] } | null = null; | |
| /** | |
| * Group the whole catalog (enabled + disabled rows so availability can be shown | |
| * and resolution is complete), applying the persisted overrides. | |
| */ | |
| export function getModelGroups(): ModelGroup[] { | |
| const db = getDb(); | |
| const rows = db.prepare(` | |
| SELECT m.id as model_db_id, m.platform, m.model_id, m.display_name, m.intelligence_rank, | |
| m.endpoint_scope, | |
| (SELECT k.group_label FROM api_keys k | |
| WHERE m.platform = 'custom' AND m.endpoint_scope != '' | |
| AND k.platform = 'custom' AND rtrim(k.base_url, '/') = m.endpoint_scope | |
| AND k.group_label IS NOT NULL | |
| ORDER BY k.id LIMIT 1) AS endpoint_group | |
| FROM models m | |
| ORDER BY m.id | |
| `).all() as GroupableRow[]; | |
| const overrides = getUnifyOverrides(); | |
| let fingerprint = JSON.stringify(overrides); | |
| for (const r of rows) { | |
| fingerprint += ` ${r.model_db_id}${r.platform}${r.model_id}${r.display_name}${r.intelligence_rank}${r.endpoint_scope ?? ''}${r.endpoint_group ?? ''}`; | |
| } | |
| if (groupsCache?.fingerprint === fingerprint) return groupsCache.groups; | |
| const groups = groupRows(rows, overrides); | |
| groupsCache = { fingerprint, groups }; | |
| return groups; | |
| } | |