<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sanghyeok Yoon</title>
    <description>The latest articles on DEV Community by Sanghyeok Yoon (@zerokdevops).</description>
    <link>https://dev.to/zerokdevops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4161480%2F62e72d21-9aad-4f89-bd57-fdabcaf2c8e9.png</url>
      <title>DEV Community: Sanghyeok Yoon</title>
      <link>https://dev.to/zerokdevops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zerokdevops"/>
    <language>en</language>
    <item>
      <title>LLM gateway on-call: the 4 incidents that actually happen</title>
      <dc:creator>Sanghyeok Yoon</dc:creator>
      <pubDate>Mon, 05 Oct 2026 22:15:00 +0000</pubDate>
      <link>https://dev.to/zerokdevops/llm-gateway-on-call-the-4-incidents-that-actually-happen-4755</link>
      <guid>https://dev.to/zerokdevops/llm-gateway-on-call-the-4-incidents-that-actually-happen-4755</guid>
      <description>&lt;p&gt;An LLM gateway is a small system with a big trust load. It's small enough for one on-call to own, and it holds your org's model credentials, its spend attribution and its audit trail. That combination shapes how you run it: conservative where the trust is, pragmatic everywhere else.&lt;/p&gt;

&lt;p&gt;The runbook set I keep for one has four incidents, not forty. These are the ones that actually happen, and every runbook has the same four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detect&lt;/strong&gt;: the metric or the report that tells you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assess&lt;/strong&gt;: the &lt;em&gt;one&lt;/em&gt; query that sizes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Act&lt;/strong&gt;: the sequence, in order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close&lt;/strong&gt;: the artifact you leave behind.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last step is the one teams skip. An incident without its artifact is a memory, and the memory is the next incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, decide what is allowed to page
&lt;/h2&gt;

