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}${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;
}