// Served-model drift guard (#534 follow-up). // // The gateway deliberately overwrites the upstream `model` field with the // routed model id before the caller sees it — the public contract exposes the // model that was ROUTED, because provider metadata is unreliable (Reka returns // the literal placeholder "default" for concrete models, #568). That overwrite // also destroys the only evidence when a provider silently serves a DIFFERENT // model than requested (verified live: kilo-auto/small serving Gemma; // kilo-auto/free already drifted once). This module receives the raw upstream // value — captured at the proxy boundary BEFORE the overwrite — compares it // against the routed id ignoring cosmetic spelling differences, and reports // genuine drift two ways: // - a structured `[ServedModel]` warn log, once per (platform, requested, // served) tuple per process, so a busy drifted route cannot spam the log; // - the raw served id for persistence in requests.served_model (NULL when // it matches, which keeps the column empty in the healthy case). // The response contract is untouched: callers keep seeing the routed model. // Cosmetic differences a provider may legitimately introduce when echoing the // model id back. Anything that survives normalization on BOTH sides and still // differs is treated as drift. // 1. case-insensitive; // 2. one trailing pricing/routing tier tag (":free"-style — OpenRouter and // friends: :free, :beta, :extended, :nitro, :standard); // 3. provider/org namespacing — only the last '/'-segment identifies the // model ("meta-llama/llama-3.3-70b" echoes "llama-3.3-70b"); // 4. separator spelling — runs of whitespace/underscores/hyphens are one // separator (mirrors services/model-groups.ts normalizeGroupKey, which // applies the same rule to display names). export function normalizeModelIdForDrift(id: string): string { let s = (id ?? '').trim().toLowerCase(); s = s.replace(/:(free|beta|extended|nitro|standard)$/, ''); const lastSlash = s.lastIndexOf('/'); if (lastSlash >= 0) s = s.slice(lastSlash + 1); return s.replace(/[\s_-]+/g, '-').trim(); } // Upstream values that name no model at all — comparing them proves nothing. // "default" is Reka's documented placeholder; empty/unknown guard against // sloppy OpenAI-compatible shims. const PLACEHOLDER_MODEL_VALUES = new Set(['', 'default', 'unknown']); // Once-per-tuple warn cache. Bounded: a hostile/broken upstream that invents a // fresh id per response must not grow this set without limit — past the cap we // keep persisting drift but stop adding (and therefore logging) new tuples. const MAX_OBSERVED_TUPLES = 1000; const observedTuples = new Set(); /** Test hook: clear the once-per-tuple warn cache. */ export function resetServedModelObservations(): void { observedTuples.clear(); } /** * Compare the raw upstream-reported model against the routed id. * Returns the raw served id when it GENUINELY differs (persist it), or null * when it matches, differs only cosmetically, or names no model at all * (store NULL — keeps the requests table small). Logs a structured warn once * per (platform, requested, served) tuple per process. */ export function observeServedModel(opts: { platform: string; requestedModel: string; servedModel: string | null | undefined; }): string | null { const { platform, requestedModel, servedModel } = opts; if (typeof servedModel !== 'string') return null; const served = servedModel.trim(); if (PLACEHOLDER_MODEL_VALUES.has(served.toLowerCase())) return null; if (normalizeModelIdForDrift(requestedModel) === normalizeModelIdForDrift(served)) return null; const tuple = `${platform}${requestedModel}${served}`; if (!observedTuples.has(tuple) && observedTuples.size < MAX_OBSERVED_TUPLES) { observedTuples.add(tuple); console.warn( `[ServedModel] platform=${platform} requested=${requestedModel} served=${served} ` + `— upstream reported a different model than the routed id (#534). ` + `Response contract unchanged (clients still see the routed model); drift recorded in requests.served_model.`, ); } return served; }