&lt;p&gt;Before any runbook, the pager needs rules, or every incident review starts with "we ignored the alert". Three severities with fixed meanings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;info&lt;/strong&gt;: a forecast. No action needed; the owner glances at it. Goes to the team channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;warn&lt;/strong&gt;: act this week. Team channel plus the team lead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crit&lt;/strong&gt;: spend or integrity is at risk &lt;em&gt;now&lt;/em&gt;. The platform on-call channel (page-worthy) plus a webhook into the incident system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two rules keep the pager trusted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fire on state entry, not state persistence.&lt;/strong&gt; Crossing 95% of a budget fires once. Sitting at 96% doesn't fire again. A re-fire is allowed after a 24-hour cooldown, and only if the state got worse by a level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One escalation, then stop.&lt;/strong&gt; An unacknowledged &lt;code&gt;crit&lt;/code&gt; escalates once, after 4 hours, then goes quiet. An alert that pages forever gets muted, and a muted &lt;code&gt;crit&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how my default thresholds split. Treat them as starting points to tune, not laws:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;warn (this week)&lt;/th&gt;
&lt;th&gt;crit (now)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Budget, per team or feature&lt;/td&gt;
&lt;td&gt;95% of the hard limit&lt;/td&gt;
&lt;td&gt;100% (hard) and 110% (overage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost anomaly&lt;/td&gt;
&lt;td&gt;a feature's 7-day cost ≥ 3× its 30-day average&lt;/td&gt;
&lt;td&gt;a team's daily cost ≥ 5× its 30-day daily average, or one request over your per-request cap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metering completeness&lt;/td&gt;
&lt;td&gt;below the 99.9% SLO&lt;/td&gt;
&lt;td&gt;below 99.5% for an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider breaker&lt;/td&gt;
&lt;td&gt;open, and fail-over worked&lt;/td&gt;
&lt;td&gt;open, and the alias has no secondary (users get 503s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metering bus lag (p95)&lt;/td&gt;
&lt;td&gt;over 5 minutes&lt;/td&gt;
&lt;td&gt;no page: dashboards must say "stale" instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential age&lt;/td&gt;
&lt;td&gt;past its rotation schedule&lt;/td&gt;
&lt;td&gt;suspected leak: revoke first, investigate second&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the four incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  IR-1: a provider degrades (the most common one)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Detect.&lt;/strong&gt; The provider's circuit breaker opens, or your services start reporting &lt;code&gt;503 model_unavailable&lt;/code&gt; on an alias. The provider's status page is corroboration, not detection. Your breaker sees it first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assess.&lt;/strong&gt; One query: request outcomes by provider over the last 15 minutes. One provider failing is a provider incident. &lt;em&gt;Every&lt;/em&gt; provider failing at once is your gateway, and that's a different runbook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Act.&lt;/strong&gt; If the alias has a secondary binding, fail-over already happened. Your job is to confirm it: watch the share of responses marked &lt;code&gt;X-LLM-Router: failed-over=1&lt;/code&gt; (or metering events where &lt;code&gt;routed_to != requested_binding&lt;/code&gt;), and check the cost delta of the secondary model. If the alias has &lt;em&gt;no&lt;/em&gt; secondary, bind one in the model registry. That's a registry change, not a deploy, which is the whole point of routing through aliases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Close.&lt;/strong&gt; The record names the window, the fail-over share, the cost delta, and the provider's acknowledgment if one came. Add a note to that provider's availability history. Your provider mix is a portfolio, and you rebalance it on that evidence, not on loyalty.&lt;/p&gt;

&lt;h2&gt;
  
  
  IR-2: a cost anomaly (the one finance calls about)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Detect.&lt;/strong&gt; The &lt;code&gt;crit&lt;/code&gt; cost alert, or finance asking. Finance's question is the same alert with a slower detector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assess.&lt;/strong&gt; Look at the per-feature, per-user and per-model views, in that order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feature first.&lt;/strong&gt; A feature's spike is a workload change or a loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User second.&lt;/strong&gt; A principal's spike is a human, or a script holding someone's grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model third.&lt;/strong&gt; A model's spike is a price change, or traffic that failed over onto an expensive secondary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Act.&lt;/strong&gt; It depends on what you found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A loop.&lt;/strong&gt; The feature's hard budget should already be denying. Confirm the denials counter is climbing for that feature, then tell the team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A price change.&lt;/strong&gt; Force a price refresh in the registry, re-forecast the budget at the new price, and warn whoever reconciles the invoice to expect the variance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fail-over cost.&lt;/strong&gt; Review the binding. The secondary was the design; its price is the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Close.&lt;/strong&gt; Name the cause class (loop, price, fail-over, one-off), the cost, and the budget state afterwards. If a budget should change, that goes through the normal budget workflow, not the incident. &lt;strong&gt;The incident doesn't grant budgets; it finds them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  IR-3: an auth failure (the one that looks like everything)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Detect.&lt;/strong&gt; A wall of &lt;code&gt;401&lt;/code&gt;/&lt;code&gt;403&lt;/code&gt;, a degraded identity platform, or "my token works in staging but not in prod."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assess.&lt;/strong&gt; This is why denial reasons deserve their own label instead of one &lt;code&gt;unauthorized&lt;/code&gt; bucket. Split the denials by reason and the shape names the cause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;token_invalid&lt;/code&gt; spikes.&lt;/strong&gt; A JWKS problem: the identity platform rotated keys out from under the gateway, or the JWKS cache is stuck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;token_expired&lt;/code&gt; across the whole org.&lt;/strong&gt; A token-TTL misconfiguration, or a clock problem in the identity platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scope_missing&lt;/code&gt; or &lt;code&gt;role_denied&lt;/code&gt; in one team.&lt;/strong&gt; A grant change: someone's role changed, or a migration forgot a scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Act.&lt;/strong&gt; For the JWKS shape, force a JWKS refresh through the gateway's admin path and check the identity platform's key state. For the TTL shape, the fix is upstream in the token mint; your job meanwhile is checking whether the affected services can degrade safely. For the grant shape, restore the grant through the normal access workflow, so the fix carries its own audit trail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Close.&lt;/strong&gt; Record the shape, the blast radius (which teams, which services), the recovery action, and whether anyone used break-glass. If they did, the break-glass audit line goes in the record. This is the incident that touches the trust load, so the record matters most here.&lt;/p&gt;

&lt;h2&gt;
  
  
  IR-4: metering integrity (the one users never see)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Detect.&lt;/strong&gt; Metering completeness drops below the 99.9% SLO, the monthly reconciliation finds a gap, or bus lag alerts. Below 99.5% for an hour is an incident, because your books are now wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assess.&lt;/strong&gt; Find &lt;em&gt;where&lt;/em&gt; the events went missing. Missing from the bus is a transport loss. On the bus but missing from the rollups is an aggregation loss. Bus lag catches the first; completeness catches the sum. If lag is normal while completeness drops, look at the rollups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Act.&lt;/strong&gt; For a transport loss, follow the bus's own runbook while the gateway's bounded queue holds events, and watch completeness recover. For a rollup loss, recompute the rollups from the bus. The recompute is a job, and its run is itself an audit event.&lt;/p&gt;

&lt;p&gt;One rule I don't bend: &lt;strong&gt;never back-fill metering from the provider's invoice.&lt;/strong&gt; The invoice is the reconciliation's &lt;em&gt;check&lt;/em&gt;, not metering's &lt;em&gt;source&lt;/em&gt;. Back-fill from it and your books become the provider's books, with no team, feature or user attribution, and next month's reconciliation has two gaps instead of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Close.&lt;/strong&gt; Record the window, the lowest completeness during it, the recompute if one ran, and the expected variance for month-end, so the finance close isn't a surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the first query in the alert
&lt;/h2&gt;

&lt;p&gt;The cheapest on-call improvement I know: write each runbook's assess query into the alert itself, so whoever gets paged at 3 a.m. doesn't have to remember it. A Prometheus sketch with the metric names used above. Thresholds come from the table; the &lt;code&gt;for:&lt;/code&gt; durations are illustrative.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm-gateway&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# IR-1: breaker gauge, 1 = open in this sketch&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LLMProviderBreakerOpen&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;max by (provider) (llm_provider_breaker_state) == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;warn&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# raise to crit if the alias has no secondary&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;first_query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(provider,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outcome)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(increase(llm_requests_total[15m]))'&lt;/span&gt;

      &lt;span class="c1"&gt;# IR-4: the books are wrong&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LLMMeteringBooksWrong&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_metering_completeness &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;0.995&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;crit&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;first_query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;max_over_time(llm_metering_bus_lag_seconds[1h])'&lt;/span&gt;   &lt;span class="c1"&gt;# high = transport, normal = rollups&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LLMMeteringBelowSLO&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_metering_completeness &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;0.999&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;warn&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;# IR-2 (cost) fires from the budget service on state entry, not from here.&lt;/span&gt;
      &lt;span class="c1"&gt;# IR-3 first query: sum by (reason) (increase(llm_requests_total{outcome="unauthorized"}[1h]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost alerts deliberately live in the budget service, not in Prometheus. They come from rollups, they need the state-entry dedup, and they should carry the budget percent, the forecast and the top features in the webhook payload, so the person who gets it can act without opening five dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that never page but will bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Credential drift.&lt;/strong&gt; Every credential the gateway holds rotates on a schedule (90 days is my default for provider credentials) through a two-secret swap: mint the new version, verify it with a tiny tagged probe call, flip the binding, and only then retire the old one. On a suspected leak, the order flips: revoke first, investigate second. The schedule is also what bounds a leak's exposure window: a 90-day rotation means a 90-day exposure, not a forever one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The degrade-mode direct keys.&lt;/strong&gt; If the gateway is down, some services that can't afford the gap may keep a scoped, audited direct-provider key. Fine, but every such key needs a named owner and a deletion review date. A direct key without an owner is a static key with a runbook around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat sheet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IR-1, provider:&lt;/strong&gt; detect with the breaker; first query is outcomes by provider over 15 minutes; close with the window, fail-over share and cost delta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IR-2, cost:&lt;/strong&gt; detect with the &lt;code&gt;crit&lt;/code&gt; cost alert; assess feature, then user, then model; close with the cause class, cost and budget state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IR-3, auth:&lt;/strong&gt; detect the &lt;code&gt;401&lt;/code&gt;/&lt;code&gt;403&lt;/code&gt; wall; first query is denials by reason; close with the shape, blast radius and any break-glass use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IR-4, metering:&lt;/strong&gt; page below 99.5% completeness for an hour; first query is bus lag; close with the window, floor, recompute and month-end variance.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is a condensed version of Chapter 7 of my book &lt;strong&gt;&lt;a href="https://leanpub.com/theaigatewayplaybook" rel="noopener noreferrer"&gt;The AI Gateway Playbook&lt;/a&gt;&lt;/strong&gt;, which also covers the audit-log retention contract, keyless credential rotation, the metrics catalog behind these alerts, and the deployment and migration checklists. Related reading: &lt;a href="https://dev.to/zerokdevops/mcp-at-90-tools-the-catalog-breaks-before-the-server-does-3ibo"&gt;MCP at 90+ tools: the catalog breaks before the server does&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article and the book were drafted with AI assistance from my own experience designing and running this kind of platform. All examples are generic.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>devops</category>
      <category>sre</category>
      <category>ai</category>
    </item>
    <item>
      <title>MCP at 90+ tools: the catalog breaks before the server does</title>
      <dc:creator>Sanghyeok Yoon</dc:creator>
      <pubDate>Sun, 04 Oct 2026 20:08:21 +0000</pubDate>
      <link>https://dev.to/zerokdevops/mcp-at-90-tools-the-catalog-breaks-before-the-server-does-3ibo</link>
      <guid>https://dev.to/zerokdevops/mcp-at-90-tools-the-catalog-breaks-before-the-server-does-3ibo</guid>
      <description>&lt;p&gt;Exposing one internal tool over the Model Context Protocol is a weekend project. Exposing ninety of them to humans &lt;em&gt;and&lt;/em&gt; agents, without turning the MCP endpoint into a credential dump and the audit log into a fire hazard, is a platform problem.&lt;/p&gt;

&lt;p&gt;This post is the short version of how I'd design that platform. The one-line answer: &lt;strong&gt;treat the MCP server as a tool broker in the same family as your LLM gateway.&lt;/strong&gt; Same identity rules (no static keys), same metering shape (every invocation is an event), same audit discipline. Plus two things the LLM path doesn't need: an envelope for stored third-party credentials, and a PII masking step for what flows back.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Organize the catalog by category, not by tool
&lt;/h2&gt;

&lt;p&gt;At 90+ tools, the first failures are organizational, not performance. A few rules keep the catalog sane:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Naming is a contract:&lt;/strong&gt; &lt;code&gt;{category}.{verb}_{object}&lt;/code&gt;, e.g. &lt;code&gt;issues.create_ticket&lt;/code&gt;, &lt;code&gt;mail.send_draft&lt;/code&gt;, &lt;code&gt;ci.get_pipeline_logs&lt;/code&gt;. Renaming a tool is a breaking change with a deprecation window, not a silent edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tool = one unit of permission, audit, and test.&lt;/strong&gt; A "do everything in the issue tracker" tool is a permission you can't grant safely, an audit row you can't read, and a test you can't write. Split until each tool is all three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version in the schema, not the name&lt;/strong&gt; (&lt;code&gt;issues.create_ticket@v2&lt;/code&gt;), and serve all live versions during the deprecation window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant tool groups, not the universe.&lt;/strong&gt; The server barely notices 94 schemas. The &lt;em&gt;client&lt;/em&gt; does: an agent that loads every schema can burn 30k+ tokens of context on tool definitions alone. Clients request a group (&lt;code&gt;ci&lt;/code&gt;, &lt;code&gt;mail&lt;/code&gt;), and the group is the grant unit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Tool design patterns that keep agents (and auditors) sane
&lt;/h2&gt;

&lt;p&gt;A tool definition worth reviewing looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;issues.create_ticket@v2&lt;/span&gt;
&lt;span class="na"&gt;input_schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;maxLength&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;permission_denied&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;validation_failed&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;upstream_error&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rate_limited&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;pii&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mask-on-store&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;min_role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;contributor&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;idempotency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;client_key&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patterns behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys on retryable writes.&lt;/strong&gt; A retried create with the same key returns the same ticket, not a duplicate. Make it a registration-time lint for write tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are part of the contract.&lt;/strong&gt; Normalize upstream failures into a small taxonomy with a &lt;code&gt;retryable&lt;/code&gt; flag (and &lt;code&gt;Retry-After&lt;/code&gt; when you have it). Agents retry on the flag; humans read the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads are cheap, writes are loud.&lt;/strong&gt; Writes need a minimum role and a scope (project, space, folder). The audit event for a write carries the full (masked) input; for a read, just the shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound every list.&lt;/strong&gt; Default &lt;code&gt;limit&lt;/code&gt; 50, server cap 200, cursor pagination. An unbounded list tool is a context-window bomb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return what was asked, not what you know.&lt;/strong&gt; Enrichment ("related tickets") is a second tool the agent can choose to call. Tools that over-return are tools agents can't plan around.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Auth: OAuth 2.1 + PKCE, with a few non-negotiables
&lt;/h2&gt;

&lt;p&gt;The happy path is simple: the client runs the authorization-code flow with PKCE against your identity platform, gets a short-lived access token, and presents it on the MCP endpoint. The server verifies it, resolves the principal, and checks tool grants. What makes it actually safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PKCE for every client type&lt;/strong&gt;, including IDEs and agent runtimes. MCP clients usually can't hold a secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource indicators.&lt;/strong&gt; The token's audience is the MCP server. A valid token minted for some other service gets rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic client registration is not a grant.&lt;/strong&gt; DCR keeps "bring your own agent runtime" possible (redirect URI allowlist, auth method checks), but tool access still flows through an explicit grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One token-time posture for the org.&lt;/strong&gt; For example: 15-minute access tokens for interactive clients, 1 hour for workloads, no refresh tokens for agents (they re-authenticate through their workload identity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scopes per tool group&lt;/strong&gt; (&lt;code&gt;mcp:ci:read&lt;/code&gt;, &lt;code&gt;mcp:issues:write&lt;/code&gt;), so the consent screen is readable instead of a wall of 94 strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client's token never travels to the SaaS.&lt;/strong&gt; The broker calls downstream systems with its own credential and carries the principal in-band for audit and metering. Where a downstream system has a clean per-user delegation model, use that instead; it's the stronger design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Stored secrets: envelope encryption, and why it isn't the control
&lt;/h2&gt;

&lt;p&gt;The broker has to store credentials for the systems behind it. Do it with a standard envelope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KMS key-encryption key (never leaves KMS, auto-rotated)
  └── data-encryption key per category (AES-256, wrapped by the KEK)
        └── secret value: AES-256-GCM(DEK, value), fresh 96-bit nonce per write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-category DEKs&lt;/strong&gt; keep the blast radius at one category. Per-user tokens get a per-user DEK under the category DEK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation is cheap by design:&lt;/strong&gt; the KMS rotates the KEK; a scheduled job re-wraps DEKs. Neither touches the secret ciphertexts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But encryption at rest only answers "a DB leak is not a credential leak."&lt;/strong&gt; The real control is that no role (except break-glass) can &lt;em&gt;read&lt;/em&gt; a secret. The broker &lt;em&gt;uses&lt;/em&gt; it in-process for one call, zeroes the buffer, and the audit event records the call and the principal, never the credential. A portal should show "credential present, last rotated N days ago", nothing more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Mask on the way back
&lt;/h2&gt;

&lt;p&gt;The LLM gateway can stay payload-blind. The MCP path can't: the tool result &lt;em&gt;is&lt;/em&gt; the product. So results pass through a PII masking step before they reach the client and before anything is stored, and the per-tool &lt;code&gt;pii&lt;/code&gt; field in the catalog says which inputs and outputs need it. This is also why write audits store masked inputs, not raw ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Broker, not proxy: no static keys, every invocation metered and audited&lt;/li&gt;
&lt;li&gt;Category naming, one-tool-one-permission, schema versioning, group grants&lt;/li&gt;
&lt;li&gt;Idempotency, error taxonomy, bounded lists, no over-returning&lt;/li&gt;
&lt;li&gt;OAuth 2.1 + PKCE, audience-bound tokens, DCR ≠ grant, group scopes&lt;/li&gt;
&lt;li&gt;Envelope encryption with per-category DEKs, secrets used and never read&lt;/li&gt;
&lt;li&gt;PII masking on results and on stored audit inputs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is a condensed version of Chapter 5 of my book &lt;strong&gt;&lt;a href="https://leanpub.com/theaigatewayplaybook" rel="noopener noreferrer"&gt;The AI Gateway Playbook&lt;/a&gt;&lt;/strong&gt;, which also covers the LLM gateway itself: model registry, keyless auth and RBAC, quotas and budgets, a RAG team assistant, and operations runbooks. Free sample on the Leanpub page.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article and the book were drafted with AI assistance from my own experience designing and running this kind of platform. All examples are generic.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>The LLM Gateway I Put in Production: 4 Decisions That Actually Mattered</title>
      <dc:creator>Sanghyeok Yoon</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:43:18 +0000</pubDate>
      <link>https://dev.to/zerokdevops/the-llm-gateway-i-put-in-production-4-decisions-that-actually-mattered-5797</link>
      <guid>https://dev.to/zerokdevops/the-llm-gateway-i-put-in-production-4-decisions-that-actually-mattered-5797</guid>
      <description>&lt;p&gt;Your team is wiring services directly to provider APIs. Every app has its own API key, its own retry logic, its own hardcoded model name. Nobody can answer "how much are we spending on LLM this month," and when a model gets deprecated, you find out through a 500 error in production.&lt;/p&gt;

&lt;p&gt;I've been working on LLM infrastructure for a few years now, and the pattern I keep coming back to is an in-house LLM gateway: a stateless proxy and a small control plane. Not an "AI platform" - just a boring, dependable layer. Here are the four decisions that actually mattered when putting one in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph Clients
        A[Service A&amp;lt;br/&amp;gt;team: payments]
        B[Service B&amp;lt;br/&amp;gt;team: growth]
        H[Developer&amp;lt;br/&amp;gt;IDE / CLI]
    end
    subgraph GW["LLM Gateway (stateless)"]
        AUTH[Auth: JWT, RBAC]
        ROUTE[Routing: alias, quotas, failover]
        METER[Metering: usage, cost]
    end
    subgraph CP["Control plane (async)"]
        REG[Model registry: aliases, prices]
        Q[Quotas / budgets]
    end
    subgraph Providers
        PA[Provider A]
        PB[Provider B]
    end
    A --&amp;gt;|short-lived JWT| AUTH
    B --&amp;gt;|short-lived JWT| AUTH
    H --&amp;gt;|user token, OBO| AUTH
    AUTH --&amp;gt; ROUTE --&amp;gt; PA
    ROUTE --&amp;gt; PB
    ROUTE -.-&amp;gt; REG
    PA --&amp;gt;|usage| METER
    PB --&amp;gt;|usage| METER
    METER --&amp;gt; BUS[(message bus)] --&amp;gt; ROLL[(rollups)] --&amp;gt; DASH[Dashboards + alerts]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Two planes, four rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One control plane, one data plane.&lt;/strong&gt; Everything a human changes (registry, roles, budgets, prices) lives in the control plane. The proxy is stateless and only &lt;em&gt;reads&lt;/em&gt; control-plane state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is metered.&lt;/strong&gt; If a request does not produce a metering event, it did not happen. The metering event is as real as the HTTP response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secrets in requests.&lt;/strong&gt; The only credentials on the wire are short-lived identity tokens. Provider credentials never leave the gateway's secret view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boring technology.&lt;/strong&gt; One language, one message format, one stream. The gateway's job is to be the least interesting component in your architecture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The load path: clients → auth → routing → provider → stream back → metering → message bus → rollups → dashboards → alerts. The control plane is never in the hot path; it writes state, and the gateway picks up changes on a short TTL (1-5 seconds is fine).&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: A model registry that syncs itself
&lt;/h2&gt;

&lt;p&gt;Lock-in happens quietly. One day a provider deprecates a model name and your services die. The fix is a model registry with aliases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apps talk to aliases (&lt;code&gt;chat-fast&lt;/code&gt;, &lt;code&gt;chat-reasoning&lt;/code&gt;, &lt;code&gt;embeddings-1k&lt;/code&gt;), not provider model IDs.&lt;/li&gt;
&lt;li&gt;A sync job polls each provider's &lt;code&gt;/models&lt;/code&gt; endpoint and updates availability and pricing.&lt;/li&gt;
&lt;li&gt;When a provider model disappears, the alias routes to a fallback model instead of failing.&lt;/li&gt;
&lt;li&gt;Price updates flow through the same sync, so your cost dashboard is never stale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two tables, one scheduled job. No microservice forest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: Auth that works for services, humans, and agents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Services&lt;/strong&gt; get short-lived JWTs from your identity platform, verified against a JWKS cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Humans&lt;/strong&gt; (developers in IDEs or CLIs) get on-behalf-of tokens, so you can attribute usage to people, not shared service accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; get service tokens with narrower scope than the services they call.&lt;/li&gt;
&lt;li&gt;Provider credentials are resolved inside the gateway via managed identities - no API keys in your config files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fail closed on unknown alias, fail closed on invalid token. Quota counters are best-effort under high load (over-admit and alert); policies fail closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: Cost tracking where the metering event is the source of truth
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every request produces a usage object: tokens in/out, model, alias, team, user.&lt;/li&gt;
&lt;li&gt;Usage events go to a durable message bus; rollup jobs aggregate them into per-model / per-tenant / per-user tables.&lt;/li&gt;
&lt;li&gt;Budgets are counters (Redis) plus policies (SQL), with alerts at 80% and 100% of monthly budget per team and per model.&lt;/li&gt;
&lt;li&gt;The rollups DB can lag. It cannot lose. Recompute from the bus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once this is in place, "how much does this feature cost?" stops being a product meeting and becomes a dashboard question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: MCP as the interface for your AI tools
&lt;/h2&gt;

&lt;p&gt;If agents or tools will talk to models, they should talk through the same gateway, with the Model Context Protocol (MCP) as the stable interface. One endpoint, any provider behind it. The gateway becomes the only place in your org that knows which provider is actually serving your traffic today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What v1 looks like
&lt;/h2&gt;

&lt;p&gt;One stateless service plus scheduled sync jobs. No orchestration framework, no second database. Everything else (portal, per-user budgets, fine-grained failover) can be added later without breaking the seams.&lt;/p&gt;




&lt;p&gt;I wrote everything I learned into a book - reference architecture, configuration walkthroughs, an operations runbook, and a production launch checklist: &lt;strong&gt;The AI Gateway Playbook&lt;/strong&gt; (32,000 words, $19, &lt;a href="https://ko-fi.com/s/5159c92ef2" rel="noopener noreferrer"&gt;PDF + diagrams&lt;/a&gt;). Proceeds keep this kind of writing going.&lt;/p&gt;

&lt;p&gt;If you're building an LLM gateway (or you built the one that's a mess of API keys and hardcoded model names), what's the hardest production problem you hit? I'd love to hear about it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>architecture</category>
      <category>api</category>
    </item>
  </channel>
</rss>
