File size: 18,892 Bytes
dcedb25 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | /**
* 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;
}
|