Spaces:
Sleeping
Sleeping
| /** | |
| * 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'; | |
| // ββ 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; | |
| } | |
| 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) | |
| } | |
| // ββ 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 grouping token for a row, after applying overrides. Split wins first | |
| // (forces a singleton/explicit key); then a merge redirects to its target; | |
| // otherwise the normalized display name. | |
| function tokenForRow(row: GroupableRow, ov: UnifyOverrides): string { | |
| const mid = memberId(row); | |
| const split = ov.splits.find(s => s.member === mid); | |
| if (split) return split.groupKey ? normalizeGroupKey(split.groupKey) : `__split__:${mid}`; | |
| const base = normalizeGroupKey(row.display_name); | |
| const merge = ov.merges.find(mg => mg.keys.some(k => k === mid || normalizeGroupKey(k) === base)); | |
| return merge ? normalizeGroupKey(merge.into) : base; | |
| } | |
| // 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 key = tokenForRow(row, ov); | |
| let g = map.get(key); | |
| if (!g) { | |
| g = { groupKey: key, canonicalId: '', groupLabel: stripProviderSuffix(row.display_name), members: [] }; | |
| map.set(key, g); | |
| } | |
| g.members.push(row); | |
| } | |
| // 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. | |
| * Accepts the canonical slug OR any member's `model_id`/"platform:model_id" | |
| * (back-compat: an old per-provider id resolves to the whole group). Member | |
| * order here is incidental β the router re-orders by the active strategy. | |
| */ | |
| export function resolveRequestedIdToMembers(requested: string, groups: ModelGroup[]): number[] | null { | |
| if (!requested) return null; | |
| const byCanonical = groups.find(g => g.canonicalId === requested); | |
| if (byCanonical) return byCanonical.members.map(m => m.model_db_id); | |
| for (const g of groups) { | |
| if (g.members.some(m => m.model_id === requested || memberId(m) === requested)) { | |
| return g.members.map(m => m.model_db_id); | |
| } | |
| } | |
| return null; | |
| } | |
| // ββ DB convenience βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * 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 | |
| FROM models m | |
| `).all() as GroupableRow[]; | |
| return groupRows(rows, getUnifyOverrides()); | |
| } | |