Model Catalog Drift: How to Design an Integration Layer That Survives Unannounced Model Changes

    An LLM model catalog is data that evolves independently of official releases, so a model ID that exists today can disappear tomorrow — and when business code hardcodes it, that entry becomes an outage. This article breaks down the four causes of catalog drift (probing ahead of release, community contributions ahead of announcements, uneven regional rollouts, unannounced removals) and the four places the risk actually lands: deployment-time failure, semantic drift, cost and rate-limit drift, and invalidated evaluation baselines. It then lays out a four-layer decoupling design: a single model registry with alias-only references, backend abstraction with capability-based routing, startup probes plus contract tests, and shadow regression with canary rollback. It closes with the observability dimensions that make the design verifiable, a mapping to generic cloud capabilities, and three criteria for judging an unreleased model signal.

    1. What happened: a menu item appears, then gets crossed out

    Start with a representative incident. An open-source proxy tool that fronts multiple model providers behind one unified API added a model identifier to its model list — one that no vendor had officially released yet. A day later, the entry was gone. The commit message explained why: the upstream cloud platform had not listed that model.

    The technical meaning matters more than the entertainment value. A model catalog is data that can be modified independently and evolves independently of the official release cadence. It is maintained by a community, generated by automated sync scripts, and backfilled by upstream discovery endpoints. Any timing gap in that chain leaves a “ghost entry” in the list. And once business code references one of those entries, a piece of data turns into an incident.

    2. Four sources of catalog drift

    Understanding the sources tells you when to follow the catalog and when to wait.

    1. Probing and publishing are out of sync. The catalog generation script periodically pulls the model list from upstream APIs, but upstream canary, pre-release, and internal load-testing environments often expose identifiers before the official release does. The script cannot tell “probeable” apart from “commercially available.”
    2. Community contributions run ahead of announcements. Open-source catalogs accept community pull requests, and a contributor may be on a canary allowlist with a genuinely usable identifier but no public release to cite.
    3. Regions move at different speeds. The same model may go live in different regions days or even weeks apart. The catalog takes a “union” view; your traffic takes a “single region” view. The two are structurally misaligned.
    4. Rollbacks and removals ship without announcements. Deprecations, renames, and changes to parameter semantics often show up only in the catalog diff and never in release notes. Deleting a row costs almost nothing; debugging a production anomaly costs a great deal.

    3. The risk is not in the model — it’s in your call chain

    Translated into engineering terms, the exposure sits in four places.

    Deployment-time failure. The model name is hardcoded in business code or front-end config. When the identifier disappears, the service fails at startup — or worse, the upstream silently rewrites the request to some other default model, so behavior drifts with no alert.

    Semantic drift. Even if the model name is still valid, the context window, maximum output length, tool-calling protocol, structured-output constraints, and valid ranges for sampling parameters can all change. Your prompts, truncation strategy, and JSON parsing logic are all implicit dependencies.

    Cost and rate-limit drift. Quotas, concurrency limits, and per-token billing change with the model. An operation that feels like “just a rename” can throw off both the monthly bill and P99 latency.

    Invalidated evaluation baselines. You think you are comparing two models; you are actually comparing two endpoints that have already changed. Without a self-owned evaluation set, a team has almost no way to notice.

    The conclusion is straightforward: do not treat a model name as part of your code. Treat it as one possible value in configuration, and put a contract around that value.

    4. A four-layer decoupling design

    4.1 Layer one: one source of truth for model IDs

    Exactly one module in the project is allowed to know real model identifiers. Business code references aliases only.

    1. # model_registry.py -- the source of truth for models; injected from config, never hardcoded
    2. from dataclasses import dataclass
    3. @dataclass(frozen=True)
    4. class ModelSpec:
    5. alias: str # the only name business code ever sees
    6. upstream_id: str # injected from env vars / config center
    7. context_window: int
    8. max_output_tokens: int
    9. supports_tool_call: bool
    10. supports_structured_output: bool
    11. temperature_range: tuple[float, float]
    12. timeout_s: int
    1. # routes.yaml -- everything tied to an environment or region is externalized
    2. default: { upstream_id: "${CHAT_DEFAULT_MODEL}", temperature: 0.2, timeout_s: 30 }
    3. fast: { upstream_id: "${CHAT_FAST_MODEL}", temperature: 0.0, timeout_s: 10 }
    4. reasoner: { upstream_id: "${CHAT_REASON_MODEL}", temperature: 0.3, timeout_s: 120 }

    Back this with a CI rule that blocks hardcoded identifiers before merge:

    1. # Business code must not contain model literals; it may only reference aliases
    2. ! grep -rEn 'model\s*=\s*"[^"$]+"' --include='*.py' src/ | grep -v 'model_registry.py'

    4.2 Layer two: backend abstraction and capability declaration

    Business code depends on an interface, not on a specific client.

    1. from typing import Protocol
    2. class ChatBackend(Protocol):
    3. def complete(self, messages: list[dict], *, spec: ModelSpec, **opts) -> str: ...
    4. def probe(self, spec: ModelSpec) -> "ProbeResult": ...

    On top of that, route on capabilities rather than model names. A task declares what it needs; the router picks the highest-priority model that meets the requirement, degrades when it cannot, and fails explicitly when nothing qualifies — never silently falls back.

    1. def route(task: Task, registry: dict[str, ModelSpec], probes: dict[str, ProbeResult]) -> ModelSpec:
    2. for alias in task.preference_order: # e.g. ["reasoner", "default", "fast"]
    3. spec = registry.get(alias)
    4. if spec is None or not probes.get(alias, ProbeResult.ok).ok:
    5. continue
    6. if satisfies(task.requirements, spec): # context window / tool calls / structured output
    7. return spec
    8. raise NoCapableModel(f"no model meets the requirements: {task.requirements}")

    4.3 Layer three: startup probes and contract tests

    Verify “it exists in the catalog” and “I can actually use it” separately. Probes make the cheapest possible call: no long context, nothing that touches a billing-sensitive path.

    1. @dataclass
    2. class ProbeResult:
    3. ok: bool
    4. reason: str = ""
    5. echo_model: str = "" # model echoed back by upstream; used to detect silent rewrites
    6. def probe(spec: ModelSpec) -> ProbeResult:
    7. payload = {
    8. "model": spec.upstream_id,
    9. "messages": [{"role": "user", "content": "ping"}],
    10. "max_tokens": 16,
    11. "stream": False,
    12. }
    13. try:
    14. resp = http.post(endpoint, json=payload, timeout=5)
    15. except TimeoutError:
    16. return ProbeResult(False, "timeout")
    17. if resp.status_code != 200:
    18. return ProbeResult(False, f"http_{resp.status_code}")
    19. body = resp.json()
    20. return ProbeResult(True, echo_model=body.get("model", ""))

    Validate at startup and refuse to boot on failure (fail fast), so an unusable route never reaches production:

    1. def assert_routes_ready(registry: dict[str, ModelSpec]) -> dict[str, ProbeResult]:
    2. results = {}
    3. for alias, spec in registry.items():
    4. r = probe(spec)
    5. if not r.ok:
    6. raise RuntimeError(f"route {alias} is unusable: {r.reason}")
    7. if r.echo_model and r.echo_model != spec.upstream_id:
    8. log.warning("echoed model mismatch; suspected silent upstream rewrite", alias=alias, echoed=r.echo_model)
    9. results[alias] = r
    10. return results

    Contract tests should assert the properties your business actually relies on, not merely “does it return a string”: can structured output be parsed strictly, is the long-context truncation point the same, is the tool-call response format still compatible, and does an over-long input raise an error or get truncated silently. Check these assertions into version control and re-run them on every model change.

    4.4 Layer four: shadow regression and canary rollback

    Switching a model is equivalent to an infrastructure change and should go through a release process, not a one-line config edit.

    1. def regression_diff(candidate: ModelSpec, baseline: ModelSpec, cases: list[Case]) -> list[dict]:
    2. rows = []
    3. for case in cases:
    4. a = call(baseline, case)
    5. b = call(candidate, case)
    6. rows.append({
    7. "case": case.id,
    8. "output_changed": a.text != b.text,
    9. "schema_valid": b.json_valid, # structured-output contract
    10. "tool_call_ok": b.tool_call_ok,
    11. "latency_ms": b.latency_ms,
    12. "tokens_in": b.usage.input, "tokens_out": b.usage.output,
    13. "cost_delta": b.cost - a.cost,
    14. })
    15. return rows

    Write the promotion gates into the pipeline explicitly: structured-output parse success rate must not drop, tool-call success rate must not drop, P95 latency must not increase beyond the agreed threshold, and per-request cost deviation must stay within budget. If any one of the four fails, the candidate model is not rolled out.

    Keep a one-click rollback path as well. Version the alias table so a rollback switches configuration without a code redeploy, and audit every rollback action.

    5. Observability: treat the model as a dimension

    Without these metrics and dimensions in your monitoring, none of the work above can be verified as effective.

    • Model dimension: alias, actual upstream model, and region as standard labels on request volume, latency, error rate, and cost.
    • Echo consistency: the share of requests where the configured model differs from the echoed model — the most direct signal of a silent rewrite.
    • Probe health: success rate and latency per route, retained at minute granularity.
    • Request feature fingerprints: input length buckets, whether truncation was triggered, output length, and whether a fallback was hit, for post-hoc attribution.
    • Cost deviation: how far the actual unit price has drifted from the budget model, with alerts routed to an owner once a threshold is crossed.
    • Change audit: who changed the alias table, on what basis, and where the rollback point is.

    6. Mapping to generic cloud platform capabilities

    Nothing in this design requires a proprietary component; standard cloud capabilities cover it.

    Engineering step Generic capability category What it provides
    Alias table distribution and environment isolation Configuration management / config center Versioned routing tables, canary rollout, second-level rollback
    Evaluation sets and shadow traffic retention Object storage Archiving of test cases, recorded samples, regression reports
    Peak smoothing for batch evaluation Message queue Async regression jobs, failure retries, result aggregation
    Integration layer and probe rollout Container platform Gateway canary, probe containers, health checks
    Model-dimension search Log service Fast lookup of anomalous batches by alias / model / fingerprint
    Availability and cost alerting Monitoring and alerting Probe failures, echo mismatches, cost deviation
    Credential management Secrets management Upstream credentials never in code, isolated per environment

    In the general-purpose product lineup of Baidu AI Cloud, configuration management, log service, monitoring and alerting, object storage, container platform, and message queue can each map to one of the steps above, combining into a complete chain from “edit a config” to “a rollback-safe release.”

    7. How to judge an “unreleased signal”

    Three actionable criteria keep a team from burning time in an observation window.

    1. Trust only stable identifiers in official documentation. Catalog entries, probe results, and community contributions are not evidence of a release. An extra dish on the menu does not mean the kitchen has the ingredients.
    2. Separate three different things: catalog listing, quota, and SLA. Appearing in a list only means “addressable” — it says nothing about quota, regional coverage, or an availability commitment. Missing any one of the three, it should not carry production traffic.
    3. Set an explicit observation window. Fix a time window and a set of validation gates (reachable in multiple regions, stable responses, all contract tests green). During the window, use the model only in an isolated environment, never on a real user path.

    8. Summary

    A ghost entry in a catalog is not an incident by itself; writing it into code is. What determines the resilience of your system is not how fast you chase model changes, but how insensitive your call chain is to them: converge model names into a single registry, replace hardcoded model names with capability declarations, replace verbal trust with startup probes, replace intuition with shadow regression, and replace overtime with canary rollback. Make those five things default behavior, and the next crossed-out menu item is just a notification instead of a production investigation.