File size: 4,219 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 | // 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<string>();
/** 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} |